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
This commit is contained in:
Tom Boucher
2026-05-11 23:01:34 -04:00
committed by GitHub
parent 39e0113bcb
commit 8cd874969c
10 changed files with 619 additions and 8 deletions

View File

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

View File

@@ -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 <N>] [--view] [--gaps] [--skip-verify] [--prd <file>] [--reviews] [--text] [--tdd] [--mvp]"
argument-hint: "[phase] [--auto] [--research] [--skip-research] [--research-phase <N>] [--view] [--gaps] [--skip-verify] [--prd <file>] [--ingest <path-or-glob>] [--ingest-format <auto|nygard|madr|narrative>] [--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 <file>` — Use a PRD/acceptance criteria file instead of discuss-phase. Parses requirements into CONTEXT.md automatically. Skips discuss-phase entirely.
- `--ingest <path-or-glob>` — 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 <auto|nygard|madr|narrative>` — 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.

View File

@@ -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 <file>` | Use a PRD file instead of discuss-phase for context |
| `--ingest <path-or-glob>` | Use ADR file(s) instead of discuss-phase for context synthesis |
| `--ingest-format <auto\|nygard\|madr\|narrative>` | 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

View File

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

View File

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

View File

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

View File

@@ -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 <number> [--research] [--skip-research] [--research-phase <N>] [--view] [--gaps] [--skip-verify] [--tdd] [--mvp]`**
**`/gsd-plan-phase <number> [--research] [--skip-research] [--research-phase <N>] [--view] [--gaps] [--skip-verify] [--prd <file>] [--ingest <path-or-glob>] [--ingest-format <auto|nygard|madr|narrative>] [--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 <file>` — use a PRD file as planning context and skip discuss-phase (mutually exclusive with `--ingest`)
- `--ingest <path-or-glob>` — use ADR file(s) as planning context and skip discuss-phase (mutually exclusive with `--prd`)
- `--ingest-format <auto|nygard|madr|narrative>` — 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

View File

@@ -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 <N>`, `--gaps`, `--skip-verify`, `--skip-ui`, `--prd <filepath>`, `--reviews`, `--text`, `--bounce`, `--skip-bounce`, `--chunked`, `--mvp`).
Extract from $ARGUMENTS: phase number (integer or decimal like `2.1`), flags (`--research`, `--skip-research`, `--research-phase <N>`, `--gaps`, `--skip-verify`, `--skip-ui`, `--prd <filepath>`, `--ingest <path-or-glob>`, `--ingest-format <auto|nygard|madr|narrative>`, `--reviews`, `--text`, `--bounce`, `--skip-bounce`, `--chunked`, `--mvp`).
**`--research-phase <N>` — research-only mode (#3042 + #3044).** When this flag is present, parse `<N>` 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 <filepath>`.** `--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 <filepath>` from $ARGUMENTS. If present, set PRD_FILE to the filepath.
Extract express-path args from $ARGUMENTS: `PRD_FILE` (`--prd <filepath>`), `INGEST_PATH` (`--ingest <path-or-glob>`), and optional `INGEST_FORMAT` (`--ingest-format <auto|nygard|madr|narrative>`, 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 <path-or-glob>` 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 `<domain>`, `<decisions>`, `<canonical_refs>`, `<specifics>`, `<deferred>`, `<scope_fence>`, 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.

101
tests/adr-parser.test.cjs Normal file
View File

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

View File

@@ -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 <path-or-glob>'),
'plan-phase command argument-hint must include --ingest <path-or-glob>');
assert.ok(command.includes('--ingest-format <auto|nygard|madr|narrative>'),
'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 <path-or-glob>'),
'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('<scope_fence>'),
'ADR ingest context template must include <scope_fence> 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 <path-or-glob>'),
'docs/COMMANDS.md must document --ingest for /gsd-plan-phase');
});
});