gsdd-cli 0.18.0 → 0.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,7 +12,7 @@ npx gsdd-cli init
12
12
  ```
13
13
 
14
14
  **Directly validated today:** Claude Code, Codex CLI, and OpenCode.
15
- **Qualified support:** Cursor, Copilot, and Gemini CLI support the same core workflow through the shared `.agents/skills/` surface; this release does not claim the same runtime proof or ergonomics.
15
+ **Qualified support:** Cursor, Copilot, and Gemini CLI support the same core workflow through the shared `.agents/skills/` surface; they do not carry the same runtime proof or ergonomics.
16
16
 
17
17
  </div>
18
18
 
@@ -63,7 +63,7 @@ Verification is a separate workflow with a separate context window, not a checkb
63
63
  <details>
64
64
  <summary>How it works</summary>
65
65
 
66
- Invariant suites (I-series), guard suites (G-series), and scenario suites (S-series) run on every change. Each assertion includes a `FIX:` instruction so failures are actionable. 52 documented design decisions in `distilled/DESIGN.md` record the rationale with evidence trails.
66
+ Invariant suites (I-series), guard suites (G-series), and scenario suites (S-series) run on every change. Each assertion includes a `FIX:` instruction so failures are actionable. 58 documented design decisions in `distilled/DESIGN.md` record the rationale with evidence trails.
67
67
 
68
68
  </details>
69
69
 
@@ -102,13 +102,14 @@ In a terminal, `gsdd init` now opens a guided install wizard:
102
102
  - Step 2: decide separately whether repo-wide `AGENTS.md` governance is worth installing
103
103
  - Step 3: configure planning defaults in the same guided flow
104
104
 
105
- Portable `.agents/skills/gsdd-*` skills are always generated. The wizard controls extra native adapters and optional governance, not the portable baseline.
106
- When those generated surfaces exist locally, `gsdd health` checks them against current render output instead of asking you to trust manual review.
105
+ Portable `.agents/skills/gsdd-*` skills are always generated. The wizard controls extra native adapters and optional governance, not the portable baseline.
106
+ When those generated surfaces exist locally, `gsdd health` checks them against current render output instead of asking you to trust manual review.
107
+ `init` generates a local `.planning/bin/gsdd*` helper surface. Workflow-embedded helper commands still use `node .planning/bin/gsdd.mjs ...` as the portable contract, so execution-time lifecycle helpers do not depend on a global `gsdd` binary after init.
107
108
 
108
109
  ### Launch Proof Status
109
110
 
110
111
  - **Directly validated:** Claude Code, Codex CLI, and OpenCode have recorded `plan -> execute -> verify` evidence for the core lifecycle.
111
- - **Qualified support:** Cursor, Copilot, and Gemini CLI support the same core workflow through the shared `.agents/skills/` surface; this release does not claim the same runtime proof or ergonomics.
112
+ - **Qualified support:** Cursor, Copilot, and Gemini CLI support the same core workflow through the shared `.agents/skills/` surface; they do not carry the same runtime proof or ergonomics.
112
113
  - **Runtime-surface freshness:** Installed generated skills and native adapters are renderer-checked locally; repair stays deterministic through `npx gsdd-cli update`.
113
114
 
114
115
  Start with the public proof pack:
@@ -504,7 +505,7 @@ For detailed troubleshooting and recovery procedures, see the [User Guide](docs/
504
505
 
505
506
  ## Design Decisions
506
507
 
507
- This repo records 52 documented design decisions relative to GSD, each with evidence from source files and external research. See [`distilled/DESIGN.md`](distilled/DESIGN.md) for the full rationale.
508
+ This repo records 58 documented design decisions relative to GSD, each with evidence from source files and external research. See [`distilled/DESIGN.md`](distilled/DESIGN.md) for the full rationale.
508
509
 
509
510
  Key choices:
510
511
  - **4-file codebase standard** — drop state that rots (STRUCTURE, INTEGRATIONS, TESTING), keep rules that don't
package/bin/gsdd.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // gsdd - Workspine CLI
4
4
 
5
- import { realpathSync } from 'fs';
5
+ import { realpathSync, readFileSync } from 'fs';
6
6
  import { join, dirname } from 'path';
7
7
  import { fileURLToPath } from 'url';
8
8
  import { createAdapterRegistry } from './adapters/index.mjs';
@@ -21,11 +21,12 @@ import { cmdFileOp } from './lib/file-ops.mjs';
21
21
  import { createCmdHealth } from './lib/health.mjs';
22
22
  import { cmdLifecyclePreflight } from './lib/lifecycle-preflight.mjs';
23
23
 
24
- const __filename = fileURLToPath(import.meta.url);
25
- const __dirname = dirname(__filename);
26
- const DISTILLED_DIR = join(__dirname, '..', 'distilled');
27
- const AGENTS_DIR = join(__dirname, '..', 'agents');
28
- const CWD = process.cwd();
24
+ const __filename = fileURLToPath(import.meta.url);
25
+ const __dirname = dirname(__filename);
26
+ const DISTILLED_DIR = join(__dirname, '..', 'distilled');
27
+ const AGENTS_DIR = join(__dirname, '..', 'agents');
28
+ const PACKAGE_JSON = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
29
+ const CWD = process.cwd();
29
30
  const IS_MAIN = process.argv[1]
30
31
  ? realpathSync(process.argv[1]) === realpathSync(__filename)
31
32
  : false;
@@ -60,14 +61,16 @@ const WORKFLOWS = [
60
61
 
61
62
  const FRAMEWORK_VERSION = 'v1.4';
62
63
 
63
- function createCliContext(cwd = process.cwd()) {
64
- return {
65
- cwd,
66
- planningDir: join(cwd, '.planning'),
67
- distilledDir: DISTILLED_DIR,
68
- agentsDir: AGENTS_DIR,
69
- workflows: WORKFLOWS,
70
- frameworkVersion: FRAMEWORK_VERSION,
64
+ function createCliContext(cwd = process.cwd()) {
65
+ return {
66
+ cwd,
67
+ planningDir: join(cwd, '.planning'),
68
+ distilledDir: DISTILLED_DIR,
69
+ agentsDir: AGENTS_DIR,
70
+ packageName: PACKAGE_JSON.name,
71
+ packageVersion: PACKAGE_JSON.version,
72
+ workflows: WORKFLOWS,
73
+ frameworkVersion: FRAMEWORK_VERSION,
71
74
  adapters: createAdapterRegistry({
72
75
  cwd,
73
76
  workflows: WORKFLOWS,
@@ -87,14 +87,14 @@ export function runTruthChecks(planningDir, frameworkDir, actualCheckIds, option
87
87
  }
88
88
  }
89
89
 
90
- if (options.runtimeFreshnessReport?.issueCount > 0) {
91
- warnings.push({
92
- id: 'W11',
93
- severity: 'WARN',
94
- message: `Installed generated runtime surfaces drift from current render output (${summarizeRuntimeFreshnessIssues(options.runtimeFreshnessReport)})`,
95
- fix: getRuntimeFreshnessRepairGuidance(options.runtimeFreshnessReport),
96
- });
97
- }
90
+ if (options.runtimeFreshnessReport?.issueCount > 0) {
91
+ warnings.push({
92
+ id: 'W11',
93
+ severity: 'WARN',
94
+ message: `Installed generated runtime and workflow-helper surfaces drift from current render output (${summarizeRuntimeFreshnessIssues(options.runtimeFreshnessReport)})`,
95
+ fix: getRuntimeFreshnessRepairGuidance(options.runtimeFreshnessReport),
96
+ });
97
+ }
98
98
 
99
99
  const drift = checkDrift(planningDir);
100
100
  if (drift.drifted) {
@@ -21,7 +21,7 @@ export function createCmdHealth(ctx) {
21
21
  const cwd = process.cwd();
22
22
  const planningDir = join(cwd, '.planning');
23
23
  const frameworkSourceMode = isFrameworkSourceRepo(cwd);
24
- const healthCheckIds = ['E1', 'E2', 'E3', 'E4', 'E5', 'E6', 'E7', 'E8', 'W1', 'W2', 'W3', 'W4', 'W5', 'W6', ...TRUTH_CHECK_IDS, 'I1', 'I2', 'I3'];
24
+ const healthCheckIds = ['E1', 'E2', 'E3', 'E4', 'E5', 'E6', 'E7', 'E8', 'W1', 'W2', 'W3', 'W4', 'W5', 'W6', ...TRUTH_CHECK_IDS, 'I1', 'I2', 'I3'];
25
25
 
26
26
  // Pre-init guard
27
27
  if (!existsSync(join(planningDir, 'config.json'))) {
@@ -57,8 +57,10 @@ export function createCmdHealth(ctx) {
57
57
  }
58
58
 
59
59
  // E3: templates/ missing
60
- const templatesDir = join(planningDir, 'templates');
61
- const hasTemplatesDir = existsSync(templatesDir);
60
+ const templatesDir = join(planningDir, 'templates');
61
+ const runtimeHelpersDir = join(planningDir, 'bin');
62
+ const hasTemplatesDir = existsSync(templatesDir);
63
+ const hasRuntimeHelpersDir = existsSync(runtimeHelpersDir);
62
64
  const rolesDir = join(templatesDir, 'roles');
63
65
  const delegatesDir = join(templatesDir, 'delegates');
64
66
  const hasRolesDir = hasTemplatesDir && existsSync(rolesDir);
@@ -121,32 +123,33 @@ export function createCmdHealth(ctx) {
121
123
  // --- WARNING checks ---
122
124
 
123
125
  // W1: generation-manifest.json missing
124
- const manifest = skipInstalledTemplateChecks ? null : readManifest(planningDir);
125
- if (!manifest && !skipInstalledTemplateChecks) {
126
- warnings.push({ id: 'W1', severity: 'WARN', message: 'generation-manifest.json missing', fix: 'Run `gsdd update --templates` to create' });
127
- }
126
+ const manifest = skipInstalledTemplateChecks ? null : readManifest(planningDir);
127
+ if (!manifest && !skipInstalledTemplateChecks) {
128
+ warnings.push({ id: 'W1', severity: 'WARN', message: 'generation-manifest.json missing', fix: 'Run `gsdd update` to create' });
129
+ }
128
130
 
129
131
  // W2 + W3: template/role hash mismatches and missing files
130
132
  if (manifest && hasTemplatesDir) {
131
- const allCategories = [
132
- { name: 'delegates', dir: delegatesDir, hashes: hasDelegatesDir ? manifest.templates?.delegates : null },
133
- { name: 'research', dir: join(templatesDir, 'research'), hashes: manifest.templates?.research },
134
- { name: 'codebase', dir: join(templatesDir, 'codebase'), hashes: manifest.templates?.codebase },
135
- { name: 'root templates', dir: templatesDir, hashes: manifest.templates?.root },
136
- { name: 'roles', dir: rolesDir, hashes: hasRolesDir ? manifest.roles : null },
137
- ];
138
-
139
- for (const cat of allCategories) {
140
- if (!cat.hashes) continue;
141
- const result = detectModifications(cat.dir, cat.hashes);
142
- if (result.modified.length > 0) {
143
- warnings.push({ id: 'W2', severity: 'WARN', message: `${cat.name}: ${result.modified.length} file(s) modified locally (${result.modified.join(', ')})`, fix: 'Intentional? Run `gsdd update --templates` to reset' });
144
- }
145
- if (result.missing.length > 0) {
146
- warnings.push({ id: 'W3', severity: 'WARN', message: `${cat.name}: ${result.missing.length} file(s) missing from disk (${result.missing.join(', ')})`, fix: 'Run `gsdd update --templates` to restore' });
147
- }
148
- }
149
- }
133
+ const allCategories = [
134
+ { name: 'delegates', dir: delegatesDir, hashes: hasDelegatesDir ? manifest.templates?.delegates : null, fixCommand: 'gsdd update --templates' },
135
+ { name: 'research', dir: join(templatesDir, 'research'), hashes: manifest.templates?.research, fixCommand: 'gsdd update --templates' },
136
+ { name: 'codebase', dir: join(templatesDir, 'codebase'), hashes: manifest.templates?.codebase, fixCommand: 'gsdd update --templates' },
137
+ { name: 'root templates', dir: templatesDir, hashes: manifest.templates?.root, fixCommand: 'gsdd update --templates' },
138
+ { name: 'roles', dir: rolesDir, hashes: hasRolesDir ? manifest.roles : null, fixCommand: 'gsdd update --templates' },
139
+ { name: 'runtime helpers', dir: planningDir, hashes: hasRuntimeHelpersDir ? manifest.runtimeHelpers : null, fixCommand: 'gsdd update' },
140
+ ];
141
+
142
+ for (const cat of allCategories) {
143
+ if (!cat.hashes) continue;
144
+ const result = detectModifications(cat.dir, cat.hashes);
145
+ if (result.modified.length > 0) {
146
+ warnings.push({ id: 'W2', severity: 'WARN', message: `${cat.name}: ${result.modified.length} file(s) modified locally (${result.modified.join(', ')})`, fix: `Intentional? Run \`${cat.fixCommand}\` to reset` });
147
+ }
148
+ if (result.missing.length > 0) {
149
+ warnings.push({ id: 'W3', severity: 'WARN', message: `${cat.name}: ${result.missing.length} file(s) missing from disk (${result.missing.join(', ')})`, fix: `Run \`${cat.fixCommand}\` to restore` });
150
+ }
151
+ }
152
+ }
150
153
 
