* 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:
5
.changeset/silly-yaks-fly.md
Normal file
5
.changeset/silly-yaks-fly.md
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -918,7 +918,7 @@ GSD の保証付きでアドホックタスクを実行します。
|
||||
| `--format` | 出力フォーマット: `markdown`(デフォルト)、`json` |
|
||||
|
||||
**前提条件:** フェーズが実行済みであること(SUMMARY.md ファイルが存在すること)
|
||||
**生成物:** `.planning/learnings/{phase}-LEARNINGS.md`
|
||||
**生成物:** `.planning/phases/{phase-dir}/{padded-phase}-LEARNINGS.md`
|
||||
|
||||
**抽出内容:**
|
||||
- アーキテクチャ上の決定とその根拠
|
||||
|
||||
@@ -924,7 +924,7 @@ GSD 보장을 통해 애드혹 작업을 실행합니다.
|
||||
| `--format` | 출력 형식: `markdown` (기본값), `json` |
|
||||
|
||||
**전제 조건:** 단계가 실행됨 (SUMMARY.md 파일 존재)
|
||||
**생성 결과:** `.planning/learnings/{phase}-LEARNINGS.md`
|
||||
**생성 결과:** `.planning/phases/{phase-dir}/{padded-phase}-LEARNINGS.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
|
||||
|
||||
@@ -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`
|
||||
|
||||
**提取内容:**
|
||||
- 架构决策及其依据
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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] : []);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user