From 8cd874969cc1e8d52b6c6464c89b36f25528e2d1 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 11 May 2026 23:01:34 -0400 Subject: [PATCH] feat(plan-phase): add ADR ingest express path for approved enhancement #3209 (#3421) * feat(plan-phase): add ADR ingest express path for approved enhancement #3209 * fix(review): address coderabbit doc note and brittle section-number assertion --- .changeset/calm-koalas-hop.md | 5 + commands/gsd/plan-phase.md | 4 +- docs/COMMANDS.md | 4 + docs/INVENTORY-MANIFEST.json | 3 +- docs/INVENTORY.md | 3 +- get-shit-done/bin/lib/adr-parser.cjs | 394 ++++++++++++++++++ get-shit-done/workflows/help.md | 9 +- get-shit-done/workflows/plan-phase.md | 24 +- tests/adr-parser.test.cjs | 101 +++++ tests/enh-3209-plan-phase-ingest-adr.test.cjs | 80 ++++ 10 files changed, 619 insertions(+), 8 deletions(-) create mode 100644 .changeset/calm-koalas-hop.md create mode 100644 get-shit-done/bin/lib/adr-parser.cjs create mode 100644 tests/adr-parser.test.cjs create mode 100644 tests/enh-3209-plan-phase-ingest-adr.test.cjs diff --git a/.changeset/calm-koalas-hop.md b/.changeset/calm-koalas-hop.md new file mode 100644 index 000000000..71d615b1c --- /dev/null +++ b/.changeset/calm-koalas-hop.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 3209 +--- +**`/gsd-plan-phase` now supports ADR ingest express-path context synthesis** - add `--ingest` and `--ingest-format`, parse approved ADR decisions into CONTEXT.md with status/scope guards, and document the new flags in help/inventory docs. diff --git a/commands/gsd/plan-phase.md b/commands/gsd/plan-phase.md index 41156ca45..f32b3b83e 100644 --- a/commands/gsd/plan-phase.md +++ b/commands/gsd/plan-phase.md @@ -1,7 +1,7 @@ --- name: gsd:plan-phase description: Create detailed phase plan (PLAN.md) with verification loop -argument-hint: "[phase] [--auto] [--research] [--skip-research] [--research-phase ] [--view] [--gaps] [--skip-verify] [--prd ] [--reviews] [--text] [--tdd] [--mvp]" +argument-hint: "[phase] [--auto] [--research] [--skip-research] [--research-phase ] [--view] [--gaps] [--skip-verify] [--prd ] [--ingest ] [--ingest-format ] [--reviews] [--text] [--tdd] [--mvp]" allowed-tools: - Read - Write @@ -46,6 +46,8 @@ Phase number: $ARGUMENTS (optional — auto-detects next unplanned phase if omit - `--gaps` — Gap closure mode (reads VERIFICATION.md, skips research) - `--skip-verify` — Skip verification loop - `--prd ` — Use a PRD/acceptance criteria file instead of discuss-phase. Parses requirements into CONTEXT.md automatically. Skips discuss-phase entirely. +- `--ingest ` — Use one or more ADR files instead of discuss-phase. Parses locked decisions + scope fences into CONTEXT.md automatically. Skips discuss-phase entirely. +- `--ingest-format ` — Optional ADR parser format override (`auto` default). - `--reviews` — Replan incorporating cross-AI review feedback from REVIEWS.md (produced by `/gsd-review`) - `--text` — Use plain-text numbered lists instead of TUI menus (required for `/rc` remote sessions) - `--mvp` — Vertical MVP mode. Planner organizes tasks as feature slices (UI→API→DB) instead of horizontal layers. On Phase 1 of a new project, also emits `SKELETON.md` (Walking Skeleton). Can be persisted on a phase via `**Mode:** mvp` in ROADMAP.md. diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 5a4b24d70..43ede783d 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -149,6 +149,8 @@ Research, plan, and verify a phase. | `--gaps` | Gap closure mode (reads VERIFICATION.md, skips research) | | `--skip-verify` | Skip plan checker verification loop | | `--prd ` | Use a PRD file instead of discuss-phase for context | +| `--ingest ` | Use ADR file(s) instead of discuss-phase for context synthesis | +| `--ingest-format ` | Optional ADR parser format override for `--ingest` | | `--reviews` | Replan with cross-AI review feedback from REVIEWS.md | | `--validate` | Run state validation before planning begins | | `--bounce` | Run external plan bounce validation after planning (uses `workflow.plan_bounce_script`) | @@ -179,6 +181,8 @@ See [Package Legitimacy Gate in the User Guide](USER-GUIDE.md#package-legitimacy /gsd-plan-phase --auto # Non-interactive planning /gsd-plan-phase 2 --validate # Validate state before planning /gsd-plan-phase 1 --bounce # Plan + external bounce validation +/gsd-plan-phase 2 --ingest docs/adr/0010.md # ADR express path for context synthesis +/gsd-plan-phase 2 --ingest 'docs/adr/00*.md' --ingest-format auto /gsd-plan-phase --research-phase 4 # Research only on phase 4 (prompts if RESEARCH.md exists) /gsd-plan-phase --research-phase 4 --view # Print existing RESEARCH.md, no spawn /gsd-plan-phase --research-phase 4 --research # Force-refresh research, no prompt diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 78300673d..9cf7de990 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -1,5 +1,5 @@ { - "generated": "2026-05-11", + "generated": "2026-05-12", "families": { "agents": [ "gsd-advisor-researcher", @@ -258,6 +258,7 @@ ], "cli_modules": [ "active-workstream-store.cjs", + "adr-parser.cjs", "artifacts.cjs", "audit.cjs", "cjs-command-router-adapter.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 783bc0d09..572a2f1cd 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -359,13 +359,14 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (53 shipped) +## CLI Modules (54 shipped) Full listing: `get-shit-done/bin/lib/*.cjs`. | Module | Responsibility | |--------|----------------| | `active-workstream-store.cjs` | Workstream source precedence and selection (CLI `--ws` > `GSD_WORKSTREAM` env > stored pointer); name validation and environment propagation | +| `adr-parser.cjs` | ADR decision parser for plan-phase ingest express path; normalizes section synonyms, parses status/decision/scope fences, and enforces status rejection gates | | `artifacts.cjs` | Canonical artifact registry — known `.planning/` root file names; used by `gsd-health` W019 lint | | `audit.cjs` | Audit dispatch, audit open sessions, audit storage helpers | | `cjs-command-router-adapter.cjs` | Shared compatibility adapter for manifest-backed CJS command-family routers | diff --git a/get-shit-done/bin/lib/adr-parser.cjs b/get-shit-done/bin/lib/adr-parser.cjs new file mode 100644 index 000000000..af48c7077 --- /dev/null +++ b/get-shit-done/bin/lib/adr-parser.cjs @@ -0,0 +1,394 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { requireSafePath } = require('./security.cjs'); + +const STATUS_REJECT_SET = new Set(['superseded', 'rejected', 'deprecated']); + +const CANONICAL_HEADERS = { + status: ['status', 'state', 'lifecycle', 'stage'], + goal: [ + 'context', + 'background', + 'problem statement', + 'problem', + 'situation', + 'forces', + 'motivation', + 'issue', + 'drivers', + 'pain points', + 'story', + 'setting', + 'premise', + 'status quo', + 'context and problem statement', + ], + decisions: [ + 'decision', + 'decisions', + 'resolution', + 'conclusion', + 'choice', + 'we decided', + 'direction', + 'approach', + 'solution', + 'outcome', + 'selected option', + 'recommendation', + 'strategy', + 'decision outcome', + ], + considered_options: [ + 'considered options', + 'alternatives', + 'options', + 'choices', + 'candidates', + 'approaches considered', + 'variants', + 'trade-offs', + 'pros and cons of the options', + 'discussion', + ], + risks: [ + 'risks', + 'trade-offs', + 'drawbacks', + 'cost', + 'tensions', + 'liabilities', + 'negative consequences', + 'side effects', + ], + success_criteria: [ + 'success criteria', + 'acceptance criteria', + 'validation', + "how we'll know", + 'metrics', + 'kpis', + 'verification', + 'test strategy', + 'compliance', + 'definition of done', + 'exit criteria', + 'positive consequences', + ], + plan_sequence: [ + 'implementation plan', + 'implementation notes', + 'steps', + 'tasks', + 'roadmap', + 'sequence', + 'migration plan', + 'plan', + 'action items', + 'work breakdown', + 'phases', + 'milestones', + 'stages', + ], + key_files: [ + 'affected files', + 'files touched', + 'surface area', + 'modules affected', + 'code locations', + 'file changes', + 'diff summary', + 'touched code', + ], + out_of_scope: [ + 'out of scope', + 'non-goals', + 'excluded', + 'not in this adr', + 'out of bounds', + "won't do", + "won't have", + 'beyond scope', + 'anti-goals', + ], + deferred: [ + 'future work', + 'deferred', + 'future', + 'later', + 'follow-up', + 'next steps', + ], + dependencies: [ + 'dependencies', + 'depends on', + 'prerequisites', + 'sequencing', + 'order', + 'blocked by', + 'cross-cuts', + 'related adrs', + 'links', + 'references', + 'see also', + 'upstream', + 'inbound', + ], + update: [ + 'update', + 'revision', + 'amendment', + 'locked design', + 'final decision', + 'post-grilling', + 'addendum', + ], + consequences: [ + 'consequences', + 'implications', + 'impact', + 'what this means', + 'result', + ], +}; + +const CONSEQUENCE_NEGATIVE_HINTS = [ + 'negative', + 'drawback', + 'risk', + 'cost', + 'liability', + 'trade-off', + 'tension', + 'side effect', +]; + +const CONSEQUENCE_POSITIVE_HINTS = [ + 'positive', + 'success', + 'metric', + 'kpi', + 'verification', + 'acceptance', + 'benefit', +]; + +function normalizeAdrHeader(raw) { + return String(raw || '') + .trim() + .toLowerCase() + .replace(/[\s:._-]+/g, ' ') + .replace(/[^\w\s]/g, '') + .trim(); +} + +function classifyHeader(normalizedHeader) { + for (const [canonical, synonyms] of Object.entries(CANONICAL_HEADERS)) { + for (const synonym of synonyms) { + if (normalizedHeader === synonym) return canonical; + if (normalizedHeader.startsWith(`${synonym} `)) return canonical; + } + } + return null; +} + +function splitEntries(blockText) { + return String(blockText || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => line.replace(/^[-*+]\s+/, '').trim()) + .filter(Boolean); +} + +function parseSections(markdown) { + const lines = String(markdown || '').split(/\r?\n/); + const sections = []; + let current = { heading: null, body: [] }; + + for (const line of lines) { + const m = line.match(/^#{1,6}\s+(.*)$/); + if (m) { + if (current.heading || current.body.length) sections.push(current); + current = { heading: m[1].trim(), body: [] }; + } else { + current.body.push(line); + } + } + + if (current.heading || current.body.length) sections.push(current); + return sections; +} + +function parseStatusFromSections(sections) { + for (const section of sections) { + const canonical = classifyHeader(normalizeAdrHeader(section.heading)); + if (canonical !== 'status') continue; + const line = splitEntries(section.body.join('\n'))[0] || ''; + const norm = normalizeAdrHeader(line); + if (!norm) return ''; + if (norm.includes('accepted')) return 'accepted'; + if (norm.includes('proposed')) return 'proposed'; + if (norm.includes('superseded')) return 'superseded'; + if (norm.includes('rejected')) return 'rejected'; + if (norm.includes('deprecated')) return 'deprecated'; + return norm; + } + return ''; +} + +function pushUnique(target, values) { + const seen = new Set(target); + for (const value of values) { + if (!seen.has(value)) { + target.push(value); + seen.add(value); + } + } +} + +function parseConsequences(lines, out) { + for (const entry of lines) { + const lower = entry.toLowerCase(); + if (CONSEQUENCE_NEGATIVE_HINTS.some((hint) => lower.includes(hint))) { + out.consequences_negative.push(entry); + continue; + } + if (CONSEQUENCE_POSITIVE_HINTS.some((hint) => lower.includes(hint))) { + out.consequences_positive.push(entry); + continue; + } + out.consequences_positive.push(entry); + } +} + +function parseAdrMarkdown(markdown, { sourcePath = '', format = 'auto' } = {}) { + const sections = parseSections(markdown); + const titleLine = String(markdown || '').split(/\r?\n/).find((line) => /^#\s+/.test(line)) || ''; + const title = titleLine.replace(/^#\s+/, '').trim(); + + const out = { + title, + status: parseStatusFromSections(sections) || 'accepted', + context: '', + decisions: [], + options_considered: [], + consequences_positive: [], + consequences_negative: [], + out_of_scope: [], + deferred: [], + dependencies: [], + updates: [], + source_path: sourcePath, + key_files: [], + plan_sequence: [], + format, + unmapped_headers: [], + }; + + for (const section of sections) { + const heading = section.heading || ''; + if (!heading) continue; + const canonical = classifyHeader(normalizeAdrHeader(heading)); + const entries = splitEntries(section.body.join('\n')); + const prose = section.body.join('\n').trim(); + + if (!canonical) { + out.unmapped_headers.push(heading); + continue; + } + + switch (canonical) { + case 'goal': + if (!out.context && prose) out.context = prose; + break; + case 'decisions': + pushUnique(out.decisions, entries); + break; + case 'considered_options': + pushUnique(out.options_considered, entries); + break; + case 'risks': + pushUnique(out.consequences_negative, entries); + break; + case 'success_criteria': + pushUnique(out.consequences_positive, entries); + break; + case 'plan_sequence': + pushUnique(out.plan_sequence, entries); + break; + case 'key_files': + pushUnique(out.key_files, entries); + break; + case 'out_of_scope': + pushUnique(out.out_of_scope, entries); + break; + case 'deferred': + pushUnique(out.deferred, entries); + break; + case 'dependencies': + pushUnique(out.dependencies, entries); + break; + case 'update': + out.updates.push({ heading, entries }); + break; + case 'consequences': + parseConsequences(entries, out); + break; + default: + break; + } + } + + return out; +} + +function shouldRejectAdrStatus(status) { + return STATUS_REJECT_SET.has(normalizeAdrHeader(status)); +} + +function parseCliArgs(argv) { + const opts = { input: null, format: 'auto', projectDir: process.cwd() }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--input') { + opts.input = argv[++i] || null; + } else if (arg === '--format') { + opts.format = argv[++i] || 'auto'; + } else if (arg === '--project-dir') { + opts.projectDir = argv[++i] || process.cwd(); + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + if (!opts.input) { + throw new Error('Missing required --input '); + } + return opts; +} + +function main(argv) { + const opts = parseCliArgs(argv); + const safePath = requireSafePath(opts.input, path.resolve(opts.projectDir), 'ADR input path', { allowAbsolute: true }); + const content = fs.readFileSync(safePath, 'utf8'); + const parsed = parseAdrMarkdown(content, { sourcePath: opts.input, format: opts.format }); + process.stdout.write(JSON.stringify(parsed, null, 2)); +} + +if (require.main === module) { + try { + main(process.argv.slice(2)); + } catch (error) { + process.stderr.write(`Error: ${error.message}\n`); + process.exit(1); + } +} + +module.exports = { + CANONICAL_HEADERS, + normalizeAdrHeader, + parseAdrMarkdown, + shouldRejectAdrStatus, +}; diff --git a/get-shit-done/workflows/help.md b/get-shit-done/workflows/help.md index 3462e6afc..9fd7b524f 100644 --- a/get-shit-done/workflows/help.md +++ b/get-shit-done/workflows/help.md @@ -92,7 +92,7 @@ Plan a phase as a vertical MVP slice — three structured user-story prompts (`A Usage: `/gsd-mvp-phase 1` Usage: `/gsd-mvp-phase 2 --force` -**`/gsd-plan-phase [--research] [--skip-research] [--research-phase ] [--view] [--gaps] [--skip-verify] [--tdd] [--mvp]`** +**`/gsd-plan-phase [--research] [--skip-research] [--research-phase ] [--view] [--gaps] [--skip-verify] [--prd ] [--ingest ] [--ingest-format ] [--tdd] [--mvp]`** Create detailed execution plan for a specific phase. - `--skip-research` — bypass the research subagent @@ -100,6 +100,9 @@ Create detailed execution plan for a specific phase. - Modifiers: `--research` forces refresh (re-spawn researcher, no prompt). `--view` prints existing `RESEARCH.md` to stdout without spawning. With neither, prompts `update / view / skip` if `RESEARCH.md` already exists. - `--gaps` — focus only on closing gaps from a prior plan-check - `--skip-verify` — skip the post-plan verifier loop +- `--prd ` — use a PRD file as planning context and skip discuss-phase (mutually exclusive with `--ingest`) +- `--ingest ` — use ADR file(s) as planning context and skip discuss-phase (mutually exclusive with `--prd`) +- `--ingest-format ` — optional ADR parser format override - `--tdd` — plan in test-driven order (tests before code) - `--mvp` — vertical-slice MVP planning mode @@ -114,7 +117,9 @@ Usage: `/gsd-plan-phase --research-phase 2 --view` — print existing `RESEARCH. Usage: `/gsd-plan-phase --research-phase 2 --research` — force-refresh, no prompt Result: Creates `.planning/phases/01-foundation/01-01-PLAN.md` -**PRD Express Path:** Pass `--prd path/to/requirements.md` to skip discuss-phase entirely. Your PRD becomes locked decisions in CONTEXT.md. Useful when you already have clear acceptance criteria. +**PRD Express Path:** Pass `--prd path/to/requirements.md` to skip discuss-phase entirely. Your PRD becomes locked decisions in CONTEXT.md. Useful when you already have clear acceptance criteria. Cannot be combined with `--ingest`. + +**ADR Ingest Express Path:** Pass `--ingest path/to/adr.md` (or a glob) to skip discuss-phase and synthesize CONTEXT.md from approved ADR decisions and scope fences. Cannot be combined with `--prd`. ### Execution diff --git a/get-shit-done/workflows/plan-phase.md b/get-shit-done/workflows/plan-phase.md index 10d268f80..cfed47888 100644 --- a/get-shit-done/workflows/plan-phase.md +++ b/get-shit-done/workflows/plan-phase.md @@ -55,7 +55,7 @@ Parse JSON for: `researcher_model`, `planner_model`, `checker_model`, `research_ ## 2. Parse and Normalize Arguments -Extract from $ARGUMENTS: phase number (integer or decimal like `2.1`), flags (`--research`, `--skip-research`, `--research-phase `, `--gaps`, `--skip-verify`, `--skip-ui`, `--prd `, `--reviews`, `--text`, `--bounce`, `--skip-bounce`, `--chunked`, `--mvp`). +Extract from $ARGUMENTS: phase number (integer or decimal like `2.1`), flags (`--research`, `--skip-research`, `--research-phase `, `--gaps`, `--skip-verify`, `--skip-ui`, `--prd `, `--ingest `, `--ingest-format `, `--reviews`, `--text`, `--bounce`, `--skip-bounce`, `--chunked`, `--mvp`). **`--research-phase ` — research-only mode (#3042 + #3044).** When this flag is present, parse `` as the phase number (overrides any positional phase argument), set `RESEARCH_ONLY=true`, and treat the rest of this workflow as a research-dispatch only — the planner spawn (step 8), plan-checker, verification, gaps, bounce, and post-planning-gaps blocks all skip on `RESEARCH_ONLY`. Use this for cross-phase research, doc review before committing to a planning approach, and correction-without-replanning loops. Replaces the deleted `/gsd-research-phase` command. @@ -104,7 +104,10 @@ When `WALKING_SKELETON=true`: **Interaction with `--prd `.** `--mvp` and `--prd` compose. The PRD express path (Step 3.5) creates `CONTEXT.md` from the PRD file and continues to research; the Walking Skeleton gate fires independently from the conditions above. When both are active on Phase 1 of a new project, the planner receives `WALKING_SKELETON=true` and PRD-derived context simultaneously — the PRD informs *what the skeleton should prove*. No precedence is needed; the two signals are orthogonal. See [`references/mvp-concepts.md`](../references/mvp-concepts.md) for the broader interaction map. -Extract `--prd ` from $ARGUMENTS. If present, set PRD_FILE to the filepath. +Extract express-path args from $ARGUMENTS: `PRD_FILE` (`--prd `), `INGEST_PATH` (`--ingest `), and optional `INGEST_FORMAT` (`--ingest-format `, default `auto`). + +`--prd` and `--ingest` are mutually exclusive. If both are present, error and exit: +`Invalid arguments: cannot combine \`--prd\` with \`--ingest\`.` **If no phase number:** Detect next unplanned phase from roadmap. @@ -261,9 +264,24 @@ gsd-sdk query commit "docs(${padded_phase}): generate context from PRD" --files **Effect:** This completely bypasses step 4 (Load CONTEXT.md) since we just created it. The rest of the workflow (research, planning, verification) proceeds normally with the PRD-derived context. +## 3.6. Handle ADR Ingest Express Path + +**Skip if:** No `--ingest` flag in arguments. + +**If `--ingest ` provided:** + +1. Display banner: `GSD ► ADR Ingest Express Path` with `{INGEST_PATH}` and `{INGEST_FORMAT}`. +2. Parse each resolved ADR through `get-shit-done/bin/lib/adr-parser.cjs` (`--input`, `--format`) and collect normalized records. +3. Status gate: reject `superseded`/`rejected`/`deprecated`; warn on `proposed`; missing status defaults to `accepted`. +4. Empty-decisions fallback: if all parsed ADRs have zero `decisions[]`, emit `ADR ingest produced no locked decisions; fall back to discuss-phase for this phase.` and exit with `/gsd-discuss-phase {N}` guidance. +5. Generate CONTEXT.md using ``, ``, ``, ``, ``, ``, map `consequences_positive[]` to Success Criteria and `consequences_negative[]` to Risk Summary, and include `**Source:** ADR Ingest Express Path ({INGEST_PATH})`. +6. Commit with `gsd-sdk query commit "docs(${padded_phase}): generate context from ADR ingest" --files "${phase_dir}/${padded_phase}-CONTEXT.md"` and set `context_content`; continue to step 5. + +**Effect:** This bypasses step 4 (Load CONTEXT.md) since CONTEXT.md was synthesized from ADR input. + ## 4. Load CONTEXT.md -**Skip if:** PRD express path was used (CONTEXT.md already created in step 3.5). +**Skip if:** PRD express path or ADR ingest express path was used (CONTEXT.md already created in step 3.5/3.6). Check `context_path` from init JSON. diff --git a/tests/adr-parser.test.cjs b/tests/adr-parser.test.cjs new file mode 100644 index 000000000..b205aac9d --- /dev/null +++ b/tests/adr-parser.test.cjs @@ -0,0 +1,101 @@ +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + parseAdrMarkdown, + shouldRejectAdrStatus, +} = require('../get-shit-done/bin/lib/adr-parser.cjs'); + +describe('adr-parser', () => { + test('maps common ADR header synonyms into canonical fields', () => { + const markdown = [ + '# ADR-0010: Deepening Roadmap', + '', + '## Status', + 'Accepted', + '', + '## Background', + 'We need a safer ingest path.', + '', + '## Decision', + '- Add `--ingest` flag.', + '', + '## Considered Options', + '- Keep only `--prd`.', + '', + '## Out of Scope', + '- Remote URL ingest.', + '', + '## Future Work', + '- Add URL ingestion later.', + '', + '## Dependencies', + '- ADR-0002', + '', + '## Consequences', + '- Positive: fewer manual transforms.', + '- Negative: parser maintenance overhead.', + ].join('\n'); + + const out = parseAdrMarkdown(markdown, { sourcePath: 'docs/adr/0010.md' }); + + assert.equal(out.title, 'ADR-0010: Deepening Roadmap'); + assert.equal(out.status, 'accepted'); + assert.equal(out.source_path, 'docs/adr/0010.md'); + assert.ok(out.context.includes('safer ingest path')); + assert.deepEqual(out.decisions, ['Add `--ingest` flag.']); + assert.deepEqual(out.options_considered, ['Keep only `--prd`.']); + assert.deepEqual(out.out_of_scope, ['Remote URL ingest.']); + assert.deepEqual(out.deferred, ['Add URL ingestion later.']); + assert.deepEqual(out.dependencies, ['ADR-0002']); + }); + + test('splits umbrella consequences into positive and negative streams', () => { + const markdown = [ + '# ADR', + '', + '## Consequences', + '- Positive: rollout is faster.', + '- Negative: complexity increases.', + '- Success: clear metrics.', + '- Drawback: migration toil.', + ].join('\n'); + + const out = parseAdrMarkdown(markdown, { sourcePath: 'docs/adr/0001.md' }); + + assert.deepEqual(out.consequences_positive, [ + 'Positive: rollout is faster.', + 'Success: clear metrics.', + ]); + assert.deepEqual(out.consequences_negative, [ + 'Negative: complexity increases.', + 'Drawback: migration toil.', + ]); + }); + + test('tracks update/amendment sections as overrides', () => { + const markdown = [ + '# ADR', + '', + '## Decision', + '- First decision.', + '', + '## Update — locked design', + '- Supersede with second decision.', + ].join('\n'); + + const out = parseAdrMarkdown(markdown, { sourcePath: 'docs/adr/0002.md' }); + assert.ok(out.updates.length >= 1); + assert.ok(out.updates[0].heading.toLowerCase().includes('update')); + assert.ok(out.updates[0].entries.includes('Supersede with second decision.')); + }); + + test('reject-status helper blocks superseded/rejected/deprecated', () => { + assert.equal(shouldRejectAdrStatus('superseded'), true); + assert.equal(shouldRejectAdrStatus('rejected'), true); + assert.equal(shouldRejectAdrStatus('deprecated'), true); + assert.equal(shouldRejectAdrStatus('accepted'), false); + assert.equal(shouldRejectAdrStatus('proposed'), false); + assert.equal(shouldRejectAdrStatus(''), false); + }); +}); diff --git a/tests/enh-3209-plan-phase-ingest-adr.test.cjs b/tests/enh-3209-plan-phase-ingest-adr.test.cjs new file mode 100644 index 000000000..7876553d4 --- /dev/null +++ b/tests/enh-3209-plan-phase-ingest-adr.test.cjs @@ -0,0 +1,80 @@ +// allow-test-rule: source-text-is-the-product +// These assertions validate shipped workflow/command markdown contracts. + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..'); +const COMMAND_PATH = path.join(ROOT, 'commands', 'gsd', 'plan-phase.md'); +const WORKFLOW_PATH = path.join(ROOT, 'get-shit-done', 'workflows', 'plan-phase.md'); +const DOCS_COMMANDS_PATH = path.join(ROOT, 'docs', 'COMMANDS.md'); + +function read(filePath) { + return fs.readFileSync(filePath, 'utf8'); +} + +describe('enh #3209: plan-phase ADR ingest express path', () => { + test('command argument-hint advertises --ingest and --ingest-format', () => { + const command = read(COMMAND_PATH); + assert.ok(command.includes('--ingest '), + 'plan-phase command argument-hint must include --ingest '); + assert.ok(command.includes('--ingest-format '), + 'plan-phase command argument-hint must include --ingest-format selector'); + }); + + test('workflow parses --ingest and --ingest-format flags', () => { + const workflow = read(WORKFLOW_PATH); + assert.ok(workflow.includes('--ingest '), + 'plan-phase workflow argument parsing must mention --ingest'); + assert.ok(workflow.includes('--ingest-format'), + 'plan-phase workflow argument parsing must mention --ingest-format'); + }); + + test('workflow has explicit mutual exclusion guard for --prd and --ingest', () => { + const workflow = read(WORKFLOW_PATH); + assert.ok( + workflow.includes('cannot combine `--prd` with `--ingest`') || + workflow.includes('mutually exclusive'), + 'plan-phase workflow must fail fast when --prd and --ingest are both provided' + ); + }); + + test('workflow defines an ADR ingest express-path step', () => { + const workflow = read(WORKFLOW_PATH); + assert.ok(/##\s*(?:\d+(?:\.\d+)*)?\.?\s*Handle ADR Ingest Express Path/i.test(workflow), + 'plan-phase workflow must include a dedicated ADR ingest express-path step'); + assert.ok(workflow.includes('ADR Ingest Express Path'), + 'workflow must display ADR ingest express-path banner text'); + }); + + test('ADR ingest context template includes scope fence and ADR source attribution', () => { + const workflow = read(WORKFLOW_PATH); + assert.ok(workflow.includes(''), + 'ADR ingest context template must include for hard out-of-scope exclusions'); + assert.ok(workflow.includes('Source:** ADR Ingest Express Path'), + 'ADR ingest context template must tag source as ADR Ingest Express Path'); + }); + + test('workflow documents status gate and no-decisions fallback', () => { + const workflow = read(WORKFLOW_PATH); + assert.ok( + workflow.includes('Reject `superseded`/`rejected`/`deprecated`') || + workflow.includes('reject `superseded`/`rejected`/`deprecated`') || + /superseded.*rejected.*deprecated/i.test(workflow), + 'ADR ingest workflow must include status gate for non-active ADRs' + ); + assert.ok( + workflow.includes('empty-decisions fallback') || + workflow.includes('fall back to discuss-phase'), + 'ADR ingest workflow must document fallback when no locked decisions are present' + ); + }); + + test('docs COMMANDS advertises --ingest flag for /gsd-plan-phase', () => { + const commands = read(DOCS_COMMANDS_PATH); + assert.ok(commands.includes('--ingest '), + 'docs/COMMANDS.md must document --ingest for /gsd-plan-phase'); + }); +});