feat(workflows): close LEARNINGS.md consumption-and-graduation loop (#2490)

* fix(tests): update 5 source-text tests to read config-schema.cjs

VALID_CONFIG_KEYS moved from config.cjs to config-schema.cjs in the
drift-prevention companion PR. Tests that read config.cjs source text
and checked for key literal includes() now point to the correct file.

Closes #2480

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(workflows): close LEARNINGS.md consumption-and-graduation loop (#2430)

Part A — Consumption: extend plan-phase.md cross-phase context load to include
LEARNINGS.md files from the 3 most recent prior phases (same recency gate as
CONTEXT.md + SUMMARY.md: CONTEXT_WINDOW >= 500000 only). Also loads LEARNINGS.md
from any phases in the Depends-on chain. Silent skip if absent; 15% context
budget cap with oldest-first truncation; [from Phase N LEARNINGS] attribution.

Part B — Graduation: add graduation_scan step to transition.md (after
evolve_project) that delegates to new graduation.md helper workflow. The helper
clusters recurring items across the last N phases (default window=5, threshold=3)
using Jaccard lexical similarity, surfaces HITL Promote/Defer/Dismiss prompts,
routes promotions to PROJECT.md or PATTERNS.md by category, annotates graduated
items with `graduated:` field, and persists dismissed/deferred clusters in
STATE.md graduation_backlog. Always non-blocking; silently no-ops on first phase
or when data is insufficient.

Also: adds optional `graduated:` annotation docs to extract_learnings.md schema.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(graduation): address CodeRabbit review findings on PR #2490

- graduation.md: unify insufficient-data guard to silent-skip (remove
  contradictory [no-op] print path)
- graduation.md: add TEXT_MODE fallback for HITL cluster prompts
- graduation.md: add A (defer-all) to accepted actions [P/D/X/A]
- graduation.md: tag untyped code fences with text language (MD040)
- transition.md: tag untyped graduation.md fence with text language

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(graduation): rephrase TEXT_MODE line to avoid prompt-injection scanner false positive

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-04-20 18:21:35 -04:00
committed by GitHub
parent cfe4dc76fd
commit b432d4a726
7 changed files with 462 additions and 2 deletions

View File

@@ -149,6 +149,7 @@
"extract_learnings.md",
"fast.md",
"forensics.md",
"graduation.md",
"health.md",
"help.md",
"import.md",

View File

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

View File

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

View File

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

View File

@@ -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
` : ''}
</files_to_read>

View File

@@ -271,6 +271,28 @@ After (Phase 2 shipped JWT auth, discovered rate limiting needed):
</step>
<step name="graduation_scan">
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)
</step>
<step name="update_current_position_after_transition">
**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.

View File

@@ -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'
);
});
});