151
154
  // W4: ROADMAP.md references phases not found in .planning/phases/
152
155
  const roadmapPath = join(planningDir, 'ROADMAP.md');
@@ -1,7 +1,7 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync, cpSync } from 'fs';
2
- import { join, isAbsolute } from 'path';
3
- import { renderSkillContent } from './rendering.mjs';
4
- import { buildManifest, writeManifest } from './manifest.mjs';
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, cpSync } from 'fs';
2
+ import { dirname, join, isAbsolute } from 'path';
3
+ import { buildPlanningCliHelperEntries, renderSkillContent } from './rendering.mjs';
4
+ import { buildManifest, readManifest, writeManifest } from './manifest.mjs';
5
5
  import { parseFlagValue, parseToolsFlag, parseAutoFlag } from './cli-utils.mjs';
6
6
  import { buildDefaultConfig, COST_PROFILES, RIGOR_PROFILES } from './models.mjs';
7
7
  import { installProjectTemplates, refreshTemplates } from './templates.mjs';
@@ -106,8 +106,11 @@ export function createCmdInit(ctx) {
106
106
  console.log(' - copied project brief to .planning/PROJECT_BRIEF.md');
107
107
  }
108
108
 
109
- generateOpenStandardSkills(ctx.cwd, ctx.workflows);
110
- console.log(' - generated open-standard skills (.agents/skills/gsdd-*)');
109
+ generateOpenStandardSkills(ctx.cwd, ctx.workflows);
110
+ console.log(' - generated open-standard skills (.agents/skills/gsdd-*)');
111
+
112
+ generatePlanningCliHelpers(ctx);
113
+ console.log(' - generated local workflow helpers (.planning/bin/gsdd*)');
111
114
 
