diff --git a/README.md b/README.md index f19dbbb..90b0739 100644 --- a/README.md +++ b/README.md @@ -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--.plan.md` is a transient working copy carrying an `` header. Subcommands: `new "" [--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 @@ -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 diff --git a/lib/dev/learnings.rb b/lib/dev/learnings.rb index 93dcbba..14886dd 100644 --- a/lib/dev/learnings.rb +++ b/lib/dev/learnings.rb @@ -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" diff --git a/lib/dev/learnings/accessor.rb b/lib/dev/learnings/accessor.rb index 72c3723..e9d3716 100644 --- a/lib/dev/learnings/accessor.rb +++ b/lib/dev/learnings/accessor.rb @@ -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 @@ -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. @@ -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 @@ -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 @@ -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. @@ -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 @@ -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. # diff --git a/lib/dev/learnings/cache.rb b/lib/dev/learnings/cache.rb index 9a78dd0..5d48953 100644 --- a/lib/dev/learnings/cache.rb +++ b/lib/dev/learnings/cache.rb @@ -3,6 +3,7 @@ require "fileutils" require "open3" require "pathname" +require_relative "layout" module Dev module Learnings @@ -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 @@ -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 diff --git a/lib/dev/learnings/layout.rb b/lib/dev/learnings/layout.rb new file mode 100644 index 0000000..8b2dab8 --- /dev/null +++ b/lib/dev/learnings/layout.rb @@ -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 diff --git a/lib/dev/learnings/scaffolder.rb b/lib/dev/learnings/scaffolder.rb new file mode 100644 index 0000000..67bc508 --- /dev/null +++ b/lib/dev/learnings/scaffolder.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "fileutils" +require "pathname" +require_relative "layout" + +module Dev + module Learnings + # Materializes the canonical learnings scaffold — Layout owns the paths + # and templates, this class only writes them. The scaffold is + # write-once-committed: it seeds an empty index that humans and capture + # passes own afterward, so an existing index is never touched (the typed + # error lets the CLI report the no-op cleanly). + # + # Stateless: one reusable instance scaffolds any root. + class Scaffolder + # The tier's index already exists at the target root — the scaffold is + # write-once and never overwrites a committed index. + class IndexAlreadyExistsError < RuntimeError; end + + # Keeps the scaffolded (empty) org skills corpus commitable — git does + # not track empty directories. + GITKEEP_FILENAME = ".gitkeep" + + # Write the empty repo-tier index into a participating repo. + # + # @param repo_root [Pathname, String] the repo's root + # @return [void] + # @raise [IndexAlreadyExistsError] when the repo already has an index + def scaffold_repo(repo_root) + write_index(Layout.repo_index_file(repo_root), Layout::REPO_INDEX_SCAFFOLD) + end + + # Write the knowledge-repo layout (index plus the skills corpus + # directory) into an org's knowledge repo checkout. + # + # @param org_root [Pathname, String] the knowledge repo's root + # @return [void] + # @raise [IndexAlreadyExistsError] when the checkout already has an index + def scaffold_org(org_root) + write_index(Layout.org_index_file(org_root), Layout::ORG_INDEX_SCAFFOLD) + skills_dir = Layout.org_skills_dir(org_root) + FileUtils.mkdir_p(skills_dir) + FileUtils.touch(skills_dir / GITKEEP_FILENAME) + end + + private + + # @param index_file [Pathname] + # @param scaffold [String] the tier's template + # @return [void] + # @raise [IndexAlreadyExistsError] when the index already exists + def write_index(index_file, scaffold) + if index_file.exist? + raise IndexAlreadyExistsError, + "#{index_file} already exists — the scaffold is write-once; edit or remove the index by hand instead." + end + + FileUtils.mkdir_p(index_file.dirname) + index_file.write(scaffold) + end + end + end +end diff --git a/src/dev/runner.rb b/src/dev/runner.rb index c7c45b4..ce706c3 100644 --- a/src/dev/runner.rb +++ b/src/dev/runner.rb @@ -274,7 +274,8 @@ def register_builtins(registry) # `dev learnings` is dispatched globally in bin/dev, like cd; this # registration only surfaces it in `dev --help`. registry.register("learnings", BuiltinCommand.new( - desc: "Learnings read path (sync: refresh now, status: what's linked, invariants: Tier-0 block)", + desc: "Learnings read path (sync: refresh now, status: what's linked, invariants: Tier-0 block, " \ + "init: scaffold the index)", ) do |args, context| Dev::Learnings::Accessor.new(project_root: context.project_root).run(args) end) diff --git a/test/dev/learnings/accessor_test.rb b/test/dev/learnings/accessor_test.rb index 0be2aca..54b43b1 100644 --- a/test/dev/learnings/accessor_test.rb +++ b/test/dev/learnings/accessor_test.rb @@ -336,6 +336,98 @@ def backdate_cache(cache, seconds) FileUtils.rm_rf(dir) end + test "init scaffolds the repo-tier index and says to commit it" do + Given "a project without a learnings index" + dir = Dir.mktmpdir("dev-learnings-acc-test-") + accessor, _cache, project, = build_env(dir) + out = StringIO.new + + When "running dev learnings init" + accessor.run(["init"], out: out) + + Then "the canonical always-on index exists and the report points at committing it" + index = Dev::Learnings::Layout.repo_index_file(project) + index.file? + index.read.include?("alwaysApply: true") + out.string.include?("scaffolded #{index}") + out.string.include?("commit") + + Cleanup + FileUtils.rm_rf(dir) + end + + test "init --org scaffolds the knowledge-repo layout" do + Given "a knowledge repo checkout without an index" + dir = Dir.mktmpdir("dev-learnings-acc-test-") + accessor, _cache, project, = build_env(dir) + out = StringIO.new + + When "running dev learnings init --org" + accessor.run(["init", "--org"], out: out) + + Then "index.md carries the fixed section structure and skills/ exists beside it" + index = Dev::Learnings::Layout.org_index_file(project) + index.file? + index.read.include?("## Invariants (always-on)") + index.read.include?("## Knowledge (on-demand)") + Dev::Learnings::Layout.org_skills_dir(project).directory? + out.string.include?("scaffolded #{index}") + + Cleanup + FileUtils.rm_rf(dir) + end + + test "init is idempotent — an existing index is reported and left untouched" do + Given "a scaffolded project whose index has since been hand-edited" + dir = Dir.mktmpdir("dev-learnings-acc-test-") + accessor, _cache, project, = build_env(dir) + accessor.run(["init"], out: StringIO.new) + index = Dev::Learnings::Layout.repo_index_file(project) + index.write("# hand-curated entries\n") + out = StringIO.new + + When "running dev learnings init again" + accessor.run(["init"], out: out) + + Then "the run reports the write-once no-op and the index is untouched" + out.string.include?("already exists") + out.string.include?("write-once") + index.read == "# hand-curated entries\n" + + Cleanup + FileUtils.rm_rf(dir) + end + + test "init outside a project raises — there is nowhere to scaffold" do + Given "a configured accessor with no enclosing project" + dir = Dir.mktmpdir("dev-learnings-acc-test-") + accessor, = build_env(dir, project_root: nil) + + When "running dev learnings init" + accessor.run(["init"], out: StringIO.new) + + Then + raises Dev::Learnings::Accessor::NoEnclosingProjectError + + Cleanup + FileUtils.rm_rf(dir) + end + + test "init with an unknown flag is rejected with usage" do + Given "a configured accessor" + dir = Dir.mktmpdir("dev-learnings-acc-test-") + accessor, = build_env(dir) + + When "running init with a flag that isn't --org" + accessor.run(["init", "--bogus"], out: StringIO.new) + + Then + raises Dev::Learnings::Accessor::UsageError + + Cleanup + FileUtils.rm_rf(dir) + end + test "an unrecognized invocation is rejected with usage" do Given "a configured accessor" dir = Dir.mktmpdir("dev-learnings-acc-test-") diff --git a/test/dev/learnings/layout_test.rb b/test/dev/learnings/layout_test.rb new file mode 100644 index 0000000..0a4ecaf --- /dev/null +++ b/test/dev/learnings/layout_test.rb @@ -0,0 +1,60 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/learnings" +require "pathname" + +transform!(RSpock::AST::Transformation) +class Dev::Learnings::LayoutTest < Minitest::Test + test "the repo tier lives inside the repo's .cursor tree" do + Given "a repo root" + root = Pathname("/some/repo") + + Expect "the index rule and the detail skills resolve under .cursor" + Dev::Learnings::Layout.repo_index_file(root) == Pathname("/some/repo/.cursor/rules/learnings-index.mdc") + Dev::Learnings::Layout.repo_skill_file(root, "some-lesson") == + Pathname("/some/repo/.cursor/skills/learnings/some-lesson/SKILL.md") + end + + test "the org tier lives at the knowledge repo's root" do + Given "a knowledge repo root" + root = Pathname("/some/knowledge") + + Expect "the index and the skills corpus resolve beside each other" + Dev::Learnings::Layout.org_index_file(root) == Pathname("/some/knowledge/index.md") + Dev::Learnings::Layout.org_skills_dir(root) == Pathname("/some/knowledge/skills") + Dev::Learnings::Layout.org_skill_file(root, "some-skill") == Pathname("/some/knowledge/skills/some-skill/SKILL.md") + end + + test "path helpers accept plain string roots and return Pathnames" do + Expect + Dev::Learnings::Layout.repo_index_file("/some/repo") == Pathname("/some/repo/.cursor/rules/learnings-index.mdc") + Dev::Learnings::Layout.org_index_file("/some/knowledge") == Pathname("/some/knowledge/index.md") + end + + test "the repo scaffold is a canonical always-on index with no entries" do + Given "the repo-tier template" + scaffold = Dev::Learnings::Layout::REPO_INDEX_SCAFFOLD + + Expect "front matter, capture/curation preamble, soft cap, and org-tier trailer — no entry lines" + scaffold.start_with?("---\n") + scaffold.include?("description:") + scaffold.include?("alwaysApply: true") + scaffold.include?("capture-learning") + scaffold.include?("Soft cap ~50 entries") + scaffold.include?("## org tier") + !scaffold.match?(/^- \[/) + end + + test "the org scaffold carries the fixed section structure dev parses" do + Given "the org-tier template" + scaffold = Dev::Learnings::Layout::ORG_INDEX_SCAFFOLD + + Expect "both fixed headings, invariants first, with no entry lines" + scaffold.include?("## Invariants (always-on)") + scaffold.include?("## Knowledge (on-demand)") + scaffold.index("## Invariants (always-on)") < scaffold.index("## Knowledge (on-demand)") + !scaffold.match?(/^- \[/) + end +end diff --git a/test/dev/learnings/scaffolder_test.rb b/test/dev/learnings/scaffolder_test.rb new file mode 100644 index 0000000..8d28099 --- /dev/null +++ b/test/dev/learnings/scaffolder_test.rb @@ -0,0 +1,93 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/learnings" +require "fileutils" +require "tmpdir" + +transform!(RSpock::AST::Transformation) +class Dev::Learnings::ScaffolderTest < Minitest::Test + test "scaffold_repo writes the empty always-on index at the canonical path" do + Given "a repo with no learnings index" + dir = Dir.mktmpdir("dev-learnings-scaffolder-test-") + + When "scaffolding the repo tier" + Dev::Learnings::Scaffolder.new.scaffold_repo(dir) + + Then "the index carries the repo-tier template, front matter included" + index = Dev::Learnings::Layout.repo_index_file(dir) + index.file? + index.read == Dev::Learnings::Layout::REPO_INDEX_SCAFFOLD + index.read.include?("alwaysApply: true") + + Cleanup + FileUtils.rm_rf(dir) + end + + test "scaffold_repo refuses an existing index with a typed error" do + Given "a repo whose index is already committed" + dir = Dir.mktmpdir("dev-learnings-scaffolder-test-") + index = Dev::Learnings::Layout.repo_index_file(dir) + FileUtils.mkdir_p(index.dirname) + index.write("# hand-curated\n") + + When "scaffolding the repo tier again" + Dev::Learnings::Scaffolder.new.scaffold_repo(dir) + + Then + raises Dev::Learnings::Scaffolder::IndexAlreadyExistsError + + Cleanup + FileUtils.rm_rf(dir) + end + + test "scaffold_org writes the knowledge-repo layout: the index plus a commitable skills corpus" do + Given "an empty knowledge repo checkout" + dir = Dir.mktmpdir("dev-learnings-scaffolder-test-") + + When "scaffolding the org tier" + Dev::Learnings::Scaffolder.new.scaffold_org(dir) + + Then "index.md carries the org template and skills/ exists with its gitkeep" + index = Dev::Learnings::Layout.org_index_file(dir) + index.file? + index.read == Dev::Learnings::Layout::ORG_INDEX_SCAFFOLD + skills_dir = Dev::Learnings::Layout.org_skills_dir(dir) + skills_dir.directory? + (skills_dir / Dev::Learnings::Scaffolder::GITKEEP_FILENAME).file? + + Cleanup + FileUtils.rm_rf(dir) + end + + test "scaffold_org refuses an existing index with a typed error" do + Given "a knowledge repo checkout that already carries an index" + dir = Dir.mktmpdir("dev-learnings-scaffolder-test-") + Dev::Learnings::Layout.org_index_file(dir).write("# curated org index\n") + + When "scaffolding the org tier again" + Dev::Learnings::Scaffolder.new.scaffold_org(dir) + + Then + raises Dev::Learnings::Scaffolder::IndexAlreadyExistsError + + Cleanup + FileUtils.rm_rf(dir) + end + + test "a scaffolded org index is already parseable by the invariants renderer" do + Given "a freshly scaffolded knowledge repo checkout" + dir = Dir.mktmpdir("dev-learnings-scaffolder-test-") + Dev::Learnings::Scaffolder.new.scaffold_org(dir) + + When "extracting the Tier-0 prompt block from the scaffolded index" + block = Dev::Learnings::InvariantsRenderer.new.prompt_block(Dev::Learnings::Layout.org_index_file(dir)) + + Then "the fixed invariants section is recognized (empty of entries, but present)" + !block.nil? + + Cleanup + FileUtils.rm_rf(dir) + end +end