gsdd-cli 0.24.0 → 0.25.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.
@@ -14,21 +14,19 @@ import { findUiProofBundleFiles, readUiProofBundleFile, validateUiProofBundle }
14
14
  import { resolveWorkspaceContext } from './workspace-root.mjs';
15
15
 
16
16
  /**
17
- * Factory function returning the health command.
18
- * ctx should provide: { frameworkVersion, workflows }
17
+ * Build the structured health report without printing or mutating workspace
18
+ * state. ctx should provide: { frameworkVersion, workflows }.
19
19
  */
20
- export function createCmdHealth(ctx) {
21
- return async function cmdHealth(...healthArgs) {
22
- const jsonMode = healthArgs.includes('--json');
20
+ export function buildHealthReport(ctx, healthArgs = []) {
23
21
  const { planningDir, workspaceRoot, invalid, error } = resolveWorkspaceContext(healthArgs);
24
22
  if (invalid) {
25
- if (jsonMode) {
26
- output({ status: 'broken', errors: [{ id: 'E1', severity: 'ERROR', message: error, fix: 'Pass --workspace-root with a real path or remove the flag.' }], warnings: [], info: [] });
27
- } else {
28
- console.log(error);
29
- }
30
- process.exitCode = 1;
31
- return;
23
+ return {
24
+ status: 'broken',
25
+ errors: [{ id: 'E1', severity: 'ERROR', message: error, fix: 'Pass --workspace-root with a real path or remove the flag.' }],
26
+ warnings: [],
27
+ info: [],
28
+ humanMessage: error,
29
+ };
32
30
  }
33
31
  const cwd = workspaceRoot;
34
32
  const frameworkSourceMode = isFrameworkSourceRepo(cwd);
@@ -36,13 +34,13 @@ export function createCmdHealth(ctx) {
36
34
 
37
35
  // Pre-init guard
38
36
  if (!existsSync(join(planningDir, 'config.json'))) {
39
- if (jsonMode) {
40
- output({ status: 'broken', errors: [{ id: 'E1', severity: 'ERROR', message: '.planning/config.json missing', fix: 'Run `npx -y gsdd-cli init`' }], warnings: [], info: [] });
41
- } else {
42
- console.log('Not initialized. Run `npx -y gsdd-cli init`. If `gsdd` is installed globally, `gsdd init` is also fine.');
43
- }
44
- process.exitCode = 1;
45
- return;
37
+ return {
38
+ status: 'broken',
39
+ errors: [{ id: 'E1', severity: 'ERROR', message: '.planning/config.json missing', fix: 'Run `npx -y gsdd-cli init`' }],
40
+ warnings: [],
41
+ info: [],
42
+ humanMessage: 'Not initialized. Run `npx -y gsdd-cli init`. If `gsdd` is installed globally, `gsdd init` is also fine.',
43
+ };
46
44
  }
47
45
 
48
46
  const errors = [];
@@ -272,22 +270,41 @@ export function createCmdHealth(ctx) {
272
270
  const hasWarnings = warnings.length > 0;
273
271
  const status = hasErrors ? 'broken' : hasWarnings ? 'degraded' : 'healthy';
274
272
 
275
- if (hasErrors) process.exitCode = 1;
273
+ return { status, errors, warnings, info };
274
+ }
275
+ /**
276
+ * Factory function returning the health command.
277
+ * ctx should provide: { frameworkVersion, workflows }
278
+ */
279
+ export function createCmdHealth(ctx) {
280
+ return async function cmdHealth(...healthArgs) {
281
+ const jsonMode = healthArgs.includes('--json');
282
+ const report = buildHealthReport(ctx, healthArgs);
283
+ const printableReport = {
284
+ status: report.status,
285
+ errors: report.errors,
286
+ warnings: report.warnings,
287
+ info: report.info,
288
+ };
289
+
290
+ if (report.status === 'broken') process.exitCode = 1;
276
291
 
277
292
  if (jsonMode) {
278
- output({ status, errors, warnings, info });
293
+ output(printableReport);
294
+ } else if (report.humanMessage) {
295
+ console.log(report.humanMessage);
279
296
  } else {
280
- console.log(`\ngsdd health workspace integrity check\n`);
281
- if (errors.length > 0) {
282
- for (const e of errors) console.log(` ERROR: [${e.id}] ${e.message}\n Fix: ${e.fix}`);
297
+ console.log(`\ngsdd health - workspace integrity check\n`);
298
+ if (report.errors.length > 0) {
299
+ for (const e of report.errors) console.log(` ERROR: [${e.id}] ${e.message}\n Fix: ${e.fix}`);
283
300
  }
284
- if (warnings.length > 0) {
285
- for (const w of warnings) console.log(` WARN: [${w.id}] ${w.message}\n Fix: ${w.fix}`);
301
+ if (report.warnings.length > 0) {
302
+ for (const w of report.warnings) console.log(` WARN: [${w.id}] ${w.message}\n Fix: ${w.fix}`);
286
303
  }
287
- if (info.length > 0) {
288
- for (const i of info) console.log(` INFO: [${i.id}] ${i.message}${i.fix ? `\n Fix: ${i.fix}` : ''}`);
304
+ if (report.info.length > 0) {
305
+ for (const i of report.info) console.log(` INFO: [${i.id}] ${i.message}${i.fix ? `\n Fix: ${i.fix}` : ''}`);
289
306
  }
290
- console.log(`\n Verdict: ${status.toUpperCase()}\n`);
307
+ console.log(`\n Verdict: ${report.status.toUpperCase()}\n`);
291
308
  }
292
309
  };
293
310
  }
@@ -194,6 +194,10 @@ Commands:
194
194
  Compare planned UI proof slots against observed bundles
195
195
  control-map [--json] [--with-ignored] [--annotations <path>]
196
196
  Report computed repo/worktree/planning state and local annotations
197
+ control-map annotate <set|clear>
198
+ Maintain optional local intent annotations under .planning/.local/
199
+ closeout-report [--json] [--phase <N>]
200
+ Replay read-only closeout status from control-map, health, preflight, verify, and UI-proof signals
197
201
  help Show this summary
198
202
 
199
203
  Platforms (for --tools):
@@ -237,6 +241,8 @@ Examples:
237
241
  npx -y gsdd-cli update
238
242
  npx -y gsdd-cli find-phase
239
243
  npx -y gsdd-cli verify 1
244
+ npx -y gsdd-cli control-map annotate set --id canonical --write-set src/app.ts
245
+ npx -y gsdd-cli control-map annotate clear --id canonical
240
246
  npx -y gsdd-cli scaffold phase 4 Payments
241
247
 
242
248
  Workflows (run via skills/adapters generated by init, not direct CLI):
@@ -265,7 +271,8 @@ Advanced/internal helpers (kept available, but not the primary first-run user st
265
271
  session-fingerprint Rebaseline the local planning-state fingerprint after review
266
272
  phase-status Update ROADMAP.md phase status through the local helper surface
267
273
  ui-proof Validate UI proof metadata and compare planned slots to observed bundles
268
- control-map Report computed repo/worktree/planning state and local annotations
274
+ control-map Report computed repo/worktree/planning state; annotate only records local intent
275
+ closeout-report Read-only post-merge closure replay; reports blockers, warnings, and next safe action
269
276
  file-op Deterministic workspace-confined file copy/delete/text mutation
270
277
  `;
271
278
  }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'fs';
2
- import { join } from 'path';
2
+ import { join, resolve } from 'path';
3
3
  import { output } from './cli-utils.mjs';
4
+ import { buildControlMap } from './control-map.mjs';
4
5
  import {
5
6
  DELIVERY_POSTURES,
6
7
  EVIDENCE_KINDS,
@@ -74,12 +75,14 @@ const RELEASE_CONTRADICTION_CHECKS = Object.freeze([
74
75
  ]);
75
76
 
76
77
  const RELEASE_CONTRADICTION_STATUSES = Object.freeze(['passed', 'failed', 'not_applicable']);
78
+ const PREFLIGHT_CONTROL_MAP_SKIP_CODES = Object.freeze(['planning_state_drift']);
77
79
 
78
80
  export function evaluateLifecyclePreflight({
79
81
  planningDir,
80
82
  surface,
81
83
  phaseNumber = null,
82
84
  expectsMutation = 'none',
85
+ controlMapReport = null,
83
86
  } = {}) {
84
87
  if (!planningDir) {
85
88
  throw new Error('planningDir is required');
@@ -184,6 +187,17 @@ export function evaluateLifecyclePreflight({
184
187
  }
185
188
  }
186
189
 
190
+ const controlMap = buildPreflightControlMap({
191
+ planningDir,
192
+ policy,
193
+ existingBlockerCodes: new Set(blockers.map((entry) => entry.code)),
194
+ controlMapReport,
195
+ });
196
+ for (const notice of controlMap.notices) {
197
+ if (notice.severity === 'block') blockers.push(notice);
198
+ else warnings.push(notice);
199
+ }
200
+
187
201
  if (lifecycle.phaseStatusAlignment.mismatches.length > 0) {
188
202
  warnings.push({
189
203
  code: 'roadmap_phase_status_mismatch',
@@ -206,6 +220,7 @@ export function evaluateLifecyclePreflight({
206
220
  blockers,
207
221
  warnings,
208
222
  planningState,
223
+ controlMap: controlMap.summary,
209
224
  lifecycle: {
210
225
  currentMilestone: lifecycle.currentMilestone,
211
226
  currentPhase: lifecycle.currentPhase ? lifecycle.currentPhase.number : null,
@@ -215,6 +230,48 @@ export function evaluateLifecyclePreflight({
215
230
  };
216
231
  }
217
232
 
233
+ function buildPreflightControlMap({ planningDir, policy, existingBlockerCodes, controlMapReport = null }) {
234
+ const empty = {
235
+ summary: null,
236
+ notices: [],
237
+ };
238
+ if (policy.classification !== 'owned_write' || !existsSync(planningDir)) return empty;
239
+
240
+ const map = controlMapReport || buildControlMap({
241
+ workspaceRoot: resolve(planningDir, '..'),
242
+ planningDir,
243
+ });
244
+ const risks = (map.risks || []).filter((risk) => (
245
+ !PREFLIGHT_CONTROL_MAP_SKIP_CODES.includes(risk.code)
246
+ && !(existingBlockerCodes.has(risk.code) && risk.severity !== 'block')
247
+ ));
248
+ const notices = risks.map((risk) => ({
249
+ ...controlMapNotice(risk),
250
+ severity: risk.severity || 'info',
251
+ }));
252
+
253
+ return {
254
+ summary: {
255
+ riskCount: map.risks.length,
256
+ noticeCount: notices.length,
257
+ blockerCount: notices.filter((notice) => notice.severity === 'block').length,
258
+ warningCount: notices.filter((notice) => notice.severity !== 'block').length,
259
+ interventions: map.interventions || [],
260
+ },
261
+ notices,
262
+ };
263
+ }
264
+
265
+ function controlMapNotice(risk) {
266
+ return {
267
+ code: risk.code,
268
+ source: 'control-map',
269
+ message: risk.message,
270
+ artifacts: ['gsdd control-map --json'],
271
+ risk,
272
+ };
273
+ }
274
+
218
275
  function buildPhaseBlockers({ lifecycle, phaseToken, surface }) {
219
276
  const blockers = [];
220
277
  const phaseEntry = lifecycle.phases.find((phase) => phase.number === phaseToken);
package/bin/lib/phase.mjs CHANGED
@@ -210,42 +210,118 @@ function normalizeUiProofIssue(issue) {
210
210
  };
211
211
  }
212
212
 
213
- function planDeclaresUiProofSlots(planContent) {
214
- const match = String(planContent || '').match(/(^|\n)ui_proof_slots:[ \t]*([^\n]*)/);
215
- if (!match) return false;
216
- const inlineValue = match[2].replace(/\s+#.*$/, '').trim();
217
- if (inlineValue) return !['[]', 'null', '~'].includes(inlineValue);
218
- const after = String(planContent || '').slice(match.index + match[0].length).split(/\r?\n/);
219
- for (const line of after) {
220
- const trimmed = line.trim();
221
- if (!trimmed || trimmed.startsWith('#')) continue;
222
- if (trimmed === '---' || trimmed === '...') break;
223
- if (/^\s+-\s+/.test(line)) return true;
224
- if (/^\S[^:\n]*:\s*/.test(line) || /^\S/.test(line)) break;
213
+ function stripInlineComment(value) {
214
+ return String(value || '').replace(/\s+#.*$/, '').trim();
215
+ }
216
+
217
+ function stripOuterScalarQuotes(value) {
218
+ return String(value)
219
+ .trim()
220
+ .replace(/^(['"])([\s\S]*)\1$/g, '$2')
221
+ .trim();
222
+ }
223
+
224
+ function normalizeNullableFrontmatterValue(value) {
225
+ const stripped = stripOuterScalarQuotes(value);
226
+ if (!stripped) return '';
227
+ if (stripped === '~') return '';
228
+ if (/^null$/i.test(stripped)) return '';
229
+ return stripped;
230
+ }
231
+
232
+ function extractUiProofSlotIds(value) {
233
+ const ids = [];
234
+ const slotPattern = /['"]?slot_id['"]?\s*:\s*['"]?([^,'"\]\s}]+)['"]?/g;
235
+ for (const match of String(value || '').matchAll(slotPattern)) {
236
+ ids.push(match[1].replace(/^['"]|['"]$/g, ''));
225
237
  }
226
- return false;
238
+ return ids;
227
239
  }
228
240
 
229
- function extractDeclaredUiProofSlotIds(planContent) {
230
- const match = String(planContent || '').match(/(^|\n)ui_proof_slots:[ \t]*([^\n]*)/);
231
- if (!match) return [];
232
- const after = String(planContent || '').slice(match.index + match[0].length).split(/\r?\n/);
233
- const slotIds = [];
234
- for (const line of after) {
241
+ function readPlanFrontmatter(planContent) {
242
+ const content = String(planContent || '');
243
+ if (!content.startsWith('---')) return '';
244
+ const lines = content.split(/\r?\n/);
245
+ const frontmatter = [];
246
+ for (let index = 1; index < lines.length; index += 1) {
247
+ if (lines[index].trim() === '---') return frontmatter.join('\n');
248
+ frontmatter.push(lines[index]);
249
+ }
250
+ return '';
251
+ }
252
+
253
+ function frontmatterKeyBlock(frontmatter, key) {
254
+ const lines = String(frontmatter || '').split(/\r?\n/);
255
+ const keyPattern = new RegExp(`^${key}:[ \\t]*(.*)$`);
256
+ for (let index = 0; index < lines.length; index += 1) {
257
+ const match = lines[index].match(keyPattern);
258
+ if (!match) continue;
259
+ const block = [];
260
+ for (let next = index + 1; next < lines.length; next += 1) {
261
+ const line = lines[next];
262
+ if (/^\S[^:\n]*:\s*/.test(line)) break;
263
+ block.push(line);
264
+ }
265
+ return { inline: stripInlineComment(match[1]), block };
266
+ }
267
+ return null;
268
+ }
269
+
270
+ function frontmatterScalar(frontmatter, key) {
271
+ const entry = frontmatterKeyBlock(frontmatter, key);
272
+ if (!entry) return null;
273
+ if (entry.inline && !['|', '>'].includes(entry.inline)) return entry.inline;
274
+ return entry.block
275
+ .map((line) => line.trim())
276
+ .filter(Boolean)
277
+ .join(' ')
278
+ .trim();
279
+ }
280
+
281
+ function readPlanUiProofContract(planContent) {
282
+ const frontmatter = readPlanFrontmatter(planContent);
283
+ const slotsEntry = frontmatterKeyBlock(frontmatter, 'ui_proof_slots');
284
+ const rationale = normalizeNullableFrontmatterValue(frontmatterScalar(frontmatter, 'no_ui_proof_rationale') || '');
285
+ const result = {
286
+ hasUiProofKey: Boolean(slotsEntry),
287
+ declaresSlots: false,
288
+ explicitEmptySlots: false,
289
+ noUiProofRationale: rationale,
290
+ hasNoUiProofRationale: Boolean(rationale.trim()),
291
+ slotIds: [],
292
+ };
293
+
294
+ if (!slotsEntry) return result;
295
+
296
+ if (slotsEntry.inline) {
297
+ if (['[]', 'null', '~'].includes(slotsEntry.inline)) {
298
+ result.explicitEmptySlots = true;
299
+ return result;
300
+ }
301
+ result.declaresSlots = true;
302
+ result.slotIds.push(...extractUiProofSlotIds(slotsEntry.inline));
303
+ return result;
304
+ }
305
+
306
+ let sawMeaningfulLine = false;
307
+ for (const line of slotsEntry.block) {
235
308
  const trimmed = line.trim();
236
309
  if (!trimmed || trimmed.startsWith('#')) continue;
237
- if (trimmed === '---' || trimmed === '...') break;
238
- if (/^\S[^:\n]*:\s*/.test(line) || /^\S/.test(line)) break;
239
- const slotMatch = trimmed.match(/(?:^-\s*)?slot_id:\s*([^#\s]+)/);
240
- if (slotMatch) slotIds.push(slotMatch[1].replace(/^['"]|['"]$/g, ''));
310
+ sawMeaningfulLine = true;
311
+ if (/^\s+-\s+/.test(line)) result.declaresSlots = true;
312
+ result.slotIds.push(...extractUiProofSlotIds(trimmed));
241
313
  }
242
- return slotIds;
314
+
315
+ result.explicitEmptySlots = !result.declaresSlots && !sawMeaningfulLine;
316
+ return result;
243
317
  }
244
318
 
245
319
  function findUiProofSlotPlansAndFiles(planningDir, planDisplayPaths) {
246
320
  const candidates = new Set();
247
321
  const declaredPlans = [];
248
322
  const declaredSlotIds = [];
323
+ const noUiPlans = [];
324
+ const contractErrors = [];
249
325
  const names = new Set([
250
326
  'ui-proof-slots.json',
251
327
  'ui-proof-slots.md',
@@ -259,21 +335,45 @@ function findUiProofSlotPlansAndFiles(planningDir, planDisplayPaths) {
259
335
  const fullPlanPath = join(planningDir, 'phases', planDisplayPath);
260
336
  if (!existsSync(fullPlanPath)) continue;
261
337
  const planContent = readFileSync(fullPlanPath, 'utf-8');
262
- if (!planDeclaresUiProofSlots(planContent)) continue;
263
338
  const relPlanPath = relative(planningDir, fullPlanPath).replace(/\\/g, '/');
339
+ const contract = readPlanUiProofContract(planContent);
340
+ const planDir = dirname(fullPlanPath);
341
+ const sidecars = [];
342
+ if (existsSync(planDir)) {
343
+ for (const entry of readdirSync(planDir, { withFileTypes: true })) {
344
+ if (entry.isFile() && names.has(entry.name)) {
345
+ sidecars.push(join(planDir, entry.name));
346
+ }
347
+ }
348
+ }
349
+
350
+ if (contract.hasUiProofKey && !contract.declaresSlots && !contract.hasNoUiProofRationale) {
351
+ contractErrors.push(normalizeUiProofIssue({
352
+ code: 'missing_no_ui_proof_rationale',
353
+ path: `${relPlanPath}.no_ui_proof_rationale`,
354
+ message: 'Plan declares empty ui_proof_slots but does not provide a no_ui_proof_rationale.',
355
+ fix: 'Add a nonblank no_ui_proof_rationale for non-UI work, or declare required UI proof slots and add a planned slots artifact.',
356
+ }));
357
+ }
358
+
359
+ if (contract.hasNoUiProofRationale && !contract.declaresSlots) {
360
+ noUiPlans.push({ plan: relPlanPath, sidecars });
361
+ continue;
362
+ }
363
+
364
+ if (!contract.declaresSlots) continue;
365
+
264
366
  declaredPlans.push(relPlanPath);
265
- for (const slotId of extractDeclaredUiProofSlotIds(planContent)) {
367
+ for (const slotId of contract.slotIds) {
266
368
  declaredSlotIds.push({ plan: relPlanPath, slot_id: slotId });
267
369
  }
268
- const planDir = dirname(fullPlanPath);
269
- if (!existsSync(planDir)) continue;
270
370
  for (const entry of readdirSync(planDir, { withFileTypes: true })) {
271
371
  if (entry.isFile() && names.has(entry.name)) {
272
372
  candidates.add(join(planDir, entry.name));
273
373
  }
274
374
  }
275
375
  }
276
- return { declaredPlans, declaredSlotIds, files: [...candidates].sort() };
376
+ return { declaredPlans, declaredSlotIds, noUiPlans, contractErrors, files: [...candidates].sort() };
277
377
  }
278
378
 
279
379
  function comparePhaseUiProof({ planningDir, workspaceRoot, planDisplayPaths }) {
@@ -285,9 +385,23 @@ function comparePhaseUiProof({ planningDir, workspaceRoot, planDisplayPaths }) {
285
385
 
286
386
  const plannedSlots = [];
287
387
  const errors = [];
388
+ const warnings = [];
288
389
  const planned = [];
289
390
  const observed = [];
290
391
 
392
+ errors.push(...plannedDiscovery.contractErrors);
393
+ for (const noUiPlan of plannedDiscovery.noUiPlans) {
394
+ for (const filePath of noUiPlan.sidecars) {
395
+ warnings.push({
396
+ code: 'stale_ui_proof_sidecar_ignored',
397
+ severity: 'warn',
398
+ path: relative(workspaceRoot, filePath).replace(/\\/g, '/'),
399
+ message: `Plan ${noUiPlan.plan} records no_ui_proof_rationale, so the UI proof sidecar is ignored for closure.`,
400
+ fix_hint: 'Remove or classify the stale sidecar if it no longer belongs to this non-UI phase.',
401
+ });
402
+ }
403
+ }
404
+
291
405
  for (const filePath of plannedFiles) {
292
406
  const rel = relative(workspaceRoot, filePath).replace(/\\/g, '/');
293
407
  const parsed = parseUiProofSlotsContent(readFileSync(filePath, 'utf-8'), rel);
@@ -343,6 +457,7 @@ function comparePhaseUiProof({ planningDir, workspaceRoot, planDisplayPaths }) {
343
457
  status: 'missing',
344
458
  comparison: { status: 'missing', slots: [], errors: [missingError] },
345
459
  errors: [missingError],
460
+ warnings,
346
461
  };
347
462
  }
348
463
 
@@ -350,9 +465,10 @@ function comparePhaseUiProof({ planningDir, workspaceRoot, planDisplayPaths }) {
350
465
  return {
351
466
  planned,
352
467
  observed,
353
- status: 'not_applicable',
468
+ status: errors.length > 0 ? 'partial' : 'not_applicable',
354
469
  comparison: null,
355
470
  errors,
471
+ warnings,
356
472
  };
357
473
  }
358
474
 
@@ -366,6 +482,7 @@ function comparePhaseUiProof({ planningDir, workspaceRoot, planDisplayPaths }) {
366
482
  status: comparison.status,
367
483
  comparison,
368
484
  errors: comparison.errors || errors,
485
+ warnings,
369
486
  };
370
487
  }
371
488
 
@@ -534,26 +651,41 @@ export function cmdFindPhase(...args) {
534
651
  });
535
652
  }
536
653
 
537
- export function cmdVerify(...args) {
654
+ export function buildPhaseVerificationReport(...args) {
538
655
  const { args: normalizedArgs, workspaceRoot, planningDir, invalid, error } = resolveWorkspaceContext(args);
539
656
  if (invalid) {
540
- console.error(error);
541
- process.exitCode = 1;
542
- return;
657
+ return { ok: false, error, exitCode: 1 };
543
658
  }
544
659
  const phaseNum = normalizedArgs[0];
545
660
  if (!phaseNum) {
546
- console.error('Usage: gsdd verify <phase-number>');
547
- process.exitCode = 1; return;
661
+ return { ok: false, error: 'Usage: gsdd verify <phase-number>', exitCode: 1 };
548
662
  }
549
663
 
550
664
  if (!existsSync(planningDir)) {
551
- console.error('No .planning/ directory found.');
552
- process.exitCode = 1; return;
665
+ return { ok: false, error: 'No .planning/ directory found.', exitCode: 1 };
553
666
  }
554
667
  const phasesDir = join(planningDir, 'phases');
555
668
  const matchingPlans = findFiles(phasesDir, `${padPhase(phaseNum)}-PLAN`);
556
669
  const matchingSummaries = findFiles(phasesDir, `${padPhase(phaseNum)}-SUMMARY`);
670
+ const prerequisiteBlockers = [];
671
+ if (matchingPlans.length === 0) {
672
+ prerequisiteBlockers.push({
673
+ code: 'missing_phase_plan',
674
+ severity: 'blocker',
675
+ path: `.planning/phases/${padPhase(phaseNum)}-*/${padPhase(phaseNum)}-PLAN.md`,
676
+ message: `No PLAN.md artifact was found for phase ${normalizePhaseToken(phaseNum)}.`,
677
+ fix_hint: `Run /gsdd-plan ${normalizePhaseToken(phaseNum)} before verifying this phase.`,
678
+ });
679
+ }
680
+ if (matchingPlans.length > 0 && matchingSummaries.length === 0) {
681
+ prerequisiteBlockers.push({
682
+ code: 'missing_phase_summary',
683
+ severity: 'blocker',
684
+ path: `.planning/phases/${padPhase(phaseNum)}-*/${padPhase(phaseNum)}-SUMMARY.md`,
685
+ message: `No SUMMARY.md artifact was found for phase ${normalizePhaseToken(phaseNum)}.`,
686
+ fix_hint: `Run /gsdd-execute ${normalizePhaseToken(phaseNum)} before verifying this phase.`,
687
+ });
688
+ }
557
689
  const artifacts = matchingPlans.flatMap((planPath) => {
558
690
  const fullPath = join(phasesDir, planPath);
559
691
  return existsSync(fullPath)
@@ -576,10 +708,11 @@ export function cmdVerify(...args) {
576
708
  required_block: uiProof.status !== 'not_applicable' && !uiProofSatisfied ? 'ui-proof-failed' : null,
577
709
  };
578
710
  const blockedOn = [
711
+ ...(prerequisiteBlockers.length > 0 ? ['prerequisites'] : []),
579
712
  ...(artifactStatus.satisfied ? [] : ['artifacts']),
580
713
  ...(uiProofGate.blocks_verification ? ['ui_proof'] : []),
581
714
  ];
582
- const closureVerified = legacyVerified && artifactStatus.satisfied && uiProofSatisfied;
715
+ const closureVerified = legacyVerified && prerequisiteBlockers.length === 0 && artifactStatus.satisfied && uiProofSatisfied;
583
716
 
584
717
  const result = {
585
718
  phase: normalizePhaseToken(phaseNum),
@@ -593,12 +726,26 @@ export function cmdVerify(...args) {
593
726
  verified: closureVerified,
594
727
  legacy_verified: legacyVerified,
595
728
  phase_artifacts_present: legacyVerified,
729
+ prerequisite_status: {
730
+ satisfied: prerequisiteBlockers.length === 0,
731
+ blockers: prerequisiteBlockers,
732
+ },
596
733
  ui_proof: uiProofGate,
597
734
  blocked_on: blockedOn,
598
735
  blocks_verification: blockedOn.length > 0,
599
736
  };
600
- output(result);
601
- if (!closureVerified && legacyVerified) process.exitCode = 1;
737
+ return { ok: true, result, exitCode: closureVerified ? 0 : 1 };
738
+ }
739
+
740
+ export function cmdVerify(...args) {
741
+ const report = buildPhaseVerificationReport(...args);
742
+ if (!report.ok) {
743
+ console.error(report.error);
744
+ process.exitCode = report.exitCode;
745
+ return;
746
+ }
747
+ output(report.result);
748
+ if (report.exitCode !== 0) process.exitCode = report.exitCode;
602
749
  }
603
750
 
604
751
  export function cmdScaffold(...args) {
@@ -7,6 +7,7 @@ const __dirname = dirname(__filename);
7
7
  const DISTILLED_DIR = join(__dirname, '..', '..', 'distilled');
8
8
  const HELPER_LIB_FILES = Object.freeze([
9
9
  'cli-utils.mjs',
10
+ 'closeout-report.mjs',
10
11
  'control-map.mjs',
11
12
  'evidence-contract.mjs',
12
13
  'file-ops.mjs',
@@ -47,19 +48,28 @@ function renderPlanningCliLauncher() {
47
48
 
48
49
  import { cmdFileOp } from './lib/file-ops.mjs';
49
50
  import { cmdLifecyclePreflight } from './lib/lifecycle-preflight.mjs';
50
- import { cmdPhaseStatus } from './lib/phase.mjs';
51
+ import { cmdPhaseStatus, cmdVerify } from './lib/phase.mjs';
51
52
  import { cmdSessionFingerprint } from './lib/session-fingerprint.mjs';
52
53
  import { cmdUiProof } from './lib/ui-proof.mjs';
53
54
  import { cmdControlMap } from './lib/control-map.mjs';
55
+ import { createCmdCloseoutReport } from './lib/closeout-report.mjs';
54
56
  import { bootstrapHelperWorkspace, consumeWorkspaceRootArg, resolveWorkspaceContext } from './lib/workspace-root.mjs';
55
57
 
58
+ const HELPER_CONTEXT = {
59
+ workflows: [],
60
+ frameworkVersion: 'generated-helper',
61
+ };
62
+ const cmdCloseoutReport = createCmdCloseoutReport(HELPER_CONTEXT);
63
+
56
64
  const COMMANDS = {
57
65
  'file-op': cmdFileOp,
58
66
  'lifecycle-preflight': cmdLifecyclePreflight,
59
67
  'phase-status': cmdPhaseStatus,
68
+ verify: cmdVerify,
60
69
  'session-fingerprint': cmdSessionFingerprint,
61
70
  'ui-proof': cmdUiProof,
62
71
  'control-map': cmdControlMap,
72
+ 'closeout-report': cmdCloseoutReport,
63
73
  };
64
74
 
65
75
  function printHelp() {
@@ -72,6 +82,8 @@ function printHelp() {
72
82
  ' Example: node .planning/bin/gsdd.mjs file-op delete .planning/.continue-here.bak --missing ok',
73
83
  ' phase-status <N> <status> Update ROADMAP.md phase status ([ ] / [-] / [x])',
74
84
  ' Example: node .planning/bin/gsdd.mjs phase-status 1 done',
85
+ ' verify <N> Run direct phase artifact and UI-proof gate checks',
86
+ ' Example: node .planning/bin/gsdd.mjs verify 1',
75
87
  ' lifecycle-preflight <surface> [phase]',
76
88
  ' Inspect lifecycle gate results for a workflow surface',
77
89
  ' Example: node .planning/bin/gsdd.mjs lifecycle-preflight verify 1 --expects-mutation phase-status',
@@ -83,6 +95,8 @@ function printHelp() {
83
95
  ' Compare planned UI proof slots against observed bundles',
84
96
  ' control-map [--json] [--with-ignored] [--annotations <path>]',
85
97
  ' Report computed repo/worktree/planning state and local annotations',
98
+ ' closeout-report [--json] [--phase <N>]',
99
+ ' Replay read-only closeout status from control-map, health, preflight, verify, and UI-proof signals',
86
100
  '',
87
101
  'Advanced option:',
88
102
  ' --workspace-root <path> Override workspace root discovery before or after the subcommand',