112
115
  for (const adapter of resolveAdapters(ctx.adapters, interactiveSession.adapterTargets)) {
113
116
  adapter.generate();
@@ -144,15 +147,25 @@ export function createCmdUpdate(ctx) {
144
147
  updated = true;
145
148
  }
146
149
 
147
- if (platforms.length > 0 || existsSync(join(ctx.cwd, '.agents', 'skills'))) {
148
- if (isDry) {
149
- console.log(' - would update open-standard skills (.agents/skills/gsdd-*)');
150
- } else {
151
- generateOpenStandardSkills(ctx.cwd, ctx.workflows);
150
+ if (platforms.length > 0 || existsSync(join(ctx.cwd, '.agents', 'skills'))) {
151
+ if (isDry) {
152
+ console.log(' - would update open-standard skills (.agents/skills/gsdd-*)');
153
+ } else {
154
+ generateOpenStandardSkills(ctx.cwd, ctx.workflows);
152
155
  console.log(' - updated open-standard skills (.agents/skills/gsdd-*)');
153
- }
154
- updated = true;
155
- }
156
+ }
157
+ updated = true;
158
+ }
159
+
160
+ if (existsSync(ctx.planningDir)) {
161
+ if (isDry) {
162
+ console.log(' - would update local workflow helpers (.planning/bin/gsdd*)');
163
+ } else {
164
+ generatePlanningCliHelpers(ctx);
165
+ console.log(' - updated local workflow helpers (.planning/bin/gsdd*)');
166
+ }
167
+ updated = true;
168
+ }
156
169
 
157
170
  for (const adapter of getAdaptersToUpdate(ctx.adapters, platforms)) {
158
171
  if (isDry) {
@@ -167,26 +180,72 @@ export function createCmdUpdate(ctx) {
167
180
 
168
181
  if (!updated) {
169
182
  console.log(' - no adapters found to update (run `gsdd init` first)');
170
- } else if (isDry) {
171
- console.log('\nDry run complete. No files were written.\n');
172
- } else {
173
- if (doTemplates && existsSync(ctx.planningDir)) {
174
- const manifest = buildManifest({ planningDir: ctx.planningDir, frameworkVersion: ctx.frameworkVersion });
175
- writeManifest(ctx.planningDir, manifest);
176
- console.log(' - updated generation manifest');
177
- }
178
- console.log('\nAdapters updated.\n');
179
- }
180
- };
181
- }
182
-
183
- function generateOpenStandardSkills(cwd, workflows) {
184
- for (const workflow of workflows) {
185
- const dir = join(cwd, '.agents', 'skills', workflow.name);
186
- mkdirSync(dir, { recursive: true });
187
- writeFileSync(join(dir, 'SKILL.md'), renderSkillContent(workflow));
188
- }
189
- }
183
+ } else if (isDry) {
184
+ console.log('\nDry run complete. No files were written.\n');
185
+ } else {
186
+ if (existsSync(ctx.planningDir)) {
187
+ const manifest = buildUpdateManifest({
188
+ planningDir: ctx.planningDir,
189
+ frameworkVersion: ctx.frameworkVersion,
190
+ updateTemplates: doTemplates,
191
+ });
192
+ if (manifest) {
193
+ writeManifest(ctx.planningDir, manifest);
194
+ console.log(' - updated generation manifest');
195
+ }
196
+ }
197
+ console.log('\nAdapters updated.\n');
198
+ }
199
+ };
200
+ }
201
+
202
+ function generateOpenStandardSkills(cwd, workflows) {
203
+ for (const workflow of workflows) {
204
+ const dir = join(cwd, '.agents', 'skills', workflow.name);
205
+ mkdirSync(dir, { recursive: true });
206
+ writeFileSync(join(dir, 'SKILL.md'), renderSkillContent(workflow));
207
+ }
208
+ }
209
+
210
+ function generatePlanningCliHelpers(ctx) {
211
+ for (const entry of buildPlanningCliHelperEntries({
212
+ packageName: ctx.packageName,
213
+ packageVersion: ctx.packageVersion,
214
+ })) {
215
+ const absolutePath = join(ctx.planningDir, entry.relativePath);
216
+ mkdirSync(dirname(absolutePath), { recursive: true });
217
+ writeFileSync(absolutePath, entry.content);
218
+ if (!absolutePath.endsWith('.cmd')) {
219
+ chmodSync(absolutePath, 0o755);
220
+ }
221
+ }
222
+ }
223
+
224
+ function buildUpdateManifest({ planningDir, frameworkVersion, updateTemplates }) {
225
+ const existingManifest = readManifest(planningDir);
226
+ const nextManifest = buildManifest({ planningDir, frameworkVersion });
227
+
228
+ if (existingManifest && !updateTemplates) {
229
+ nextManifest.templates = existingManifest.templates ?? nextManifest.templates;
230
+ nextManifest.roles = existingManifest.roles ?? nextManifest.roles;
231
+ }
232
+
233
+ if (existingManifest && manifestsEqualIgnoringTimestamp(existingManifest, nextManifest)) {
234
+ return null;
235
+ }
236
+
237
+ return nextManifest;
238
+ }
239
+
240
+ function manifestsEqualIgnoringTimestamp(left, right) {
241
+ return JSON.stringify(stripManifestTimestamp(left)) === JSON.stringify(stripManifestTimestamp(right));
242
+ }
243
+
244
+ function stripManifestTimestamp(manifest) {
245
+ if (!manifest || typeof manifest !== 'object') return manifest;
246
+ const { generatedAt, ...rest } = manifest;
247
+ return rest;
248
+ }
190
249
 
