gsdd-cli 0.16.1 → 0.18.0

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.
Files changed (47) hide show
  1. package/README.md +109 -60
  2. package/agents/README.md +1 -1
  3. package/bin/adapters/claude.mjs +8 -1
  4. package/bin/adapters/codex.mjs +5 -1
  5. package/bin/adapters/opencode.mjs +7 -1
  6. package/bin/gsdd.mjs +6 -4
  7. package/bin/lib/evidence-contract.mjs +112 -0
  8. package/bin/lib/health-truth.mjs +29 -31
  9. package/bin/lib/health.mjs +32 -80
  10. package/bin/lib/init-runtime.mjs +44 -24
  11. package/bin/lib/init.mjs +3 -7
  12. package/bin/lib/lifecycle-preflight.mjs +333 -0
  13. package/bin/lib/lifecycle-state.mjs +293 -0
  14. package/bin/lib/phase.mjs +81 -26
  15. package/bin/lib/provenance.mjs +20 -1
  16. package/bin/lib/rendering.mjs +8 -0
  17. package/bin/lib/runtime-freshness.mjs +239 -0
  18. package/bin/lib/session-fingerprint.mjs +106 -0
  19. package/distilled/DESIGN.md +398 -12
  20. package/distilled/EVIDENCE-INDEX.md +105 -0
  21. package/distilled/README.md +44 -28
  22. package/distilled/SKILL.md +4 -4
  23. package/distilled/templates/agents.block.md +1 -1
  24. package/distilled/workflows/audit-milestone.md +50 -0
  25. package/distilled/workflows/complete-milestone.md +38 -2
  26. package/distilled/workflows/execute.md +13 -0
  27. package/distilled/workflows/map-codebase.md +14 -3
  28. package/distilled/workflows/new-milestone.md +13 -0
  29. package/distilled/workflows/new-project.md +3 -1
  30. package/distilled/workflows/plan.md +3 -1
  31. package/distilled/workflows/progress.md +68 -13
  32. package/distilled/workflows/quick.md +15 -8
  33. package/distilled/workflows/resume.md +14 -1
  34. package/distilled/workflows/verify.md +52 -17
  35. package/docs/BROWNFIELD-PROOF.md +95 -0
  36. package/docs/RUNTIME-SUPPORT.md +77 -0
  37. package/docs/USER-GUIDE.md +439 -0
  38. package/docs/VERIFICATION-DISCIPLINE.md +59 -0
  39. package/docs/claude/context-monitor.md +98 -0
  40. package/docs/proof/consumer-node-cli/README.md +37 -0
  41. package/docs/proof/consumer-node-cli/ROADMAP.md +14 -0
  42. package/docs/proof/consumer-node-cli/SPEC.md +17 -0
  43. package/docs/proof/consumer-node-cli/brief.md +9 -0
  44. package/docs/proof/consumer-node-cli/phases/01-foundation/01-01-PLAN.md +34 -0
  45. package/docs/proof/consumer-node-cli/phases/01-foundation/01-01-SUMMARY.md +10 -0
  46. package/docs/proof/consumer-node-cli/phases/01-foundation/01-VERIFICATION.md +30 -0
  47. package/package.json +34 -27
@@ -8,10 +8,12 @@ import { join } from 'path';
8
8
  import { readManifest, detectModifications } from './manifest.mjs';
9
9
  import { output } from './cli-utils.mjs';
10
10
  import { runTruthChecks, TRUTH_CHECK_IDS } from './health-truth.mjs';
11
+ import { evaluateLifecycleState } from './lifecycle-state.mjs';
12
+ import { evaluateRuntimeFreshness } from './runtime-freshness.mjs';
11
13
 
12
14
  /**
13
15
  * Factory function returning the health command.
14
- * ctx must provide: { frameworkVersion }
16
+ * ctx should provide: { frameworkVersion, workflows }
15
17
  */
