fix(#3683): wire gated learnings extraction into completion, align copy path (#3810)

* test(#3683): failing-first rows for learnings source resolution and wiring pins

* fix(#3683): wire gated learnings extraction into completion, align copy path

* test(#3683): register the learnings suite in the docs-guard lane, drop unverified markers

* fix(#3683): close review findings — per-item parsing, readdir guards, docs paths

* fix(#3683): route phase enumeration through the locator seam, fix assertion targets

* fix(#3683): merge execute-phase ack into the 3003 fragment, fix fidelity targets

* fix(#3663): replace the spent execute-phase ack entry with the 3683 re-arm

* chore(#3683): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-24 09:23:01 -04:00
committed by GitHub
parent 31fcb833ec
commit 4b84be1da4
13 changed files with 280 additions and 29 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -918,7 +918,7 @@ GSD の保証付きでアドホックタスクを実行します。
| `--format` | 出力フォーマット: `markdown`(デフォルト)、`json` |
**前提条件:** フェーズが実行済みであること(SUMMARY.md ファイルが存在すること)
**生成物:** `.planning/learnings/{phase}-LEARNINGS.md`
**生成物:** `.planning/phases/{phase-dir}/{padded-phase}-LEARNINGS.md`
**抽出内容:**
- アーキテクチャ上の決定とその根拠

View File

@@ -924,7 +924,7 @@ GSD 보장을 통해 애드혹 작업을 실행합니다.
| `--format` | 출력 형식: `markdown` (기본값), `json` |
**전제 조건:** 단계가 실행됨 (SUMMARY.md 파일 존재)
**생성 결과:** `.planning/learnings/{phase}-LEARNINGS.md`
**생성 결과:** `.planning/phases/{phase-dir}/{padded-phase}-LEARNINGS.md`
**추출 내용:**
- 아키텍처 결정 및 근거

View File

@@ -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

View File

@@ -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`
**提取内容:**
- 架构决策及其依据

View File

@@ -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 |

View File

@@ -1432,10 +1432,12 @@ gsd_run query commit "docs(phase-{X}): complete phase execution" --files .planni
</step>
<step name="auto_copy_learnings">
**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"
```

View File

@@ -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',

View File

@@ -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] : []);
}
}

View File

@@ -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."
}
}

View File

@@ -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 <root>/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('<step name="auto_copy_learnings">');
assert.ok(stepStart !== -1, 'auto_copy_learnings step must exist');
const stepEnd = content.indexOf('</step>', 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',
);
});
});