191
250
  async function ensureConfig({ cwd, planningDir, isAuto, promptApi, preselectedConfig = null }) {
192
251
  const configFile = join(planningDir, 'config.json');
@@ -161,8 +161,8 @@ export function getPostInitRoutingLines(selectedRuntimes) {
161
161
 
162
162
  export function getHelpText() {
163
163
  return `
164
- gsdd - Workspine CLI
165
- Portable multi-runtime software delivery framework for AI coding agents.
164
+ gsdd - Workspine CLI
165
+ Repo-native delivery spine for long-horizon AI-assisted work across coding runtimes.
166
166
 
167
167
  Usage: gsdd <command> [args]
168
168
 
@@ -198,8 +198,9 @@ Platforms (for --tools):
198
198
  all Generate all adapters (Claude, OpenCode, Codex, AGENTS.md, Cursor, Copilot, Gemini)
199
199
 
200
200
  Notes:
201
- - init always generates open-standard skills at .agents/skills/gsdd-* (portable workflow entrypoints)
202
- - Workspine is the public product name; the retained package, command, workflow, and workspace contracts stay gsdd-cli, gsdd, gsdd-*, and .planning/
201
+ - init always generates open-standard skills at .agents/skills/gsdd-* (portable workflow entrypoints)
202
+ - init also generates a local .planning/bin/gsdd* helper surface so workflow-embedded helper commands do not depend on a global gsdd binary
203
+ - Workspine is the public product name; the retained package, command, workflow, and workspace contracts stay gsdd-cli, gsdd, gsdd-*, and .planning/
203
204
  - running plain \`gsdd init\` in a terminal opens the guided runtime-selection wizard
204
205
  - the wizard lets you pick runtimes first, then separately decide whether repo-wide AGENTS.md governance is worth installing
205
206
  - \`gsdd health\` compares any installed generated runtime surfaces against current render output and points back to \`gsdd update\` when they drift
@@ -1,5 +1,7 @@
1
- import { existsSync, readFileSync, readdirSync } from 'fs';
2
- import { join } from 'path';
1
+ import { existsSync, readFileSync, readdirSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ const BROWNFIELD_CHANGE_DIR = 'brownfield-change';
3
5
 
4
6
  const PHASE_LINE_RE = /^\s*[-*]\s*\[([ x-])\]\s*\*\*Phase\s+(\d+(?:\.\d+)*[a-z]?):\s*(.+?)\*\*(?:\s+—\s+\[([^\]]+)])?/i;
5
7
  const ACTIVE_MILESTONE_HEADING_RE = /^###\s+(v[^\s]+)\s+(.+)$/im;
@@ -15,9 +17,16 @@ export function evaluateLifecycleState({ planningDir, provenance = null } = {})
15
17
  const milestonesPath = join(planningDir, 'MILESTONES.md');
16
18
  const phasesDir = join(planningDir, 'phases');
17
19
 
18
- const spec = readTextIfExists(specPath);
19
- const roadmap = readTextIfExists(roadmapPath);
20
- const milestones = readTextIfExists(milestonesPath);
20
+ const spec = readTextIfExists(specPath);
21
+ const roadmap = readTextIfExists(roadmapPath);
22
+ const milestones = readTextIfExists(milestonesPath);
23
+ const brownfieldChange = readBrownfieldChangeState(planningDir);
24
+ const nonPhaseState = deriveNonPhaseState({
25
+ planningDir,
26
+ hasSpec: Boolean(spec.trim()),
27
+ hasRoadmap: Boolean(roadmap.trim()),
28
+ brownfieldChange,
29
+ });
21
30
 
22
31
  const phases = parseActiveRoadmapPhases(roadmap);
23
32
  const phaseArtifacts = collectPhaseArtifacts(phasesDir);
@@ -69,10 +78,12 @@ export function evaluateLifecycleState({ planningDir, provenance = null } = {})
69
78
  phases: enrichedPhases,
70
79
  currentPhase: enrichedPhases.find((phase) => phase.status === 'in_progress') || null,
71
80
  nextPhase: enrichedPhases.find((phase) => phase.status === 'not_started') || null,
72
- counts,
73
- phaseArtifacts,
74
- incompletePlans,
75
- requirementAlignment: evaluateRequirementAlignment(spec, enrichedPhases),
81
+ counts,
82
+ phaseArtifacts,
83
+ incompletePlans,
84
+ brownfieldChange,
85
+ nonPhaseState,
86
+ requirementAlignment: evaluateRequirementAlignment(spec, enrichedPhases),
76
87
  provenance: provenance
77
88
  ? {
78
89
  provided: true,
@@ -86,9 +97,13 @@ function readTextIfExists(filePath) {
86
97
  return existsSync(filePath) ? readFileSync(filePath, 'utf-8') : '';
87
98
  }
88
99
 
89
- function normalizeContent(content) {
90
- return String(content || '').replace(/\r\n/g, '\n');
91
- }
100
+ function normalizeContent(content) {
101
+ return String(content || '').replace(/\r\n/g, '\n');
102
+ }
103
+
104
+ function escapeRegExp(value) {
105
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
106
+ }
92
107
 
93
108
  export function normalizePhaseToken(value) {
94
109
  const raw = String(value || '').trim().toLowerCase();
@@ -108,8 +123,8 @@ function normalizePhaseStatus(marker) {
108
123
  return 'not_started';
109
124
  }
110
125
 
111
- function parseActiveRoadmapPhases(roadmap) {
112
- if (!roadmap) return [];
126
+ function parseActiveRoadmapPhases(roadmap) {
127
+ if (!roadmap) return [];
113
128
 
114
129
  const phases = [];
115
130
  let inDetails = false;
@@ -135,12 +150,74 @@ function parseActiveRoadmapPhases(roadmap) {
135
150
  requirements: splitRequirementList(match[4]),
136
151
  });
137
152
  }
138
-
139
- return phases;
140
- }
141
-
142
- function splitRequirementList(rawRequirements = '') {
143
- return String(rawRequirements)
153
+
154
+ return phases;
155
+ }
156
+
157
+ export function readBrownfieldChangeState(planningDir) {
158
+ const dir = join(planningDir, BROWNFIELD_CHANGE_DIR);
159
+ const changePath = join(dir, 'CHANGE.md');
160
+ const handoffPath = join(dir, 'HANDOFF.md');
161
+
162
+ if (!existsSync(changePath)) {
163
+ return {
164
+ exists: false,
165
+ dir,
166
+ changePath,
167
+ handoffPath,
168
+ title: null,
169
+ changeId: null,
170
+ currentStatus: null,
171
+ currentIntegrationSurface: null,
172
+ currentOwnerRuntime: null,
173
+ nextAction: null,
174
+ declaredOwnedPaths: [],
175
+ handoff: null,
176
+ };
177
+ }
178
+
179
+ const changeArtifact = readMarkdownArtifact(changePath);
180
+ const handoffArtifact = existsSync(handoffPath) ? readMarkdownArtifact(handoffPath) : null;
181
+ const currentStatusSection = extractMarkdownSection(changeArtifact.body, 'Current Status');
182
+ const nextActionSection = extractMarkdownSection(changeArtifact.body, 'Next Action');
183
+ const sliceSection = extractMarkdownSection(changeArtifact.body, 'PR Slice Ownership');
184
+
185
+ return {
186
+ exists: true,
187
+ dir,
188
+ changePath,
189
+ handoffPath,
190
+ title: extractMarkdownHeading(changeArtifact.body),
191
+ changeId: changeArtifact.frontmatter.change || null,
192
+ currentStatus: changeArtifact.frontmatter.status || extractBulletLabel(currentStatusSection, 'Current posture'),
193
+ currentIntegrationSurface: extractBulletLabel(currentStatusSection, 'Current branch / integration surface'),
194
+ currentOwnerRuntime: extractBulletLabel(currentStatusSection, 'Current owner / runtime'),
195
+ nextAction: collapseMarkdownSection(nextActionSection),
196
+ declaredOwnedPaths: parseOwnedPathHints(sliceSection),
197
+ handoff: handoffArtifact
198
+ ? {
199
+ updated: handoffArtifact.frontmatter.updated || null,
200
+ activeConstraints: collapseMarkdownSection(extractMarkdownSection(handoffArtifact.body, 'Active Constraints')),
201
+ unresolvedUncertainty: collapseMarkdownSection(extractMarkdownSection(handoffArtifact.body, 'Unresolved Uncertainty')),
202
+ decisionPosture: collapseMarkdownSection(extractMarkdownSection(handoffArtifact.body, 'Decision Posture')),
203
+ antiRegression: collapseMarkdownSection(extractMarkdownSection(handoffArtifact.body, 'Anti-Regression')),
204
+ nextActionContext: collapseMarkdownSection(extractMarkdownSection(handoffArtifact.body, 'Next Action')),
205
+ }
206
+ : null,
207
+ };
208
+ }
209
+
210
+ export function deriveNonPhaseState({ planningDir, hasSpec, hasRoadmap, brownfieldChange } = {}) {
211
+ if (brownfieldChange?.exists) return 'active_brownfield_change';
212
+ if (hasRoadmap) return null;
213
+ if (hasSpec) return 'between_milestones';
214
+ if (hasSubstantiveCodebaseMaps(planningDir)) return 'codebase_only';
215
+ if (hasQuickLaneArtifacts(planningDir)) return 'quick_lane';
216
+ return null;
217
+ }
218
+
219
+ function splitRequirementList(rawRequirements = '') {
220
+ return String(rawRequirements)
144
221
  .split(',')
145
222
  .map((entry) => entry.trim())
146
223
  .filter(Boolean);
@@ -262,10 +339,10 @@ function deriveCurrentMilestone({ roadmap, planningDir, milestoneLedger, counts
262
339
  };
263
340
  }
264
341
 
265
- function evaluateRequirementAlignment(spec, phases) {
266
- const checkedRequirements = new Set(
267
- [...normalizeContent(spec).matchAll(/- \[x\] \*\*\[([A-Z0-9-]+)]\*\*/g)].map((result) => result[1])
268
- );
342
+ function evaluateRequirementAlignment(spec, phases) {
343
+ const checkedRequirements = new Set(
344
+ [...normalizeContent(spec).matchAll(/- \[x\] \*\*\[([A-Z0-9-]+)]\*\*/g)].map((result) => result[1])
345
+ );
269
346
 
270
347
  const roadmapRequirements = new Map();
271
348
  for (const phase of phases) {
@@ -287,7 +364,113 @@ function evaluateRequirementAlignment(spec, phases) {
287
364
 
288
365
  return {
289
366
  checkedRequirements,
290
- roadmapRequirements,
291
- mismatches,
292
- };
293
- }
367
+ roadmapRequirements,
368
+ mismatches,
369
+ };
370
+ }
371
+
372
+ function readMarkdownArtifact(filePath) {
373
+ const raw = readTextIfExists(filePath);
374
+ const normalized = normalizeContent(raw);
375
+ const match = normalized.match(/^---\n([\s\S]*?)\n---\n?/);
376
+ if (!match) {
377
+ return {
378
+ raw: normalized,
379
+ frontmatter: {},
380
+ body: normalized,
381
+ };
382
+ }
383
+
384
+ return {
385
+ raw: normalized,
386
+ frontmatter: parseFrontmatter(match[1]),
387
+ body: normalized.slice(match[0].length),
388
+ };
389
+ }
390
+
391
+ function parseFrontmatter(content) {
392
+ const data = {};
393
+ for (const line of normalizeContent(content).split('\n')) {
394
+ const match = line.match(/^([A-Za-z0-9_-]+):\s*(.+)$/);
395
+ if (!match) continue;
396
+ data[match[1]] = match[2].trim();
397
+ }
398
+ return data;
399
+ }
400
+
401
+ function extractMarkdownHeading(content) {
402
+ return normalizeContent(content).match(/^#\s+(.+)$/m)?.[1]?.trim() || null;
403
+ }
404
+
405
+ function extractMarkdownSection(content, heading) {
406
+ const normalized = normalizeContent(content);
407
+ const headingMatch = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, 'm').exec(normalized);
408
+ if (!headingMatch) return '';
409
+
410
+ const start = headingMatch.index + headingMatch[0].length;
411
+ const remainder = normalized.slice(start).replace(/^\n/, '');
412
+ const nextHeading = /\n##\s+/.exec(remainder);
413
+ const end = nextHeading ? nextHeading.index : remainder.length;
414
+ return remainder.slice(0, end).trim();
415
+ }
416
+
417
+ function extractBulletLabel(section, label) {
418
+ const match = normalizeContent(section).match(
419
+ new RegExp(`^[-*]\\s+${escapeRegExp(label)}\\s*:\\s*(.+)$`, 'im')
420
+ );
421
+ return match ? match[1].trim() : null;
422
+ }
423
+
424
+ function collapseMarkdownSection(section) {
425
+ return normalizeContent(section)
426
+ .split('\n')
427
+ .map((line) => line.replace(/^[-*]\s+/, '').trim())
428
+ .filter(Boolean)
429
+ .join(' ');
430
+ }
431
+
432
+ function parseOwnedPathHints(section) {
433
+ const lines = normalizeContent(section)
434
+ .split('\n')
435
+ .map((line) => line.trim())
436
+ .filter((line) => line.startsWith('|'));
437
+ const paths = [];
438
+
439
+ for (const line of lines.slice(2)) {
440
+ const columns = line.split('|').map((column) => column.trim());
441
+ const owned = columns[3];
442
+ if (!owned) continue;
443
+
444
+ for (const candidate of owned.split(',')) {
445
+ const normalized = normalizeOwnedPathHint(candidate);
446
+ if (normalized) paths.push(normalized);
447
+ }
448
+ }
449
+
450
+ return [...new Set(paths)];
451
+ }
452
+
453
+ function normalizeOwnedPathHint(value) {
454
+ const normalized = String(value || '')
455
+ .replace(/[`[\]]/g, '')
456
+ .trim()
457
+ .replace(/\\/g, '/');
458
+ if (!normalized) return null;
459
+ if (/^(disjoint write set|owned files \/ modules|what this slice does|planned)$/i.test(normalized)) {
460
+ return null;
461
+ }
462
+ if (!/[/.\\*]/.test(normalized)) return null;
463
+ return normalized;
464
+ }
465
+
466
+ function hasSubstantiveCodebaseMaps(planningDir) {
467
+ const dir = join(planningDir, 'codebase');
468
+ if (!existsSync(dir)) return false;
469
+ return readdirSync(dir).some((entry) => entry.toLowerCase().endsWith('.md'));
470
+ }
471
+
472
+ function hasQuickLaneArtifacts(planningDir) {
473
+ const dir = join(planningDir, 'quick');
474
+ if (!existsSync(dir)) return false;
475
+ return readdirSync(dir).length > 0;
476
+ }