16
18
  export function createCmdHealth(ctx) {
17
19
  return async function cmdHealth(...healthArgs) {
@@ -41,8 +43,10 @@ export function createCmdHealth(ctx) {
41
43
  // E1: config.json missing (already handled by pre-init guard, but keep for completeness)
42
44
  // E2: config.json missing required fields
43
45
  let config = null;
46
+ let configOk = false;
44
47
  try {
45
48
  config = JSON.parse(readFileSync(join(planningDir, 'config.json'), 'utf-8'));
49
+ configOk = true;
46
50
  const requiredFields = ['researchDepth', 'modelProfile', 'initVersion'];
47
51
  const missing = requiredFields.filter((f) => !(f in config));
48
52
  if (missing.length > 0) {
@@ -148,45 +152,28 @@ export function createCmdHealth(ctx) {
148
152
  const roadmapPath = join(planningDir, 'ROADMAP.md');
149
153
  const phasesDir = join(planningDir, 'phases');
150
154
  const roadmap = existsSync(roadmapPath) ? readFileSync(roadmapPath, 'utf-8') : null;
151
- const phaseArtifacts = existsSync(phasesDir) ? listPhaseArtifacts(phasesDir) : [];
155
+ const lifecycle = evaluateLifecycleState({ planningDir });
152
156
 
153
157
  if (roadmap && existsSync(phasesDir)) {
154
- const phaseLabels = [];
155
- let inDetails = false;
156
- for (const line of roadmap.split('\n')) {
157
- if (line.includes('<details>') && !line.includes('</details>')) {
158
- inDetails = true;
159
- continue;
160
- }
161
- if (line.includes('</details>')) {
162
- inDetails = false;
163
- continue;
164
- }
165
- if (inDetails) continue;
166
- const match = line.match(/^\s*[-*]\s*\[([x-])\]\s*\*\*Phase\s+(\d+[a-z]?)/i);
167
- if (match) phaseLabels.push(normalizePhaseLabel(match[2]));
168
- }
169
- for (const label of phaseLabels) {
170
- const hasFile = phaseArtifacts.some((artifact) => normalizePhaseLabel(artifact.phasePrefix) === label);
171
- if (!hasFile) {
172
- warnings.push({ id: 'W4', severity: 'WARN', message: `ROADMAP.md references active Phase ${label} but no files found in .planning/phases/`, fix: 'Create missing phase dirs or update ROADMAP' });
173
- }
158
+ for (const phase of lifecycle.phases.filter((entry) => entry.status !== 'not_started' && !entry.hasArtifacts)) {
159
+ warnings.push({
160
+ id: 'W4',
161
+ severity: 'WARN',
162
+ message: `ROADMAP.md references active Phase ${phase.number} but no files found in .planning/phases/`,
163
+ fix: 'Create missing phase dirs or update ROADMAP',
164
+ });
174
165
  }
175
166
  }
176
167
 
177
168
  // W5: Phase dir has PLAN but no SUMMARY (stale in-progress)
178
- if (phaseArtifacts.length > 0) {
179
- const plans = phaseArtifacts.filter((artifact) => artifact.name.includes('PLAN'));
180
- for (const plan of plans) {
181
- const prefix = plan.name.split('-PLAN')[0];
182
- const hasSummary = phaseArtifacts.some((artifact) =>
183
- artifact.dir === plan.dir &&
184
- artifact.name.startsWith(prefix) &&
185
- artifact.name.includes('SUMMARY')
186
- );
187
- if (!hasSummary) {
188
- warnings.push({ id: 'W5', severity: 'WARN', message: `${plan.displayPath} exists but no matching SUMMARY found (stale in-progress?)`, fix: 'Resume or complete the phase' });
189
- }
169
+ if (lifecycle.incompletePlans.length > 0) {
170
+ for (const plan of lifecycle.incompletePlans) {
171
+ warnings.push({
172
+ id: 'W5',
173
+ severity: 'WARN',
174
+ message: `${plan.displayPath} exists but no matching SUMMARY found (stale in-progress?)`,
175
+ fix: 'Resume or complete the phase',
176
+ });
190
177
  }
191
178
  }
192
179
 
@@ -202,7 +189,11 @@ export function createCmdHealth(ctx) {
202
189
  warnings.push({ id: 'W6', severity: 'WARN', message: 'No adapter surfaces detected', fix: 'Run `gsdd init --tools <platform>`' });
203
190
  }
204
191
 
205
- warnings.push(...runTruthChecks(planningDir, cwd, healthCheckIds));
192
+ const runtimeFreshnessReport = configOk && Array.isArray(ctx.workflows)
193
+ ? evaluateRuntimeFreshness({ cwd, workflows: ctx.workflows })
194
+ : null;
195
+
196
+ warnings.push(...runTruthChecks(planningDir, cwd, healthCheckIds, { runtimeFreshnessReport }));
206
197
 
207
198
  // --- INFO checks ---
208
199
 
@@ -212,20 +203,12 @@ export function createCmdHealth(ctx) {
212
203
  }
213
204
 
214
205
  // I2: Phase completion count
215
- if (roadmap) {
216
- const lines = roadmap.split('\n');
217
- let total = 0;
218
- let done = 0;
219
- for (const line of lines) {
220
- const match = line.match(/^\s*[-*]\s*\[([x ]|-)\]\s*\*\*Phase\s+\d+/i);
221
- if (match) {
222
- total++;
223
- if (match[1] === 'x') done++;
224
- }
225
- }
226
- if (total > 0) {
227
- info.push({ id: 'I2', severity: 'INFO', message: `Phases: ${done}/${total} completed` });
228
- }
206
+ if (lifecycle.counts.total > 0) {
207
+ info.push({
208
+ id: 'I2',
209
+ severity: 'INFO',
210
+ message: `Phases: ${lifecycle.counts.completed}/${lifecycle.counts.total} completed`,
211
+ });
229
212
  }
230
213
 
231
214
  // I3: Which adapters are installed
@@ -268,34 +251,3 @@ function isFrameworkSourceRepo(cwd) {
268
251
  return existsSync(join(cwd, 'distilled', 'templates')) && existsSync(join(cwd, 'distilled', 'workflows'));
269
252
  }
270
253
 
271
- function normalizePhaseLabel(value) {
272
- if (!value) return null;
273
- return value.toLowerCase().replace(/^0+(\d)/, '$1');
274
- }
275
-
276
- function listPhaseArtifacts(phasesDir) {
277
- const artifacts = [];
278
- for (const entry of readdirSync(phasesDir, { withFileTypes: true })) {
279
- const entryPath = join(phasesDir, entry.name);
280
- if (entry.isFile()) {
281
- artifacts.push(createPhaseArtifact('', entry.name));
282
- continue;
283
- }
284
- if (!entry.isDirectory()) continue;
285
- for (const child of readdirSync(entryPath, { withFileTypes: true })) {
286
- if (child.isFile()) {
287
- artifacts.push(createPhaseArtifact(entry.name, child.name));
288
- }
289
- }
290
- }
291
- return artifacts;
292
- }
293
-
294
- function createPhaseArtifact(dir, name) {
295
- return {
296
- dir,
297
- name,
298
- displayPath: dir ? `${dir}/${name}` : name,
299
- phasePrefix: name.match(/^(\d+[a-z]?(?:\.\d+)?)-/i)?.[1] || dir.match(/^(\d+[a-z]?(?:\.\d+)?)-/i)?.[1] || null,
300
- };
301
- }
@@ -2,37 +2,37 @@ const RUNTIME_OPTIONS = [
2
2
  {
3
3
  id: 'claude',
4
4
  label: 'Claude Code',
5
- description: 'Directly validated native skills, commands, and agents',
5
+ description: 'Directly validated native skills, commands, and agents with local freshness checks',
6
6
  kind: 'native',
7
7
  },
8
8
  {
9
9
  id: 'opencode',
10
10
  label: 'OpenCode',
11
- description: 'Directly validated native slash commands and agents',
11
+ description: 'Directly validated native slash commands and agents with local freshness checks',
12
12
  kind: 'native',
13
13
  },
14
14
  {
15
15
  id: 'codex',
16
16
  label: 'Codex CLI',
17
- description: 'Directly validated portable skills plus native checker agents',
17
+ description: 'Directly validated portable skills plus native checker agents with local freshness checks',
18
18
  kind: 'native',
19
19
  },
20
20
  {
21
21
  id: 'cursor',
22
22
  label: 'Cursor',
23
- description: 'Qualified support via skills-native slash commands from .agents/skills/',
23
+ description: 'Qualified support via skills-native slash commands from .agents/skills/ with the same local freshness checks',
24
24
  kind: 'skills_native',
25
25
  },
26
26
  {
27
27
  id: 'copilot',
28
28
  label: 'GitHub Copilot',
29
- description: 'Qualified support via skills-native slash commands from .agents/skills/',
29
+ description: 'Qualified support via skills-native slash commands from .agents/skills/ with the same local freshness checks',
30
30
  kind: 'skills_native',
31
31
  },
32
32
  {
33
33
  id: 'gemini',
34
34
  label: 'Gemini CLI',
35
- description: 'Qualified support via skills-native slash commands from .agents/skills/',
35
+ description: 'Qualified support via skills-native slash commands from .agents/skills/ with the same local freshness checks',
36
36
  kind: 'skills_native',
37
37
  },
38
38
  ];
@@ -161,25 +161,31 @@ export function getPostInitRoutingLines(selectedRuntimes) {
161
161
 
162
162
  export function getHelpText() {
163
163
  return `
164
- gsdd - GSD Distilled CLI
165
- Spec-Driven Development for AI coding agents.
164
+ gsdd - Workspine CLI
165
+ Portable multi-runtime software delivery framework for AI coding agents.
166
166
 
167
167
  Usage: gsdd <command> [args]
168
168
 
169
- Commands:
170
- init [--tools <platform>] [--auto] [--brief <file>]
171
- Launch guided install wizard in TTYs, or use --tools for manual/headless setup
172
- --auto: non-interactive mode with smart defaults (requires --tools)
173
- --brief <file>: copy project brief to .planning/PROJECT_BRIEF.md
169
+ Commands:
170
+ init [--tools <platform>] [--auto] [--brief <file>]
171
+ Launch guided install wizard in TTYs, or use --tools for manual/headless setup
172
+ --auto: non-interactive mode with smart defaults (requires --tools)
173
+ --brief <file>: copy project brief to .planning/PROJECT_BRIEF.md
174
174
  update [--tools <platform>] [--templates] [--dry]
175
175
  Regenerate adapters from latest framework sources
176
176
  --templates: also refresh .planning/templates/ and roles
177
177
  --dry: preview changes without writing files
178
- health [--json] Check workspace integrity (healthy/degraded/broken)
179
- models [subcommand] Inspect or update model profile / runtime overrides
180
- find-phase [N] Show phase info as JSON (for agent consumption)
181
- verify <N> Run artifact checks for phase N
182
- scaffold phase <N> [name] Create a new phase plan file
178
+ health [--json] Check workspace integrity (healthy/degraded/broken)
179
+ models [subcommand] Inspect or update model profile / runtime overrides
180
+ find-phase [N] Show phase info as JSON (for agent consumption)
181
+ verify <N> Run artifact checks for phase N
182
+ scaffold phase <N> [name] Create a new phase plan file
183
+ file-op <copy|delete|regex-sub>
184
+ Run deterministic workspace-confined file copy/delete/text mutation
185
+ phase-status <N> <status> Update ROADMAP.md phase status ([ ] / [-] / [x])
186
+ lifecycle-preflight <surface> [phase]
187
+ Inspect deterministic lifecycle gate results for a workflow surface
188
+ help Show this summary
183
189
 
184
190
  Platforms (for --tools):
185
191
  claude Generate Claude Code skills (.claude/skills/gsdd-*), commands (.claude/commands/gsdd-*.md), and native agents (.claude/agents/gsdd-*.md)
@@ -193,12 +199,14 @@ Platforms (for --tools):
193
199
 
194
200
  Notes:
195
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/
196
203
  - running plain \`gsdd init\` in a terminal opens the guided runtime-selection wizard
197
204
  - the wizard lets you pick runtimes first, then separately decide whether repo-wide AGENTS.md governance is worth installing
205
+ - \`gsdd health\` compares any installed generated runtime surfaces against current render output and points back to \`gsdd update\` when they drift
198
206
  - directly validated launch surfaces in this repo are Claude Code, OpenCode, and Codex CLI
199
207
  - Cursor, Copilot, and Gemini are qualified support through the shared .agents/skills/ surface plus optional governance
200
208
  - --tools remains the advanced/manual path and preserves legacy runtime aliases for backward compatibility
201
- - --tools codex generates .codex/agents/gsdd-plan-checker.toml (portable skill is the entry surface)
209
+ - --tools codex generates .codex/agents/gsdd-plan-checker.toml (portable skill is the entry surface; $gsdd-plan is plan-only until explicit $gsdd-execute)
202
210
  - root AGENTS.md is only written on init when explicitly requested via --tools agents, --tools all, or the wizard governance opt-in
203
211
 
204
212
  Examples:
@@ -219,8 +227,20 @@ Examples:
219
227
  npx gsdd-cli verify 1
220
228
  npx gsdd-cli scaffold phase 4 Payments
221
229
 
222
- Workflows (run via skills/adapters generated by init, not direct CLI):
223
- map-codebase Map or refresh codebase (.agents/skills/gsdd-map-codebase/)
224
- audit-milestone Audit a completed milestone (.agents/skills/gsdd-audit-milestone/)
225
- `;
226
- }
230
+ Workflows (run via skills/adapters generated by init, not direct CLI):
231
+ gsdd-new-project Full initializer: questioning, brownfield audit, research, spec, roadmap
232
+ gsdd-map-codebase Map or refresh brownfield codebase context
233
+ gsdd-plan Research, plan, and fresh-context plan check for a phase
234
+ gsdd-execute Execute a phase plan and write phase summaries
235
+ gsdd-verify Verify a completed phase with 3-level checks
236
+ gsdd-verify-work Conversational UAT validation for user-facing behavior
237
+ gsdd-audit-milestone Cross-phase integration, requirements coverage, and E2E audit
238
+ gsdd-complete-milestone Archive a shipped milestone and collapse roadmap state
239
+ gsdd-new-milestone Start the next milestone with goals, requirements, and phases
240
+ gsdd-plan-milestone-gaps Turn milestone-audit gaps into closure phases
241
+ gsdd-quick Bounded brownfield lane for sub-hour work
242
+ gsdd-pause Save session context to checkpoint
243
+ gsdd-resume Restore context and route to the next action
244
+ gsdd-progress Read-only status and routing surface
245
+ `;
246
+ }
package/bin/lib/init.mjs CHANGED
@@ -4,13 +4,9 @@ import { createCmdInit, createCmdUpdate } from './init-flow.mjs';
4
4
  import { createInitPromptApi, promptChoiceList } from './init-prompts.mjs';
5
5
  import { buildRuntimeChoices, detectPlatforms, getHelpText, normalizeRequestedTools } from './init-runtime.mjs';
6
6
 
7
- function cmdHelp() {
8
- console.log(`${getHelpText().trimEnd()}
9
- file-op <copy|delete|regex-sub>
10
- Run deterministic workspace-confined file copy/delete/text mutation
11
- phase-status <N> <status> Update ROADMAP.md phase status ([ ] / [-] / [x])
12
- `);
13
- }
7
+ function cmdHelp() {
8
+ console.log(getHelpText().trimEnd());
9
+ }
14
10
 
15
11
  export {
16
12
  buildRuntimeChoices,
@@ -0,0 +1,333 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { output } from './cli-utils.mjs';
4
+ import { describeEvidenceSurface } from './evidence-contract.mjs';
5
+ import { evaluateLifecycleState, normalizePhaseToken } from './lifecycle-state.mjs';
6
+ import { checkDrift } from './session-fingerprint.mjs';
7
+
8
+ const SURFACE_POLICIES = {
9
+ progress: {
10
+ classification: 'read_only',
11
+ ownedWrites: [],
12
+ explicitLifecycleMutation: 'none',
13
+ },
14
+ execute: {
15
+ classification: 'owned_write',
16
+ ownedWrites: ['summary'],
17
+ explicitLifecycleMutation: 'phase-status',
18
+ phaseRequired: true,
19
+ },
20
+ verify: {
21
+ classification: 'owned_write',
22
+ ownedWrites: ['verification'],
23
+ explicitLifecycleMutation: 'phase-status',
24
+ phaseRequired: true,
25
+ },
26
+ 'audit-milestone': {
27
+ classification: 'owned_write',
28
+ ownedWrites: ['milestone-audit'],
29
+ explicitLifecycleMutation: 'none',
30
+ },
31
+ 'complete-milestone': {
32
+ classification: 'owned_write',
33
+ ownedWrites: ['milestone-archives', 'milestones-ledger', 'spec', 'roadmap'],
34
+ explicitLifecycleMutation: 'none',
35
+ },
36
+ 'new-milestone': {
37
+ classification: 'owned_write',
38
+ ownedWrites: ['spec', 'roadmap', 'phase-directories'],
39
+ explicitLifecycleMutation: 'none',
40
+ },
41
+ resume: {
42
+ classification: 'owned_write',
43
+ ownedWrites: ['checkpoint-cleanup'],
44
+ explicitLifecycleMutation: 'none',
45
+ },
46
+ };
47
+
48
+ export function evaluateLifecyclePreflight({
49
+ planningDir,
50
+ surface,
51
+ phaseNumber = null,
52
+ expectsMutation = 'none',
53
+ } = {}) {
54
+ if (!planningDir) {
55
+ throw new Error('planningDir is required');
56
+ }
57
+
58
+ const policy = SURFACE_POLICIES[surface];
59
+ if (!policy) {
60
+ throw new Error(`Unsupported lifecycle surface: ${surface}`);
61
+ }
62
+
63
+ const lifecycle = evaluateLifecycleState({ planningDir });
64
+ const normalizedPhase = phaseNumber ? normalizePhaseToken(phaseNumber) : null;
65
+ const checkpointPath = join(planningDir, '.continue-here.md');
66
+ const specPath = join(planningDir, 'SPEC.md');
67
+ const milestonesPath = join(planningDir, 'MILESTONES.md');
68
+ const blockers = [];
69
+
70
+ if (!existsSync(planningDir)) {
71
+ blockers.push(blocker('missing_planning_dir', '.planning/ does not exist yet.', ['.planning/']));
72
+ }
73
+
74
+ if (expectsMutation !== 'none' && expectsMutation !== policy.explicitLifecycleMutation) {
75
+ blockers.push(
76
+ blocker(
77
+ 'illegal_lifecycle_mutation',
78
+ `${surface} is classified as ${policy.classification} and cannot mutate lifecycle state via ${expectsMutation}.`,
79
+ []
80
+ )
81
+ );
82
+ }
83
+
84
+ if (policy.phaseRequired && !normalizedPhase) {
85
+ blockers.push(blocker('missing_phase_argument', `${surface} requires an explicit phase number.`, []));
86
+ }
87
+
88
+ if (normalizedPhase) {
89
+ blockers.push(...buildPhaseBlockers({ lifecycle, phaseToken: normalizedPhase, surface }));
90
+ }
91
+
92
+ if (surface === 'audit-milestone') {
93
+ blockers.push(...buildAuditBlockers(lifecycle));
94
+ }
95
+
96
+ if (surface === 'complete-milestone') {
97
+ blockers.push(...buildAuditBlockers(lifecycle, { allowArchivedBlocker: true }));
98
+ blockers.push(...buildCompletionBlockers(planningDir, lifecycle));
99
+ }
100
+
101
+ if (surface === 'new-milestone') {
102
+ if (!existsSync(specPath)) {
103
+ blockers.push(blocker('missing_spec', 'SPEC.md is required before starting a new milestone.', ['.planning/SPEC.md']));
104
+ }
105
+ if (!existsSync(milestonesPath)) {
106
+ blockers.push(blocker('missing_milestones', 'MILESTONES.md is required before starting a new milestone.', ['.planning/MILESTONES.md']));
107
+ }
108
+ if (lifecycle.currentMilestone.version && lifecycle.currentMilestone.archiveState !== 'archived') {
109
+ blockers.push(
110
+ blocker(
111
+ 'active_milestone_in_progress',
112
+ `Milestone ${lifecycle.currentMilestone.version} is still active. Archive or remove the active roadmap before starting the next milestone.`,
113
+ ['.planning/ROADMAP.md']
114
+ )
115
+ );
116
+ }
117
+ }
118
+
119
+ if (surface === 'resume' && !existsSync(checkpointPath)) {
120
+ blockers.push(blocker('missing_checkpoint', 'resume requires .planning/.continue-here.md to exist.', ['.planning/.continue-here.md']));
121
+ }
122
+
123
+ const warnings = [];
124
+
125
+ if (existsSync(planningDir)) {
126
+ const drift = checkDrift(planningDir);
127
+ if (drift.drifted) {
128
+ warnings.push({
129
+ code: 'planning_state_drift',
130
+ message: `Planning state has drifted since the last recorded session: ${drift.details.join('; ')}`,
131
+ artifacts: ['.planning/ROADMAP.md', '.planning/SPEC.md', '.planning/config.json'],
132
+ });
133
+ }
134
+ }
135
+
136
+ return {
137
+ surface,
138
+ phase: normalizedPhase,
139
+ classification: policy.classification,
140
+ ownedWrites: policy.ownedWrites,
141
+ explicitLifecycleMutation: policy.explicitLifecycleMutation,
142
+ closureEvidence: describeEvidenceSurface(surface),
143
+ mutationRequest: expectsMutation,
144
+ allowed: blockers.length === 0,
145
+ status: blockers.length === 0 ? 'allowed' : 'blocked',
146
+ reason: blockers[0]?.code ?? null,
147
+ blockers,
148
+ warnings,
149
+ lifecycle: {
150
+ currentMilestone: lifecycle.currentMilestone,
151
+ currentPhase: lifecycle.currentPhase ? lifecycle.currentPhase.number : null,
152
+ nextPhase: lifecycle.nextPhase ? lifecycle.nextPhase.number : null,
153
+ counts: lifecycle.counts,
154
+ },
155
+ };
156
+ }
157
+
158
+ function buildPhaseBlockers({ lifecycle, phaseToken, surface }) {
159
+ const blockers = [];
160
+ const phaseEntry = lifecycle.phases.find((phase) => phase.number === phaseToken);
161
+ if (!phaseEntry) {
162
+ blockers.push(
163
+ blocker(
164
+ 'missing_phase',
165
+ `Phase ${phaseToken} was not found in the active roadmap.`,
166
+ ['.planning/ROADMAP.md']
167
+ )
168
+ );
169
+ return blockers;
170
+ }
171
+
172
+ const planArtifacts = lifecycle.phaseArtifacts.filter((artifact) => artifact.phaseToken === phaseToken && artifact.kind === 'plan');
173
+ const summaryArtifacts = lifecycle.phaseArtifacts.filter((artifact) => artifact.phaseToken === phaseToken && artifact.kind === 'summary');
174
+ const pendingPlans = planArtifacts.filter(
175
+ (artifact) => !summaryArtifacts.some((candidate) => candidate.dir === artifact.dir && candidate.baseId === artifact.baseId)
176
+ );
177
+
178
+ if (surface === 'execute') {
179
+ if (planArtifacts.length === 0) {
180
+ blockers.push(
181
+ blocker(
182
+ 'missing_plan',
183
+ `Phase ${phaseToken} cannot execute because no PLAN artifact exists.`,
184
+ ['.planning/phases/']
185
+ )
186
+ );
187
+ } else if (pendingPlans.length === 0) {
188
+ blockers.push(
189
+ blocker(
190
+ 'no_pending_plan',
191
+ `Phase ${phaseToken} has no pending PLAN artifacts left to execute.`,
192
+ planArtifacts.map((artifact) => artifact.displayPath)
193
+ )
194
+ );
195
+ }
196
+ }
197
+
198
+ if (surface === 'verify') {
199
+ if (planArtifacts.length === 0) {
200
+ blockers.push(
201
+ blocker(
202
+ 'missing_plan',
203
+ `Phase ${phaseToken} cannot be verified because no PLAN artifact exists.`,
204
+ ['.planning/phases/']
205
+ )
206
+ );
207
+ }
208
+ if (summaryArtifacts.length === 0) {
209
+ blockers.push(
210
+ blocker(
211
+ 'missing_summary',
212
+ `Phase ${phaseToken} cannot be verified because no SUMMARY artifact exists yet.`,
213
+ ['.planning/phases/']
214
+ )
215
+ );
216
+ }
217
+ }
218
+
219
+ return blockers;
220
+ }
221
+
222
+ function buildAuditBlockers(lifecycle, { allowArchivedBlocker = false } = {}) {
223
+ const blockers = [];
224
+ if (!lifecycle.currentMilestone.version) {
225
+ blockers.push(blocker('missing_milestone', 'No active or retained milestone could be derived from ROADMAP.md.', ['.planning/ROADMAP.md']));
226
+ return blockers;
227
+ }
228
+
229
+ if (lifecycle.currentMilestone.archiveState === 'archived') {
230
+ blockers.push(
231
+ blocker(
232
+ allowArchivedBlocker ? 'milestone_already_archived' : 'milestone_already_archived',
233
+ `Milestone ${lifecycle.currentMilestone.version} is already archived-with-ROADMAP.md evidence.`,
234
+ ['.planning/ROADMAP.md', '.planning/MILESTONES.md']
235
+ )
236
+ );
237
+ }
238
+
239
+ if (lifecycle.counts.total === 0) {
240
+ blockers.push(blocker('missing_phases', 'No active milestone phases were found in ROADMAP.md.', ['.planning/ROADMAP.md']));
241
+ } else if (lifecycle.counts.completed !== lifecycle.counts.total) {
242
+ blockers.push(
243
+ blocker(
244
+ 'incomplete_phases',
245
+ `Milestone ${lifecycle.currentMilestone.version} still has incomplete phases (${lifecycle.counts.completed}/${lifecycle.counts.total} complete).`,
246
+ ['.planning/ROADMAP.md']
247
+ )
248
+ );
249
+ }
250
+
251
+ const phasesMissingVerification = lifecycle.phases
252
+ .filter((phase) => phase.status === 'done')
253
+ .filter((phase) => !phase.artifacts.some((artifact) => artifact.kind === 'verification'))
254
+ .map((phase) => phase.number);
255
+
256
+ if (phasesMissingVerification.length > 0) {
257
+ blockers.push(
258
+ blocker(
259
+ 'missing_verification',
260
+ `Completed phases are missing VERIFICATION artifacts (${phasesMissingVerification.join(', ')}).`,
261
+ ['.planning/phases/']
262
+ )
263
+ );
264
+ }
265
+
266
+ return blockers;
267
+ }
268
+
269
+ function buildCompletionBlockers(planningDir, lifecycle) {
270
+ const auditPath = join(planningDir, `${lifecycle.currentMilestone.version}-MILESTONE-AUDIT.md`);
271
+ if (!existsSync(auditPath)) {
272
+ return [
273
+ blocker(
274
+ 'missing_milestone_audit',
275
+ `Milestone ${lifecycle.currentMilestone.version} cannot be completed without a milestone audit artifact.`,
276
+ [auditPath]
277
+ ),
278
+ ];
279
+ }
280
+
281
+ const auditContent = readFileSync(auditPath, 'utf-8');
282
+ const statusMatch = auditContent.match(/^status:\s*(.+)$/m);
283
+ if (!statusMatch || statusMatch[1].trim() !== 'passed') {
284
+ return [
285
+ blocker(
286
+ 'audit_not_passed',
287
+ `Milestone ${lifecycle.currentMilestone.version} requires a passed audit before completion.`,
288
+ [auditPath]
289
+ ),
290
+ ];
291
+ }
292
+
293
+ return [];
294
+ }
295
+
296
+ function blocker(code, message, artifacts) {
297
+ return { code, message, artifacts };
298
+ }
299
+
300
+ export function cmdLifecyclePreflight(...args) {
301
+ const cwd = process.cwd();
302
+ const planningDir = join(cwd, '.planning');
303
+ const [surface, maybePhase, ...rest] = args;
304
+
305
+ if (!surface) {
306
+ console.error('Usage: gsdd lifecycle-preflight <surface> [phase] [--expects-mutation <none|phase-status>]');
307
+ process.exitCode = 1;
308
+ return;
309
+ }
310
+
311
+ let phaseNumber = maybePhase && !maybePhase.startsWith('--') ? maybePhase : null;
312
+ let expectsMutation = 'none';
313
+
314
+ const flagArgs = phaseNumber ? rest : [maybePhase, ...rest].filter(Boolean);
315
+ for (let index = 0; index < flagArgs.length; index += 1) {
316
+ const arg = flagArgs[index];
317
+ if (arg === '--expects-mutation') {
318
+ expectsMutation = flagArgs[index + 1] ?? 'none';
319
+ index += 1;
320
+ }
321
+ }
322
+
323
+ try {
324
+ const result = evaluateLifecyclePreflight({ planningDir, surface, phaseNumber, expectsMutation });
325
+ output(result);
326
+ if (!result.allowed) {
327
+ process.exitCode = 1;
328
+ }
329
+ } catch (error) {
330
+ console.error(error.message);
331
+ process.exitCode = 1;
332
+ }
333
+ }