diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 2b9dc2a9..d9d48494 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -149,6 +149,7 @@ "extract_learnings.md", "fast.md", "forensics.md", + "graduation.md", "health.md", "help.md", "import.md", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 88329abc..af9c06bb 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -172,7 +172,7 @@ Full roster at `commands/gsd/*.md`. The groupings below mirror `docs/COMMANDS.md --- -## Workflows (79 shipped) +## Workflows (80 shipped) Full roster at `get-shit-done/workflows/*.md`. Workflows are thin orchestrators that commands reference internally; most are not read directly by end users. Rows below map each workflow file to its role (derived from the `` block) and, where applicable, to the command that invokes it. @@ -206,6 +206,7 @@ Full roster at `get-shit-done/workflows/*.md`. Workflows are thin orchestrators | `extract_learnings.md` | Extract decisions, lessons, patterns, and surprises from completed phase artifacts. | `/gsd-extract-learnings` | | `fast.md` | Execute a trivial task inline without subagent overhead. | `/gsd-fast` | | `forensics.md` | Forensics investigation of failed workflows — git, artifacts, and state analysis. | `/gsd-forensics` | +| `graduation.md` | Cluster recurring LEARNINGS.md items across phases and surface HITL promotion candidates. | `transition.md` (graduation_scan step) | | `health.md` | Validate `.planning/` directory integrity and report actionable issues. | `/gsd-health` | | `help.md` | Display the complete GSD command reference. | `/gsd-help` | | `import.md` | Ingest external plans with conflict detection against existing project decisions. | `/gsd-import` | diff --git a/get-shit-done/workflows/extract_learnings.md b/get-shit-done/workflows/extract_learnings.md index 3e6609b4..059fbf0f 100644 --- a/get-shit-done/workflows/extract_learnings.md +++ b/get-shit-done/workflows/extract_learnings.md @@ -135,6 +135,12 @@ missing_artifacts: --- ``` +Individual items may carry an optional `graduated:` annotation (added by `graduation.md` when a cluster is promoted): +```markdown +**Graduated:** {target-file}:{ISO_DATE} +``` +This annotation is appended after the item's existing fields and prevents the item from being re-surfaced in future graduation scans. Do not add this field during extraction — it is written only by the graduation workflow. + The body follows this structure: ```markdown # Phase {PHASE_NUMBER} Learnings: {PHASE_NAME} diff --git a/get-shit-done/workflows/graduation.md b/get-shit-done/workflows/graduation.md new file mode 100644 index 00000000..0a75bd0a --- /dev/null +++ b/get-shit-done/workflows/graduation.md @@ -0,0 +1,195 @@ +# graduation.md — LEARNINGS.md Cross-Phase Graduation Helper + +**Invoked by:** `transition.md` step `graduation_scan`. Never invoked directly by users. + +This workflow clusters recurring items across the last N phases' LEARNINGS.md files and surfaces promotion candidates to the developer via HITL. No item is promoted without explicit developer approval. + +--- + +## Configuration + +Read from project config (`config.json`): + +| Key | Default | Description | +|-----|---------|-------------| +| `features.graduation` | `true` | Master on/off switch. `false` skips silently. | +| `features.graduation_window` | `5` | How many prior phases to scan | +| `features.graduation_threshold` | `3` | Minimum cluster size to surface | + +--- + +## Step 1: Guard Checks + +```bash +GRADUATION_ENABLED=$(gsd-sdk query config-get features.graduation 2>/dev/null || echo "true") +GRADUATION_WINDOW=$(gsd-sdk query config-get features.graduation_window 2>/dev/null || echo "5") +GRADUATION_THRESHOLD=$(gsd-sdk query config-get features.graduation_threshold 2>/dev/null || echo "3") +``` + +**Skip silently (print nothing) if:** +- `features.graduation` is `false` +- Fewer than `graduation_threshold` completed prior phases exist (not enough data) + +**Skip silently (print nothing) if total items across all LEARNINGS.md files in the window is fewer than 5.** + +--- + +## Step 2: Collect LEARNINGS.md Files + +Find LEARNINGS.md files from the last N completed phases (excluding the phase currently completing): + +```bash +find .planning/phases -name "*-LEARNINGS.md" | sort | tail -n "$GRADUATION_WINDOW" +``` + +For each file found: +1. Parse the four category sections: `## Decisions`, `## Lessons`, `## Patterns`, `## Surprises` +2. Extract each `### Item Title` + body as a single item record: `{ category, title, body, source_phase, source_file }` +3. **Skip items that already contain `**Graduated:**`** — they have been promoted and must not re-surface + +--- + +## Step 3: Cluster by Lexical Similarity + +For each category independently, cluster items using Jaccard similarity on tokenized title+body: + +**Tokenization:** lowercase, strip punctuation, split on whitespace, remove stop words (a, an, the, is, was, in, on, at, to, for, of, and, or, but, with, from, that, this, by, as). + +**Jaccard similarity:** `|A ∩ B| / |A ∪ B|` where A and B are token sets. Two items are in the same cluster if similarity ≥ 0.25. + +**Clustering algorithm:** single-pass greedy — process items in phase order; add to the first cluster whose centroid (union of all cluster tokens) has similarity ≥ 0.25 with the new item; otherwise start a new cluster. + +**Cluster size filter:** only surface clusters with distinct source phases ≥ `graduation_threshold` (not just total items — same item repeated in one phase still counts as 1 distinct phase). + +--- + +## Step 4: Check graduation_backlog in STATE.md + +Read `.planning/STATE.md` `graduation_backlog` section (if present). Format: + +```yaml +graduation_backlog: + - cluster_id: "{sha256-of-cluster-title}" + status: "dismissed" # or "deferred" + deferred_until: "phase-N" # only for deferred entries + cluster_title: "{representative title}" +``` + +**Skip any cluster whose `cluster_id` matches a `dismissed` entry.** + +**Skip any cluster whose `cluster_id` matches a `deferred` entry where `deferred_until` phase has not yet completed.** + +--- + +## Step 5: Surface Promotion Candidates + +For each qualifying cluster, determine the suggested target file: + +| Category | Suggested Target | +|----------|-----------------| +| `decisions` | `PROJECT.md` — append under `## Validated Decisions` (create section if absent) | +| `patterns` | `PATTERNS.md` — append under the appropriate category section (create file if absent) | +| `lessons` | `PROJECT.md` — append under `## Invariants` (create section if absent) | +| `surprises` | Flag for human review — if genuinely surprising 3+ times, something structural is wrong | + +Print the graduation report: + +```text +📚 Graduation scan across phases {M}–{N}: + + HIGH RECURRENCE ({K}/{WINDOW} phases) + ├─ Cluster: "{representative title}" + ├─ Category: {category} + ├─ Sources: {list of NN-LEARNINGS filenames} + └─ Suggested target: {target file} § {section} + + [repeat for each qualifying cluster, ordered HIGH→LOW recurrence] + +For each cluster above, choose an action: + P = Promote now D = Defer (re-surface next transition) X = Dismiss (never re-surface) A = Defer all remaining +``` + +--- + +## Step 6: HITL — Process Each Cluster + +For each cluster (in order from Step 5), ask the developer: + +```text +Cluster: "{title}" [{category}, {K} phases] → {target} +Action [P/D/X/A]: +``` + +Use `AskUserQuestion` (or equivalent HITL primitive for the current runtime). If `TEXT_MODE` is true, display the cluster question as plain text and accept typed input. Accept single-character input: `P`, `D`, `X`, `A` (case-insensitive). + +**On `P` (Promote now):** + +1. Read the target file (or create it with a standard header if absent) +2. Append the cluster entry under the suggested section: + ```markdown + ### {Cluster representative title} + {Merged body — combine unique sentences across cluster items} + + **Sources:** Phase {A}, Phase {B}, Phase {C} + **Promoted:** {ISO_DATE} + ``` +3. For each source LEARNINGS.md item in the cluster, append `**Graduated:** {target-file}:{ISO_DATE}` after its last existing field +4. Commit both the target file and all annotated LEARNINGS.md files in a single atomic commit: + `docs(learnings): graduate "{cluster title}" to {target-file}` + +**On `D` (Defer):** + +Write to `.planning/STATE.md` under `graduation_backlog`: +```yaml +- cluster_id: "{sha256}" + status: "deferred" + deferred_until: "phase-{NEXT_PHASE_NUMBER}" + cluster_title: "{title}" +``` + +**On `X` (Dismiss):** + +Write to `.planning/STATE.md` under `graduation_backlog`: +```yaml +- cluster_id: "{sha256}" + status: "dismissed" + cluster_title: "{title}" +``` + +**On `A` (Defer all):** + +Defer the current cluster (same as `D`) and skip all remaining clusters for this run, deferring each to the next transition. Print: +```text +[graduation: deferred all remaining clusters to next transition] +``` +Then proceed directly to Step 7. + +--- + +## Step 7: Completion Report + +After processing all clusters, print: + +```text +Graduation complete: {promoted} promoted, {deferred} deferred, {dismissed} dismissed. +``` + +If no clusters qualified (all filtered by backlog or threshold), print: +```text +[graduation: no qualifying clusters in phases {M}–{N}] +``` + +--- + +## First-Run Behaviour + +On the first transition after upgrading to a version that includes this workflow, all extant LEARNINGS.md files may produce a large batch of candidates at once. A `[Defer all]` shorthand is available: if the developer enters `A` at any cluster prompt, all remaining clusters for this run are deferred to the next transition. + +--- + +## No-Op Conditions (silent skip) + +- `features.graduation = false` +- Fewer than `graduation_threshold` prior phases with LEARNINGS.md +- Total items < 5 across the window +- All qualifying clusters are in `graduation_backlog` as dismissed diff --git a/get-shit-done/workflows/plan-phase.md b/get-shit-done/workflows/plan-phase.md index a9ca7b1d..1c4551f1 100644 --- a/get-shit-done/workflows/plan-phase.md +++ b/get-shit-done/workflows/plan-phase.md @@ -720,7 +720,8 @@ ${CONTEXT_WINDOW >= 500000 ? ` **Cross-phase context (1M model enrichment):** - CONTEXT.md files from the 3 most recent completed phases (locked decisions — maintain consistency) - SUMMARY.md files from the 3 most recent completed phases (what was built — reuse patterns, avoid duplication) -- CONTEXT.md and SUMMARY.md from any phases listed in the current phase's "Depends on:" field in ROADMAP.md (regardless of recency — explicit dependencies always load, deduplicated against the 3 most recent) +- LEARNINGS.md files from the 3 most recent completed phases (structured decisions, patterns, lessons, surprises — skip silently if a phase has no LEARNINGS.md; prefix each block with \`[from Phase N LEARNINGS]\` for source attribution; if total size exceeds 15% of context budget, drop oldest first) +- CONTEXT.md, SUMMARY.md, and LEARNINGS.md from any phases listed in the current phase's "Depends on:" field in ROADMAP.md (regardless of recency — explicit dependencies always load, deduplicated against the 3 most recent) - Skip all other prior phases to stay within context budget ` : ''} diff --git a/get-shit-done/workflows/transition.md b/get-shit-done/workflows/transition.md index fe3b8ea9..c6838543 100644 --- a/get-shit-done/workflows/transition.md +++ b/get-shit-done/workflows/transition.md @@ -271,6 +271,28 @@ After (Phase 2 shipped JWT auth, discovered rate limiting needed): + + +Scan LEARNINGS.md files from recent phases for recurring patterns and surface promotion candidates to the developer. + +**Invoke the graduation helper:** + +```text +@~/.claude/get-shit-done/workflows/graduation.md +``` + +This step is fully delegated to `graduation.md`. It handles guard checks (feature flag, window size, threshold), clustering, backlog filtering, HITL prompting, promotion writes, and STATE.md updates. + +**This step is always non-blocking:** graduation candidates are surfaced for the developer's decision; no action is required to continue the transition. If the graduation scan produces no qualifying clusters, it prints a single `[graduation: no qualifying clusters]` line and returns. + +**Step complete when:** + +- [ ] graduation.md guard checks passed (or skipped with silent no-op) +- [ ] Recurring clusters surfaced (or `[graduation: no qualifying clusters]` printed) +- [ ] Each cluster resolved as Promote / Defer / Dismiss (or all skipped) + + + **Note:** Basic position updates (Current Phase, Status, Current Plan, Last Activity) were already handled by `gsd-sdk query phase.complete` in the update_roadmap_and_state step. diff --git a/tests/enh-2430-learnings-consumption.test.cjs b/tests/enh-2430-learnings-consumption.test.cjs new file mode 100644 index 00000000..a7a7eea4 --- /dev/null +++ b/tests/enh-2430-learnings-consumption.test.cjs @@ -0,0 +1,234 @@ +'use strict'; + +/** + * Tests for #2430 — LEARNINGS.md consumption loop. + * + * Part A: plan-phase.md cross-phase context load includes LEARNINGS.md + * Part B: transition.md graduation_scan step + graduation.md helper + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const WORKFLOWS_DIR = path.join(__dirname, '../get-shit-done/workflows'); + +function readWorkflow(name) { + return fs.readFileSync(path.join(WORKFLOWS_DIR, name), 'utf-8'); +} + +describe('enh-2430 Part A — plan-phase LEARNINGS.md context load', () => { + let content; + + test('plan-phase.md includes LEARNINGS.md in cross-phase context load', () => { + content = readWorkflow('plan-phase.md'); + assert.ok( + content.includes('LEARNINGS.md files from the 3 most recent completed phases'), + 'plan-phase.md must mention LEARNINGS.md in cross-phase context block' + ); + }); + + test('plan-phase.md LEARNINGS load is inside the 1M context-window gate', () => { + content = content || readWorkflow('plan-phase.md'); + const windowBlock = content.match(/\$\{CONTEXT_WINDOW >= 500000[\s\S]*?\` : ''\}/); + assert.ok(windowBlock, 'CONTEXT_WINDOW gate block must exist'); + assert.ok( + windowBlock[0].includes('LEARNINGS.md'), + 'LEARNINGS.md load must be inside the CONTEXT_WINDOW >= 500000 gate' + ); + }); + + test('plan-phase.md source attribution mentioned for LEARNINGS load', () => { + content = content || readWorkflow('plan-phase.md'); + assert.ok( + content.includes('[from Phase N LEARNINGS]') || content.includes('source attribution'), + 'plan-phase.md must document source attribution for loaded LEARNINGS.md content' + ); + }); + + test('plan-phase.md handles missing LEARNINGS.md gracefully (silent skip)', () => { + content = content || readWorkflow('plan-phase.md'); + assert.ok( + content.includes('skip silently if a phase has no LEARNINGS.md') || + content.includes('skip silently'), + 'plan-phase.md must document silent skip when LEARNINGS.md is absent' + ); + }); + + test('plan-phase.md LEARNINGS load includes Depends-on chain', () => { + content = content || readWorkflow('plan-phase.md'); + const dependsSection = content.match(/Depends on.*?(\n.*?)+/); + assert.ok( + content.includes('LEARNINGS.md from any phases listed in'), + 'plan-phase.md must load LEARNINGS.md for Depends on chain phases' + ); + }); + + test('plan-phase.md specifies context budget limit for LEARNINGS', () => { + content = content || readWorkflow('plan-phase.md'); + assert.ok( + content.includes('15%') || content.includes('drop oldest'), + 'plan-phase.md must specify budget limit and truncation strategy for LEARNINGS' + ); + }); +}); + +describe('enh-2430 Part B — graduation_scan in transition.md', () => { + let content; + + test('transition.md contains graduation_scan step', () => { + content = readWorkflow('transition.md'); + assert.ok( + content.includes('graduation_scan'), + 'transition.md must contain graduation_scan step' + ); + }); + + test('graduation_scan is placed after evolve_project step', () => { + content = content || readWorkflow('transition.md'); + const evolvePos = content.indexOf('name="evolve_project"'); + const graduationPos = content.indexOf('name="graduation_scan"'); + assert.ok(evolvePos >= 0, 'evolve_project step must exist'); + assert.ok(graduationPos >= 0, 'graduation_scan step must exist'); + assert.ok( + graduationPos > evolvePos, + 'graduation_scan must appear after evolve_project in transition.md' + ); + }); + + test('graduation_scan is non-blocking (transition continues regardless)', () => { + content = content || readWorkflow('transition.md'); + const scanBlock = content.match(/name="graduation_scan"[\s\S]*?<\/step>/); + assert.ok(scanBlock, 'graduation_scan step must be parseable'); + assert.ok( + scanBlock[0].includes('non-blocking') || scanBlock[0].includes('always non-blocking'), + 'graduation_scan must be documented as non-blocking' + ); + }); + + test('graduation_scan delegates to graduation.md helper', () => { + content = content || readWorkflow('transition.md'); + assert.ok( + content.includes('graduation.md'), + 'graduation_scan must reference graduation.md helper workflow' + ); + }); +}); + +describe('enh-2430 Part B — graduation.md helper workflow', () => { + let content; + + test('graduation.md exists', () => { + content = readWorkflow('graduation.md'); + assert.ok(content.length > 0, 'graduation.md must exist and be non-empty'); + }); + + test('graduation.md documents features.graduation config flag', () => { + content = content || readWorkflow('graduation.md'); + assert.ok( + content.includes('features.graduation'), + 'graduation.md must document features.graduation config flag' + ); + }); + + test('graduation.md documents graduation_window config', () => { + content = content || readWorkflow('graduation.md'); + assert.ok( + content.includes('graduation_window'), + 'graduation.md must document features.graduation_window config' + ); + }); + + test('graduation.md documents graduation_threshold config', () => { + content = content || readWorkflow('graduation.md'); + assert.ok( + content.includes('graduation_threshold'), + 'graduation.md must document features.graduation_threshold config' + ); + }); + + test('graduation.md specifies HITL: Promote / Defer / Dismiss', () => { + content = content || readWorkflow('graduation.md'); + assert.ok(content.includes('Promote'), 'graduation.md must document Promote action'); + assert.ok(content.includes('Defer'), 'graduation.md must document Defer action'); + assert.ok(content.includes('Dismiss'), 'graduation.md must document Dismiss action'); + }); + + test('graduation.md specifies category→target routing', () => { + content = content || readWorkflow('graduation.md'); + assert.ok( + content.includes('PROJECT.md') && content.includes('PATTERNS.md'), + 'graduation.md must route categories to appropriate target files' + ); + }); + + test('graduation.md specifies graduation_backlog in STATE.md', () => { + content = content || readWorkflow('graduation.md'); + assert.ok( + content.includes('graduation_backlog'), + 'graduation.md must document STATE.md graduation_backlog for Defer/Dismiss' + ); + }); + + test('graduation.md skips items with graduated: annotation', () => { + content = content || readWorkflow('graduation.md'); + assert.ok( + content.includes('graduated:') || content.includes('Graduated:'), + 'graduation.md must skip already-graduated items' + ); + }); + + test('graduation.md has silent no-op for first phase / insufficient data', () => { + content = content || readWorkflow('graduation.md'); + assert.ok( + content.includes('no-op') || content.includes('silent'), + 'graduation.md must silently no-op when there is insufficient data' + ); + }); + + test('graduation.md specifies Defer-all shorthand (A key)', () => { + content = content || readWorkflow('graduation.md'); + assert.ok( + content.includes('Defer all') || content.includes('[Defer all]'), + 'graduation.md must document the Defer all shorthand for first-run batches' + ); + }); +}); + +describe('enh-2430 — extract_learnings.md graduated: field', () => { + test('extract_learnings.md documents optional graduated: annotation', () => { + const content = readWorkflow('extract_learnings.md'); + assert.ok( + content.includes('graduated:') || content.includes('Graduated:'), + 'extract_learnings.md must document optional graduated: field' + ); + }); + + test('extract_learnings.md clarifies graduated: is written only by graduation workflow', () => { + const content = readWorkflow('extract_learnings.md'); + assert.ok( + content.includes('graduation workflow') || content.includes('graduation.md'), + 'extract_learnings.md must clarify that graduated: is written only by graduation.md' + ); + }); +}); + +describe('enh-2430 — INVENTORY sync', () => { + test('INVENTORY.md lists graduation.md', () => { + const inventory = fs.readFileSync( + path.join(__dirname, '../docs/INVENTORY.md'), 'utf-8' + ); + assert.ok(inventory.includes('graduation.md'), 'INVENTORY.md must list graduation.md'); + }); + + test('INVENTORY-MANIFEST.json includes graduation.md', () => { + const manifest = JSON.parse( + fs.readFileSync(path.join(__dirname, '../docs/INVENTORY-MANIFEST.json'), 'utf-8') + ); + assert.ok( + manifest.families.workflows.includes('graduation.md'), + 'INVENTORY-MANIFEST.json must include graduation.md in workflows array' + ); + }); +});