Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ Custom integrations implement `Dev::Deps::Integration` (with `install_all(pins,
- **`dev cache gc [--keep N]`** — reclaim host caches dev owns (see below).
- **`dev reset-container`** — remove the persistent build container (clears its incremental cache); registered only when `build.container.persist` is set.
- **`dev plan …`** — global (works without a `dev.yml`; the workspace is the nearest dev.yml or git root). Sync Cursor plans with GitHub issues (ai-flow): the issue is the canonical plan, the local `.cursor/plans/gh-<n>-<slug>.plan.md` is a transient working copy carrying an `<!-- ai-flow … -->` header. Subcommands: `new "<title>" [--org]` (create issue + linked plan; `--org` scaffolds a `Target repos:` line), `link <n> [<file>]` / `link <file>` (attach a draft to an existing issue / create one from it), `pull <n> [--merge]` (fetch, 3-way merging when both sides changed — the merge base lives at `~/.local/state/ai-flow/`), `push [<file>|<n>]` (guarded body PATCH — refuses to clobber newer remote edits; a number resolves the linked plan like `pull`), and `status` (clean / ahead / behind / diverged, per linked plan). `--org` targets the org plans repo (`plans_repo:` in `~/.config/dev/config.yml`, or `DEV_PLANS_REPO`) instead of the current repo's origin. Every invocation also refreshes the user-global links for dev's shipped skills (`share/cursor-skills/*` → `~/.cursor/skills/`, so the Cursor agent knows these verbs) and the org learnings artifacts (see [Agent skills & org learnings](#agent-skills--org-learnings)). For auto-push, a participating repo adds a Cursor `afterFileEdit` hook to `.cursor/hooks.json` running `dev plan hook-after-edit` — it reads the hook payload from stdin and no-ops unless the edited file is a linked plan. What happens to a plan after it's canonical — `/ask`, `/edit`, `/split` (two-phase dry/apply), `/build` — is ai-flow's remote half: see [plan-lifecycle.md](https://github.com/d3mlabs/ai-flow/blob/HEAD/docs/plan-lifecycle.md) and [commands.md](https://github.com/d3mlabs/ai-flow/blob/HEAD/docs/commands.md).
- **`dev learnings sync|status|invariants`** — global (works without a `dev.yml`). `sync` refreshes the whole learnings read path now, blocking, errors bubbling: pull the machine cache of the knowledge repo, relink skills (shipped, org, and the project's gem skills), render the invariants rule and link it into the enclosing project. Outside a project the machine-global parts run and the project-scoped ones are skipped. `status` reports the configured knowledge repo, cache location and age, and what's rendered/linked per tier. `invariants` prints the Tier-0 prompt block (the invariants section extracted from the org index) — the seam prompt-building consumers like ai-flow shell out to instead of parsing the cache themselves. See [Agent skills & org learnings](#agent-skills--org-learnings).
- **`dev learnings sync|status|invariants|init`** — global (works without a `dev.yml`). `sync` refreshes the whole learnings read path now, blocking, errors bubbling: pull the machine cache of the knowledge repo, relink skills (shipped, org, and the project's gem skills), render the invariants rule and link it into the enclosing project. Outside a project the machine-global parts run and the project-scoped ones are skipped. `status` reports the configured knowledge repo, cache location and age, and what's rendered/linked per tier. `invariants` prints the Tier-0 prompt block (the invariants section extracted from the org index) — the seam prompt-building consumers like ai-flow shell out to instead of parsing the cache themselves. `init` scaffolds the canonical empty learnings layout at the enclosing repo's root: the repo-tier index (`.cursor/rules/learnings-index.mdc` with its `alwaysApply: true` front matter, capture/curation preamble, soft cap, and org-tier trailer — no entries), or with `--org` the knowledge-repo layout (`index.md` with the fixed `## Invariants (always-on)` / `## Knowledge (on-demand)` section structure `dev learnings sync` parses, plus the `skills/` corpus directory) for a new org adopting the loop. The scaffold is **write-once-committed**: an existing index is reported and left untouched (exit 0), so consumers such as ai-flow's `/learn` call `init` unconditionally before capturing into an unseeded repo. `Dev::Learnings::Layout` is the canonical owner of both tiers' paths and templates. See [Agent skills & org learnings](#agent-skills--org-learnings).

## Agent skills & org learnings

Expand All @@ -375,7 +375,7 @@ dev distributes agent-facing skills (Cursor-style `SKILL.md` directories) over t

### Repo learnings

Alongside the distributed channels, a repo can carry **committed learnings** — lessons distilled from review feedback, builds, and scans — as an always-on index (`.cursor/rules/learnings-index.mdc`, one `[domain/slug]` line + trigger sentence per learning) pointing at on-demand detail skills (`.cursor/skills/learnings/<slug>/SKILL.md`; architecture digests under `.cursor/skills/architecture/<topic>/`). Committed files need no distribution step: every checkout — IDE, runner, worktree — has them by construction. The index defines its own format in its preamble; this repo's copy is the reference. Capture goes through the `capture-learning` skill (shipped in `share/cursor-skills/`, so it is available in every IDE session) or ai-flow's `/learn` command on GitHub surfaces — both stage learnings as proposal PRs, and human merge is the curation gate.
Alongside the distributed channels, a repo can carry **committed learnings** — lessons distilled from review feedback, builds, and scans — as an always-on index (`.cursor/rules/learnings-index.mdc`, one `[domain/slug]` line + trigger sentence per learning) pointing at on-demand detail skills (`.cursor/skills/learnings/<slug>/SKILL.md`; architecture digests under `.cursor/skills/architecture/<topic>/`). Committed files need no distribution step: every checkout — IDE, runner, worktree — has them by construction. The index defines its own format in its preamble; this repo's copy is the reference, and `dev learnings init` seeds an unseeded repo with the same canonical (empty) index — write-once: after the scaffold is committed, humans and capture passes own the file. Capture goes through the `capture-learning` skill (shipped in `share/cursor-skills/`, so it is available in every IDE session) or ai-flow's `/learn` command on GitHub surfaces — both stage learnings as proposal PRs, and human merge is the curation gate.

## Build container & caching model

Expand Down
2 changes: 2 additions & 0 deletions lib/dev/learnings.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require "dev/learnings/layout"
require "dev/learnings/scaffolder"
require "dev/learnings/cache"
require "dev/learnings/invariants_renderer"
require "dev/learnings/synchronizer"
Expand Down
49 changes: 48 additions & 1 deletion lib/dev/learnings/accessor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
require_relative "../skill_installer"
require_relative "cache"
require_relative "invariants_renderer"
require_relative "layout"
require_relative "scaffolder"
require_relative "synchronizer"

module Dev
Expand All @@ -25,6 +27,11 @@ module Learnings
# - `invariants` — print the Tier-0 prompt block (the invariants section
# extracted from the org index); the seam prompt-building
# consumers like ai-flow shell out to
# - `init` — scaffold the canonical learnings layout (repo tier, or
# the org knowledge-repo layout with --org); write-once —
# an existing index is reported and left untouched, so
# consumers (e.g. ai-flow's /learn) can call it
# unconditionally before capturing
#
# RuntimeError subclasses throughout so the CLI boundary prints clean
# `dev:` messages instead of backtraces.
Expand All @@ -35,11 +42,17 @@ class UsageError < RuntimeError; end
# configured, no cache cloned yet, or no invariants section upstream.
class InvariantsUnavailableError < RuntimeError; end

# `dev learnings init` ran outside any project — there is no root to
# scaffold into.
class NoEnclosingProjectError < RuntimeError; end

USAGE = <<~USAGE.strip
usage: dev learnings <subcommand>
dev learnings sync refresh the whole read path now (blocking): knowledge repo cache, skill links, invariants render
dev learnings status configured knowledge repo, cache location/age, what's rendered and linked
dev learnings invariants print the always-on org invariants block (the Tier-0 prompt seam)
dev learnings init scaffold this repo's empty learnings index (write-once: an existing index is left untouched)
dev learnings init --org scaffold the org knowledge-repo layout (index.md + skills/) here, same write-once semantics
USAGE

# @param project_root [Pathname, String, nil] the enclosing project for
Expand All @@ -54,9 +67,10 @@ class InvariantsUnavailableError < RuntimeError; end
# @param gem_skill_linker [Dev::Deps::GemSkillLinker, nil] override for
# tests; defaults to the project's linker (nil outside a project)
# @param renderer [Dev::Learnings::InvariantsRenderer]
# @param scaffolder [Dev::Learnings::Scaffolder]
def initialize(project_root:, settings: Dev::Settings.new, cache: nil, synchronizer: nil,
skill_installer: Dev::SkillInstaller.new, gem_skill_linker: nil,
renderer: InvariantsRenderer.new)
renderer: InvariantsRenderer.new, scaffolder: Scaffolder.new)
@project_root = project_root && Pathname(project_root)
@settings = settings
repo = settings.knowledge_repo
Expand All @@ -66,6 +80,7 @@ def initialize(project_root:, settings: Dev::Settings.new, cache: nil, synchroni
@gem_skill_linker = gem_skill_linker ||
(@project_root && Dev::Deps::GemSkillLinker.new(project_root: @project_root))
@renderer = renderer
@scaffolder = scaffolder
end

# Dispatch a `dev learnings …` invocation.
Expand All @@ -79,6 +94,8 @@ def run(args, out: $stdout)
when ["sync"] then sync(out:)
when ["status"] then status(out:)
when ["invariants"] then invariants(out:)
when ["init"] then init(out:)
when ["init", "--org"] then init(out:, org: true)
else raise UsageError, USAGE
end
end
Expand Down Expand Up @@ -140,6 +157,36 @@ def invariants(out:)
out.puts block
end

# Scaffold the canonical learnings layout at the enclosing project's
# root: the empty repo-tier index, or the org knowledge-repo layout
# with org: true. The scaffold is write-once-committed — an existing
# index makes this a reported no-op (exit 0), never an overwrite — so
# consumers can call init unconditionally before capturing.
#
# @param out [IO]
# @param org [Boolean] scaffold the org knowledge-repo layout instead
# of the repo tier
# @return [void]
# @raise [NoEnclosingProjectError] when run outside any project
def init(out:, org: false)
if @project_root.nil?
raise NoEnclosingProjectError,
"no enclosing project — run `dev learnings init` inside the repo to scaffold."
end

if org
@scaffolder.scaffold_org(@project_root)
out.puts "dev: scaffolded #{Layout.org_index_file(@project_root)} and " \
"#{Layout.org_skills_dir(@project_root)}/ (the org knowledge-repo layout) — commit them."
else
@scaffolder.scaffold_repo(@project_root)
out.puts "dev: scaffolded #{Layout.repo_index_file(@project_root)} " \
"(this repo's empty always-on learnings index) — commit it."
end
rescue Scaffolder::IndexAlreadyExistsError => e
out.puts "dev: #{e.message}"
end

# The org tier's rendered/linked state: the machine-side invariants
# render and the org skill links.
#
Expand Down
14 changes: 7 additions & 7 deletions lib/dev/learnings/cache.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
require "fileutils"
require "open3"
require "pathname"
require_relative "layout"

module Dev
module Learnings
Expand All @@ -28,11 +29,6 @@ class KnowledgeCloneError < RuntimeError; end
# `git pull` refreshing an existing cache failed.
class KnowledgeFetchError < RuntimeError; end

# Layout of the knowledge repo (see d3mlabs/knowledge's README): the
# always-on index at the root, the on-demand skills corpus beside it.
INDEX_FILE = "index.md"
SKILLS_SUBDIR = "skills"

OWNER_REPO_PATTERN = %r{\A[\w.-]+/[\w.-]+\z}

# How long a bounded refresh waits for the pull before detaching it and
Expand Down Expand Up @@ -70,14 +66,18 @@ def present?
(@dir / ".git").exist?
end

# The org tier's layout inside the cache (Layout is the canonical
# owner: the always-on index at the root, the on-demand skills corpus
# beside it).

# @return [Pathname] the on-demand skills corpus inside the cache
def skills_dir
@dir / SKILLS_SUBDIR
Layout.org_skills_dir(@dir)
end

# @return [Pathname] the org learnings index inside the cache
def index_file
@dir / INDEX_FILE
Layout.org_index_file(@dir)
end

# Blocking refresh: clone on first run, fast-forward pull after (the
Expand Down
118 changes: 118 additions & 0 deletions lib/dev/learnings/layout.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# frozen_string_literal: true

require "pathname"
require_relative "../skill_installer"

module Dev
module Learnings
# The canonical learnings layout, both tiers — the single owner of where
# the learning loop's committed files live and what an empty index looks
# like. Everything in dev that touches the layout (the `dev learnings
# init` scaffold, the knowledge cache, future subcommands) reads from
# here; ai-flow mirrors these paths as data with a pointer comment back
# at this module.
#
# - Repo tier: an always-on index rule plus committed detail skills,
# inside a repo's .cursor/ tree.
# - Org tier: the knowledge repo's root index — whose fixed section
# headings InvariantsRenderer parses — plus the on-demand skills
# corpus beside it.
module Layout
REPO_INDEX_SUBDIRS = [".cursor", "rules", "learnings-index.mdc"].freeze
REPO_SKILLS_SUBDIRS = [".cursor", "skills", "learnings"].freeze

ORG_INDEX_FILENAME = "index.md"
ORG_SKILLS_DIRNAME = "skills"

# The empty repo-tier index: the canonical front matter (`alwaysApply:
# true` is what makes the index load always-on — an improvised index
# without it is written but never read), the capture/curation
# preamble, the soft cap, and the org-tier trailer. No entries:
# capture passes and humans own those.
REPO_INDEX_SCAFFOLD = <<~SCAFFOLD
---
description: Always-on index of this repo's learnings — lessons distilled from reviews, builds, and scans. Read the pointed skill before working in an entry's territory.
alwaysApply: true
---

# Learnings index

One line per learning: `[domain/slug]`, the trigger sentence, the skill
pointer. The line buys awareness; the pointed skill carries the rule, a
wrong/right pair, and the origin — read it before working in that entry's
territory. Detail skills live in `.cursor/skills/learnings/<slug>/`
(architecture digests in `.cursor/skills/architecture/<topic>/`); gem and
org skills are pointed at wherever their channel installs them.

Capture and curation go through the capture-learning skill (IDE sessions)
or ai-flow's `/learn` (GitHub comments) — both land as proposal PRs; human
merge is the gate. Soft cap ~50 entries: at the cap, an addition must
propose a retirement, a consolidation, or a glob-scoped sub-index split.

## org tier

Org-wide invariants and knowledge live in the configured org knowledge
repo (`knowledge_repo:`), not here: dev renders the always-on slice once
machine-side and links it in as `.cursor/rules/org-invariants.mdc`, and
links the on-demand corpus into `~/.cursor/skills/`. A lesson about how
we build software — not about this repo — belongs there.
SCAFFOLD

# The empty org-tier index: the fixed section structure `dev learnings
# sync` parses. The invariants section carries only entry lines —
# everything in it is rendered into every project on every machine.
ORG_INDEX_SCAFFOLD = <<~SCAFFOLD
# Org learnings index

The org tier of the learning loop: invariants and knowledge about how
we build software, not about any one repo. One line per entry:
`[domain/slug]`, the trigger sentence, the skill pointer into
`skills/<slug>/`. The line buys awareness; the pointed skill carries
the rule — dev links the corpus into `~/.cursor/skills/` on every
machine.

The two section headings below are fixed — dev parses them. The
`## Invariants (always-on)` section is rendered into every project as
an always-on rule, so it carries only entry lines, never prose.

## Invariants (always-on)

## Knowledge (on-demand)
SCAFFOLD

module_function

# @param repo_root [Pathname, String] a participating repo's root
# @return [Pathname] the repo tier's always-on index rule
def repo_index_file(repo_root)
Pathname(repo_root).join(*REPO_INDEX_SUBDIRS)
end

# @param repo_root [Pathname, String] a participating repo's root
# @param slug [String] the learning's slug
# @return [Pathname] the repo-tier detail skill for the slug
def repo_skill_file(repo_root, slug)
Pathname(repo_root).join(*REPO_SKILLS_SUBDIRS, slug, SkillInstaller::SKILL_FILE)
end

# @param org_root [Pathname, String] a knowledge repo checkout (or cache)
# @return [Pathname] the org tier's index
def org_index_file(org_root)
Pathname(org_root) / ORG_INDEX_FILENAME
end

# @param org_root [Pathname, String] a knowledge repo checkout (or cache)
# @return [Pathname] the org tier's on-demand skills corpus
def org_skills_dir(org_root)
Pathname(org_root) / ORG_SKILLS_DIRNAME
end

# @param org_root [Pathname, String] a knowledge repo checkout (or cache)
# @param slug [String] the skill's slug
# @return [Pathname] the org-tier skill for the slug
def org_skill_file(org_root, slug)
org_skills_dir(org_root) / slug / SkillInstaller::SKILL_FILE
end
end
end
end
Loading
Loading