diff --git a/.changeset/silly-yaks-fly.md b/.changeset/silly-yaks-fly.md new file mode 100644 index 000000000..07cf0838f --- /dev/null +++ b/.changeset/silly-yaks-fly.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3810 +--- +**Completing a phase with `features.global_learnings` enabled now produces the phase's LEARNINGS.md automatically and copies it to the global store** — previously three shipped consumers read an artifact nothing ever generated, and the copy command read a project-root path the extractor never wrote, so the store stayed empty even after manual extraction. Extraction and copy failures never block completion; with the gate off (the default) behavior is unchanged. (#3683) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index be8b5fa94..262a3ef1a 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -1197,7 +1197,7 @@ Extract reusable patterns, anti-patterns, and architectural decisions from compl | `--format` | Output format: `markdown` (default), `json` | **Prerequisites:** Phase has been executed (SUMMARY.md files exist) -**Produces:** `.planning/learnings/{phase}-LEARNINGS.md` +**Produces:** `.planning/phases/{phase-dir}/{padded-phase}-LEARNINGS.md` **Extracts:** - Architectural decisions and their rationale diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 28b0eff77..85eee5680 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -2562,6 +2562,8 @@ capture_thought({ Users who run a memory / knowledge-base MCP server (for example, ExoCortex-style servers, `claude-mem`, or `mem0`-style servers) can implement this tool name to have learnings routed into their knowledge base automatically with `project`, `phase`, and `source` metadata. Everyone else can use `/gsd-extract-learnings` without any extra setup — the `LEARNINGS.md` artifact is the feature. +With `features.global_learnings: true`, phase completion runs the extraction for the just-completed phase automatically and copies the artifact to the global store at `~/.gsd/knowledge/` (#3683) — extraction and copy failures never block completion. With the gate off (the default), extraction stays fully manual. + --- ### 114. Context-Window-Aware Prompt Thinning diff --git a/docs/ja-JP/COMMANDS.md b/docs/ja-JP/COMMANDS.md index 9bef43e2c..056c7ed42 100644 --- a/docs/ja-JP/COMMANDS.md +++ b/docs/ja-JP/COMMANDS.md @@ -918,7 +918,7 @@ GSD の保証付きでアドホックタスクを実行します。 | `--format` | 出力フォーマット: `markdown`(デフォルト)、`json` | **前提条件:** フェーズが実行済みであること(SUMMARY.md ファイルが存在すること) -**生成物:** `.planning/learnings/{phase}-LEARNINGS.md` +**生成物:** `.planning/phases/{phase-dir}/{padded-phase}-LEARNINGS.md` **抽出内容:** - アーキテクチャ上の決定とその根拠 diff --git a/docs/ko-KR/COMMANDS.md b/docs/ko-KR/COMMANDS.md index 474179357..493594b5b 100644 --- a/docs/ko-KR/COMMANDS.md +++ b/docs/ko-KR/COMMANDS.md @@ -924,7 +924,7 @@ GSD 보장을 통해 애드혹 작업을 실행합니다. | `--format` | 출력 형식: `markdown` (기본값), `json` | **전제 조건:** 단계가 실행됨 (SUMMARY.md 파일 존재) -**생성 결과:** `.planning/learnings/{phase}-LEARNINGS.md` +**생성 결과:** `.planning/phases/{phase-dir}/{padded-phase}-LEARNINGS.md` **추출 내용:** - 아키텍처 결정 및 근거 diff --git a/docs/pt-BR/COMMANDS.md b/docs/pt-BR/COMMANDS.md index 1ff705d99..6ccafc38d 100644 --- a/docs/pt-BR/COMMANDS.md +++ b/docs/pt-BR/COMMANDS.md @@ -921,7 +921,7 @@ Extrai padrões reutilizáveis, antipadrões e decisões arquiteturais do trabal | `--format` | Formato de saída: `markdown` (padrão), `json` | **Pré-requisitos:** A fase foi executada (arquivos SUMMARY.md existem) -**Produz:** `.planning/learnings/{phase}-LEARNINGS.md` +**Produz:** `.planning/phases/{phase-dir}/{padded-phase}-LEARNINGS.md` **Extrai:** - Decisões arquiteturais e sua justificativa diff --git a/docs/zh-CN/COMMANDS.md b/docs/zh-CN/COMMANDS.md index e24b79363..4ca3b4e9b 100644 --- a/docs/zh-CN/COMMANDS.md +++ b/docs/zh-CN/COMMANDS.md @@ -918,7 +918,7 @@ ROADMAP.md 中阶段的 CRUD 操作 — 通过单一合并命令添加、插入 | `--format` | 输出格式:`markdown`(默认)、`json` | **前提条件:** 阶段已被执行(SUMMARY.md 文件已存在) -**产出:** `.planning/learnings/{phase}-LEARNINGS.md` +**产出:** `.planning/phases/{phase-dir}/{padded-phase}-LEARNINGS.md` **提取内容:** - 架构决策及其依据 diff --git a/gsd-core/templates/README.md b/gsd-core/templates/README.md index 7ead2e12a..acd8f0183 100644 --- a/gsd-core/templates/README.md +++ b/gsd-core/templates/README.md @@ -18,7 +18,7 @@ These files live directly at `.planning/` — not inside phase subdirectories. | `REQUIREMENTS.md` | `requirements.md` | `/gsd:new-milestone` | Functional requirements with traceability | | `MILESTONES.md` | `milestone.md` | `/gsd:complete-milestone` | Log of completed milestones with accomplishments | | `BACKLOG.md` | *(inline)* | `/gsd-add-backlog` | Pending ideas and deferred work | -| `LEARNINGS.md` | *(inline)* | `/gsd:extract-learnings`, `/gsd:execute-phase` | Phase retrospective learnings for future plans | +| `LEARNINGS.md` | *(inline)* | `/gsd:extract-learnings`, `/gsd:execute-phase` (gated: `features.global_learnings`) | Phase retrospective learnings for future plans | | `THREADS.md` | *(inline)* | `/gsd:thread` | Persistent discussion threads | | `config.json` | `config.json` | `/gsd:new-project`, `/gsd:health --repair` | Project-specific GSD configuration | | `CLAUDE.md` | `claude-md.md` | `/gsd-profile` | Auto-assembled Claude Code context file | diff --git a/gsd-core/workflows/execute-phase.md b/gsd-core/workflows/execute-phase.md index 29e1e2b3c..776bc29d1 100644 --- a/gsd-core/workflows/execute-phase.md +++ b/gsd-core/workflows/execute-phase.md @@ -1432,10 +1432,12 @@ gsd_run query commit "docs(phase-{X}): complete phase execution" --files .planni -**Auto-copy phase learnings to global store (when enabled).** +**Auto-extract and copy phase learnings to global store (when enabled).** -This step runs AFTER phase completion and SUMMARY.md is written. It copies any LEARNINGS.md -entries from the completed phase to the global learnings store at `~/.gsd/knowledge/`. +This step runs AFTER phase completion and SUMMARY.md is written. It produces the phase's +learnings artifact (the sole producer is otherwise the user-invoked +`/gsd:extract-learnings`) and copies it to the global learnings store at +`~/.gsd/knowledge/`. **Check config gate:** ```bash @@ -1446,8 +1448,10 @@ GL_ENABLED=$(gsd_run query config-get features.global_learnings --raw 2>/dev/nul **If enabled:** -1. Check if LEARNINGS.md exists in the phase directory (use the `phase_dir` value from init context) -2. If found, copy to global store: +1. Run the `extract-learnings` workflow for the JUST-COMPLETED phase (its + `write_learnings` step writes `{phase_dir}/{PADDED_PHASE}-LEARNINGS.md`). Extraction + failure must NOT block phase completion — report the failure and continue. +2. Copy the phase artifact to the global store: ```bash gsd_run query learnings.copy 2>/dev/null || echo "⚠ Learnings copy failed — continuing" ``` diff --git a/scripts/docs-guard-registry.cjs b/scripts/docs-guard-registry.cjs index 22a3c8fcd..e32f2e1ab 100644 --- a/scripts/docs-guard-registry.cjs +++ b/scripts/docs-guard-registry.cjs @@ -161,6 +161,10 @@ const DOCS_GUARD_TESTS = { // fixture ADRs throughout) and separately reads docs/contributor-standards.md. 'tests/adr-index-gate.test.cjs': ['docs/adr/', 'docs/contributor-standards.md'], 'tests/agent-classification-parity.test.cjs': ['docs/AGENTS.md', 'docs/INVENTORY.md'], + // #3683: pins the learnings feature section's agreement with the canonical + // artifact registry (reads docs/FEATURES.md around the extract-learnings + // entry). + 'tests/learnings.test.cjs': ['docs/FEATURES.md'], 'tests/analyze-dependencies.test.cjs': ['docs/COMMANDS.md'], 'tests/autonomous-converge.test.cjs': [ 'docs/COMMANDS.md', diff --git a/src/learnings.cts b/src/learnings.cts index f1e6a6e4b..69a5db39a 100644 --- a/src/learnings.cts +++ b/src/learnings.cts @@ -18,6 +18,9 @@ import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import phaseLocator = require('./phase-locator.cjs'); import os from 'node:os'; // eslint-disable-next-line @typescript-eslint/no-require-imports import ioMod = require('./io.cjs'); @@ -198,9 +201,51 @@ function learningsDelete(id: string, opts?: { storeDir?: string }): boolean { return true; } +/** + * #3683 — resolve which learnings artifact a copy reads. The extractor writes + * phase-scoped `{PHASE_DIR}/{PADDED}-LEARNINGS.md`; the legacy shape is a + * project-root `LEARNINGS.md`. The MOST RECENT phase-scoped artifact wins (a + * gated completion copies the phase it just wrote); the project-root file is + * the fallback when no phase artifact exists. Returns null when neither shape + * is present. + */ +function resolveLearningsSource(planningDir: string): string | null { + let best: { p: string; m: number } | null = null; + const phasesDir = path.join(planningDir, 'phases'); + // Phase enumeration routes through the sanctioned seam (the #3185 drift + // guard rejects ad-hoc re-derivations): no cwd → every phase dir in the + // tree, sentinel dirs excluded, unreadable phases dir already degraded to + // an empty list by the locator itself. + const { value: phaseDirNames } = phaseLocator.listMilestonePhaseDirs(phasesDir); + for (const entry of phaseDirNames) { + const phaseDir = path.join(phasesDir, entry); + let files: string[]; + try { + files = fs.readdirSync(phaseDir); + } catch { + // An unreadable phase dir (ACL, removal race) must not crash the copy — + // skip it and keep scanning. + continue; + } + for (const f of files) { + if (!/-LEARNINGS\.md$/i.test(f)) continue; + const p = path.join(phaseDir, f); + try { + const m = fs.statSync(p).mtimeMs; + if (best === null || m > best.m) best = { p, m }; + } catch { + continue; + } + } + } + if (best !== null) return best.p; + const rootPath = path.join(planningDir, 'LEARNINGS.md'); + return fs.existsSync(rootPath) ? rootPath : null; +} + function learningsCopyFromProject(planningDir: string, opts?: WriteOpts & { sourceProject?: string }): CopyResult { - const learningsPath = path.join(planningDir, 'LEARNINGS.md'); - if (!fs.existsSync(learningsPath)) { + const learningsPath = resolveLearningsSource(planningDir); + if (learningsPath === null) { return { total: 0, created: 0, skipped: 0 }; } @@ -223,31 +268,51 @@ function learningsCopyFromProject(planningDir: string, opts?: WriteOpts & { sour } } - // Parse markdown: split on ## headings + // Parse markdown: split on ## category headings. + // #3683: the real producer (extract-learnings.md write_learnings) writes ## + // categories containing ### item headings — each item is ONE learning (the + // store's relevance contract caps injection by count, so category-sized + // blobs defeat it). A bare ## section with no ### items keeps the legacy + // single-learning shape. const sections = content.split(/^## /m).slice(1); // skip preamble before first ## let created = 0; let skipped = 0; - for (const section of sections) { - const lines = section.trim().split('\n'); - const title = lines[0].trim(); - const body = lines.slice(1).join('\n').trim(); - if (!body) continue; - - // Extract tags from title (simple: use words as tags) - const tags = title.toLowerCase().split(/\s+/).filter(w => w.length > 2); - + const writeOne = (title: string, body: string, extraTags: string[]): void => { + const tags = Array.from(new Set([ + ...extraTags, + ...title.toLowerCase().split(/\s+/).filter(w => w.length > 2), + ])); const result = learningsWrite({ source_project: sourceProject, learning: body, context: title, tags, }, { ...opts, dedupeIndex }); + if (result.created) created++; + else skipped++; + }; - if (result.created) { - created++; - } else { - skipped++; + for (const section of sections) { + const lines = section.trim().split('\n'); + const category = lines[0].trim(); + const body = lines.slice(1).join('\n').trim(); + if (!body) continue; + const categoryTag = category.toLowerCase().split(/\s+/)[0] || ''; + + // Split into ### items when present; otherwise one learning per section. + const items = body.split(/^### /m).map(s => s.trim()).filter(s => s.length > 0); + const hasItemHeadings = /^### /m.test(body); + if (!hasItemHeadings) { + writeOne(category, body, categoryTag ? [categoryTag] : []); + continue; + } + for (const item of items) { + const itemLines = item.split('\n'); + const title = itemLines[0].trim(); + const itemBody = itemLines.slice(1).join('\n').trim(); + if (!itemBody && !title) continue; + writeOne(title, itemBody || title, categoryTag ? [categoryTag] : []); } } diff --git a/tests/emitted-drift-acks/3003-declared-deletions.json b/tests/emitted-drift-acks/3003-declared-deletions.json index 23b0e66e6..1386b15f7 100644 --- a/tests/emitted-drift-acks/3003-declared-deletions.json +++ b/tests/emitted-drift-acks/3003-declared-deletions.json @@ -1,7 +1,7 @@ { - "$comment": "Growth ack (#2914 fragment). Reason: #3003 threads a plan-declared deletion list from plan frontmatter to the cleanup-wave deletions guard. execute-phase.md gains --deletions \"$PLAN_DELETIONS\" on the record-agent call; 92326 -> 92356 LF bytes (+30). Supersedes the spent #2856 fragment entry (tests/emitted-drift-acks/2856-live-dom-uat.json, merged into next so its ripple is already absorbed at the base and it can no longer clear anything), which also named execute-phase.md and would otherwise double-ack the same path — the same supersede that entry itself performed on the spent #3370 fragment, which had performed it on #3324. Only this path needs an entry: currentSizes() (tests/helpers/emitted-runtime.cjs:916-929) reads gsd-core/workflows/ and agents/ with a NON-recursive readdirSync that skips directories, so the two other grown shipped files are outside the growth ratchet — gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md (+885) sits in a subdirectory and gsd-core/templates/phase-prompt.md (+285) is under templates/. Their emitted-hash ripples are attributable to this diff and need no acknowledgment.", + "$comment": "Growth ack (#2914 fragment). RE-ARMED for #3683 (+238B, gated extract-learnings wiring in auto_copy_learnings); prior #3003 reason retained after the em-dash. Reason: #3003 threads a plan-declared deletion list from plan frontmatter to the cleanup-wave deletions guard. execute-phase.md gains --deletions \"$PLAN_DELETIONS\" on the record-agent call; 92326 -> 92356 LF bytes (+30). Supersedes the spent #2856 fragment entry (tests/emitted-drift-acks/2856-live-dom-uat.json, merged into next so its ripple is already absorbed at the base and it can no longer clear anything), which also named execute-phase.md and would otherwise double-ack the same path — the same supersede that entry itself performed on the spent #3370 fragment, which had performed it on #3324. Only this path needs an entry: currentSizes() (tests/helpers/emitted-runtime.cjs:916-929) reads gsd-core/workflows/ and agents/ with a NON-recursive readdirSync that skips directories, so the two other grown shipped files are outside the growth ratchet — gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md (+885) sits in a subdirectory and gsd-core/templates/phase-prompt.md (+285) is under templates/. Their emitted-hash ripples are attributable to this diff and need no acknowledgment.", "version": 1, "paths": { - "execute-phase.md": "gsd-core/workflows/execute-phase.md +30B: pass --deletions \"$PLAN_DELETIONS\" to worktree.record-agent so a plan-declared deletion reaches the cleanup-wave guard (#3003)" + "execute-phase.md": "gsd-core/workflows/execute-phase.md +238B: auto_copy_learnings instructs running extract-learnings for the just-completed phase when features.global_learnings is enabled (gate check first; extraction failure explicitly non-fatal) before the copy — the step the registry always named as a producer becomes one (#3683). Supersedes the spent #3003 entry (+30B, --deletions \"$PLAN_DELETIONS\"), same supersede chain that entry itself performed on #2856." } } diff --git a/tests/learnings.test.cjs b/tests/learnings.test.cjs index 4c252d727..3205525b0 100644 --- a/tests/learnings.test.cjs +++ b/tests/learnings.test.cjs @@ -627,3 +627,174 @@ describe('learnings dedupe scaling (#306)', () => { assert.strictEqual(all.length, 1); }); }); + +// ─── #3683 — phase-scoped learnings source (path agreement + gated wiring) ── +// +// The extractor writes {PHASE_DIR}/{PADDED}-LEARNINGS.md; the copy command read +// only /LEARNINGS.md (project root), so learnings.copy ALWAYS no-oped — +// the global-learnings store stayed empty even after manual extraction. The +// copy must discover the most recent phase-scoped artifact; the project-root +// path remains the fallback (legacy shape). Pins also cover the gated +// completion wiring in execute-phase.md and the registry/docs agreement. + +describe('#3683 learnings copy source resolution', () => { + let storeDir; + let projectDir; + + beforeEach(() => { + storeDir = makeTempDir(); + projectDir = makeTempDir(); + }); + afterEach(() => { + cleanupDir(storeDir); + cleanupDir(projectDir); + }); + + function writePhaseLearnings(phaseSlug, ageMinutes) { + const phaseDir = path.join(projectDir, 'phases', phaseSlug); + fs.mkdirSync(phaseDir, { recursive: true }); + const file = path.join(phaseDir, `${phaseSlug}-LEARNINGS.md`); + fs.writeFileSync(file, `# Learnings\n\n## ${phaseSlug} Lesson\nBody for ${phaseSlug}.\n`, 'utf-8'); + const when = new Date(Date.now() - ageMinutes * 60 * 1000); + fs.utimesSync(file, when, when); + return file; + } + + test('learnings copy reads the phase-scoped artifact', () => { + writePhaseLearnings('1.0-discovery', 30); + const result = learningsCopyFromProject(projectDir, { storeDir, sourceProject: 'app' }); + assert.strictEqual(result.created, 1, `phase-scoped artifact must be the copy source: ${JSON.stringify(result)}`); + const all = learningsList({ storeDir }); + assert.ok(all.some((r) => r.learning.includes('Body for 1.0-discovery')), 'item body must land in learning'); + }); + + test('learnings copy still reads the project-root artifact', () => { + fs.writeFileSync(path.join(projectDir, 'LEARNINGS.md'), '# L\n\n## Root Lesson\nBody.\n', 'utf-8'); + const result = learningsCopyFromProject(projectDir, { storeDir, sourceProject: 'app' }); + assert.strictEqual(result.created, 1); + assert.ok(learningsList({ storeDir }).some((r) => r.context === 'Root Lesson'), 'section title lands in context'); + }); + + test('phase-scoped wins when both exist', () => { + writePhaseLearnings('2.0-build', 10); + fs.writeFileSync(path.join(projectDir, 'LEARNINGS.md'), '# L\n\n## Root Lesson\nBody.\n', 'utf-8'); + const result = learningsCopyFromProject(projectDir, { storeDir, sourceProject: 'app' }); + assert.strictEqual(result.created, 1); + assert.ok( + learningsList({ storeDir }).some((r) => r.learning.includes('Body for 2.0-build')), + 'the most recent phase artifact must win over the project-root file', + ); + }); + + test('learnings copy no-ops with no artifact', () => { + const result = learningsCopyFromProject(projectDir, { storeDir, sourceProject: 'app' }); + assert.deepStrictEqual(result, { total: 0, created: 0, skipped: 0 }); + }); + + test('learnings copy picks the most recent phase artifact', () => { + writePhaseLearnings('1.0-discovery', 240); // older + writePhaseLearnings('3.0-hardening', 5); // newer + const result = learningsCopyFromProject(projectDir, { storeDir, sourceProject: 'app' }); + assert.strictEqual(result.created, 1); + assert.ok(learningsList({ storeDir }).some((r) => r.learning.includes('Body for 3.0-hardening'))); + }); + + test('extractor-shaped artifact copies per-item entries, not category blobs', () => { + // The real producer writes ## category sections containing ### items + // (extract-learnings.md write_learnings). The copy must store each ### + // item as its own learning — aggregating a category into one mega-entry + // defeats the store's relevance contract (learnings.max_inject). + const phaseDir = path.join(projectDir, 'phases', '4.0-real'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync( + path.join(phaseDir, '4.0-real-LEARNINGS.md'), + [ + '# Phase 4 Learnings', + '', + '## Decisions', + '', + '### Use SQLite over Postgres', + 'Zero-ops for single-node deployments.', + '', + '### Pin the runner image', + 'Reproducible CI beats newest-libraries.', + '', + '## Surprises', + '', + '### npm dedupe changed lockfile', + 'Expected; audit after upgrades.', + '', + ].join('\n'), + 'utf-8', + ); + const result = learningsCopyFromProject(projectDir, { storeDir, sourceProject: 'app' }); + assert.strictEqual(result.created, 3, 'each ### item must become one learning'); + const all = learningsList({ storeDir }); + // ### item TITLES land in context; their BODIES land in learning. + assert.ok(all.some((r) => r.context === 'Use SQLite over Postgres' && r.learning.includes('Zero-ops'))); + assert.ok(all.some((r) => r.context === 'Pin the runner image' && r.learning.includes('Reproducible'))); + assert.ok(all.some((r) => r.context === 'npm dedupe changed lockfile' && r.learning.includes('audit'))); + for (const r of all) { + assert.ok(r.learning.length < 200, 'entries must be item-scoped, not category blobs'); + } + }); + + test('malformed artifact yields no entries', () => { + const phaseDir = path.join(projectDir, 'phases', '1.0-x'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '1.0-x-LEARNINGS.md'), 'no sections at all\n', 'utf-8'); + const result = learningsCopyFromProject(projectDir, { storeDir, sourceProject: 'app' }); + assert.strictEqual(result.created, 0); + }); +}); + +describe('#3683 completion wiring and registry pins', () => { + const EXECUTE_PHASE = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-phase.md'); + const REGISTRY = path.join(__dirname, '..', 'gsd-core', 'templates', 'README.md'); + const FEATURES = path.join(__dirname, '..', 'docs', 'FEATURES.md'); + + test('execute-phase completion wires gated extraction', () => { + const content = fs.readFileSync(EXECUTE_PHASE, 'utf-8'); + const stepStart = content.indexOf(''); + assert.ok(stepStart !== -1, 'auto_copy_learnings step must exist'); + const stepEnd = content.indexOf('', stepStart); + const step = content.slice(stepStart, stepEnd); + assert.ok( + /extract-learnings|extract_learnings/.test(step), + 'the gated step must invoke the extraction producer for the completed phase', + ); + assert.ok( + /must NOT block phase completion|does not block phase completion|non-fatal/i.test(step), + 'extraction failure must be explicitly non-fatal', + ); + // Gate-first ordering: the disabled skip must precede the extraction wiring + // so disabled runs stay byte-identical. + const gateIdx = step.indexOf('GL_ENABLED'); + const extractIdx = step.search(/Run the .extract[-_]learnings. workflow/); + assert.ok(gateIdx !== -1 && extractIdx !== -1 && gateIdx < extractIdx, 'gate check must precede extraction'); + }); + + test('registry producer attribution is gated', () => { + const registry = fs.readFileSync(REGISTRY, 'utf-8'); + const splitLines = require('../gsd-core/bin/lib/text-lines.cjs').splitLines; + const row = splitLines(registry).find((l) => l.includes('LEARNINGS.md')); + assert.ok(row, 'registry must carry a LEARNINGS.md row'); + assert.ok( + /global_learnings|gated/i.test(row), + `producer attribution must state the gate: ${row}`, + ); + }); + + test('features doc agrees with the registry', () => { + const features = fs.readFileSync(FEATURES, 'utf-8'); + // Anchor on the SECTION HEADING — the TOC also mentions extract-learnings + // ~2400 chars earlier and its window contains neither keyword. + const heading = features.search(/##+\s*\d*\d*\.?\s*Extract Learnings/i); + assert.ok(heading !== -1, 'FEATURES.md must have an Extract Learnings section'); + const section = features.slice(heading, heading + 4000); + assert.ok( + /global_learnings|automatically/i.test(section), + 'the learnings feature section must acknowledge the gated automatic path', + ); + }); +});