gsdd-cli 0.24.0 → 0.26.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.
@@ -36,7 +36,20 @@ ${delegateContent.trim()}
36
36
  `;
37
37
  }
38
38
 
39
- function renderClaudePlanSkill() {
39
+ function renderClaudePlanSkill({ portableContractPath = '.agents/skills/gsdd-plan/SKILL.md' } = {}) {
40
+ const contractSection = portableContractPath
41
+ ? `Portable contract:
42
+ - Read \`${portableContractPath}\` first. That file remains the canonical vendor-agnostic plan contract.
43
+ - Keep the portable contract honest: it defines the workflow, but it does not by itself prove fresh-context checker orchestration across runtimes.
44
+ - If the portable skill says plan is still a stub, treat that as a portability-status warning for the generic surface, not as a stop signal for this Claude-native adapter path.`
45
+ : `Workflow contract:
46
+ - This globally installed skill is the canonical Claude-native \`gsdd-plan\` workflow contract.
47
+ - Keep the workflow portable: use repo-local \`.planning/\` artifacts for project state and do not require repo-local \`.agents/skills/\` files to exist.
48
+ - Do not claim that other runtimes have the same behavior unless their own adapters explicitly implement and prove it.`;
49
+ const planningContract = portableContractPath
50
+ ? `\`${portableContractPath}\``
51
+ : 'this skill';
52
+
40
53
  return `---
41
54
  name: gsdd-plan
42
55
  description: Claude-native Phase planning with fresh-context plan checking for GSDD
@@ -45,10 +58,7 @@ argument-hint: [phase-number]
45
58
 
46
59
  You are the Claude-native \`/gsdd-plan\` skill for GSDD phase planning.
47
60
 
48
- Portable contract:
49
- - Read \`.agents/skills/gsdd-plan/SKILL.md\` first. That file remains the canonical vendor-agnostic plan contract.
50
- - Keep the portable contract honest: it defines the workflow, but it does not by itself prove fresh-context checker orchestration across runtimes.
51
- - If the portable skill says plan is still a stub, treat that as a portability-status warning for the generic surface, not as a stop signal for this Claude-native adapter path.
61
+ ${contractSection}
52
62
 
53
63
  Native Claude adapter rule:
54
64
  - This skill is the canonical Claude-native entry surface for \`/gsdd-plan\`.
@@ -66,7 +76,7 @@ Execution flow:
66
76
  d. The explorer runs a GSD-style interactive conversation with the user (gray areas, research, deep-dive questions, assumptions) and writes APPROACH.md.
67
77
  e. Before planning, confirm APPROACH.md records all canonical proof fields: \`alignment_status\`, \`alignment_method\`, \`user_confirmed_at\`, \`explicit_skip_approved\`, \`skip_scope\`, \`skip_rationale\`, and \`confirmed_decisions\`. For \`alignment_status: user_confirmed\`, \`confirmed_decisions\` must name the locked decisions and skip fields may be \`false\`/\`N/A\`; for \`alignment_status: approved_skip\`, \`explicit_skip_approved: true\`, \`skip_scope\`, and \`skip_rationale\` must be substantive. Agent-only "No questions needed" is not valid proof under \`workflow.discuss: true\`.
68
78
  f. Load APPROACH.md decisions as locked constraints alongside SPEC.md decisions.
69
- 4. Produce the initial phase plan according to \`.agents/skills/gsdd-plan/SKILL.md\`. Pass APPROACH.md decisions (if any) as locked constraints to the planner.
79
+ 4. Produce the initial phase plan according to ${planningContract}. Pass APPROACH.md decisions (if any) as locked constraints to the planner.
70
80
  5. If \`.planning/config.json\` has \`workflow.planCheck: false\`, stop after planner self-check and explicitly report reduced assurance. This only skips the independent checker; it does not skip the step 3 alignment-proof gate when \`workflow.discuss: true\`.
71
81
  6. If \`workflow.planCheck: true\`, invoke the native \`gsdd-plan-checker\` subagent with fresh context.
72
82
  7. Pass only explicit inputs to the checker:
@@ -107,14 +117,14 @@ Never return raw checker JSON without summarizing it.
107
117
  `;
108
118
  }
109
119
 
110
- function renderClaudePlanCommand() {
120
+ function renderClaudePlanCommand({ skillPath = '.claude/skills/gsdd-plan/SKILL.md' } = {}) {
111
121
  return `---
112
122
  description: Compatibility alias for the Claude-native \`/gsdd-plan\` skill
113
123
  argument-hint: [phase-number]
114
124
  allowed-tools: Read
115
125
  ---
116
126
 
117
- Read \`.claude/skills/gsdd-plan/SKILL.md\` and execute that skill as the canonical Claude-native \`/gsdd-plan\` entry.
127
+ Read \`${skillPath}\` and execute that skill as the canonical Claude-native \`/gsdd-plan\` entry.
118
128
 
119
129
  Rules:
120
130
  - Do NOT duplicate orchestration logic here.
@@ -145,7 +145,7 @@ ${delegateContent.trim()}
145
145
  `;
146
146
  }
147
147
 
148
- function renderOpenCodePlanCommand() {
148
+ function renderOpenCodePlanCommand({ skillPath = '.agents/skills/gsdd-plan/SKILL.md' } = {}) {
149
149
  return `---
150
150
  description: OpenCode-native phase planning with fresh-context plan checking for GSDD
151
151
  subtask: false
@@ -154,7 +154,7 @@ subtask: false
154
154
  You are the OpenCode-native \`/gsdd-plan\` command for GSDD phase planning.
155
155
 
156
156
  Portable contract:
157
- - Read \`.agents/skills/gsdd-plan/SKILL.md\` first. That file remains the canonical vendor-agnostic plan contract.
157
+ - Read \`${skillPath}\` first. That file remains the canonical vendor-agnostic plan contract.
158
158
  - Keep the portable contract honest: it defines the workflow, but it does not by itself prove fresh-context checker orchestration across runtimes.
159
159
  - If the portable skill says plan is still a stub, treat that as a portability-status warning for the generic surface, not as a stop signal for this OpenCode-native adapter path.
160
160
 
@@ -174,7 +174,7 @@ Execution flow:
174
174
  d. The explorer runs a GSD-style interactive conversation with the user (gray areas, research, deep-dive questions, assumptions) and writes APPROACH.md.
175
175
  e. Before planning, confirm APPROACH.md records all canonical proof fields: \`alignment_status\`, \`alignment_method\`, \`user_confirmed_at\`, \`explicit_skip_approved\`, \`skip_scope\`, \`skip_rationale\`, and \`confirmed_decisions\`. For \`alignment_status: user_confirmed\`, \`confirmed_decisions\` must name the locked decisions and skip fields may be \`false\`/\`N/A\`; for \`alignment_status: approved_skip\`, \`explicit_skip_approved: true\`, \`skip_scope\`, and \`skip_rationale\` must be substantive. Agent-only "No questions needed" is not valid proof under \`workflow.discuss: true\`.
176
176
  f. Load APPROACH.md decisions as locked constraints alongside SPEC.md decisions.
177
- 4. Produce the initial phase plan according to \`.agents/skills/gsdd-plan/SKILL.md\`. Pass APPROACH.md decisions (if any) as locked constraints to the planner.
177
+ 4. Produce the initial phase plan according to \`${skillPath}\`. Pass APPROACH.md decisions (if any) as locked constraints to the planner.
178
178
  5. If \`.planning/config.json\` has \`workflow.planCheck: false\`, stop after planner self-check and explicitly report reduced assurance. This only skips the independent checker; it does not skip the step 3 alignment-proof gate when \`workflow.discuss: true\`.
179
179
  6. If \`workflow.planCheck: true\`, invoke the hidden \`gsdd-plan-checker\` subagent with fresh context.
180
180
  7. Pass only explicit inputs to the checker:
package/bin/gsdd.mjs CHANGED
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env node
2
-
3
2
  import { realpathSync, readFileSync } from 'fs';
4
3
  import { join, dirname } from 'path';
5
4
  import { fileURLToPath } from 'url';
@@ -12,8 +11,9 @@ import {
12
11
  upsertBoundedBlock,
13
12
  getDelegateContent,
14
13
  } from './lib/rendering.mjs';
15
- import { loadProjectModelConfig, getRuntimeModelOverride, resolveRuntimeAgentModel, cmdModels } from './lib/models.mjs';
14
+ import { loadProjectModelConfig, getRuntimeModelOverride, resolveRuntimeAgentModel, cmdModels, cmdRigor } from './lib/models.mjs';
16
15
  import { createCmdInit, createCmdUpdate, cmdHelp } from './lib/init.mjs';
16
+ import { createCmdInstall } from './lib/global-install.mjs';
17
17
  import { cmdFindPhase, cmdVerify, cmdScaffold, cmdPhaseStatus } from './lib/phase.mjs';
18
18
  import { cmdFileOp } from './lib/file-ops.mjs';
19
19
  import { createCmdHealth } from './lib/health.mjs';
@@ -21,8 +21,10 @@ import { cmdLifecyclePreflight } from './lib/lifecycle-preflight.mjs';
21
21
  import { cmdSessionFingerprint } from './lib/session-fingerprint.mjs';
22
22
  import { cmdUiProof } from './lib/ui-proof.mjs';
23
23
  import { cmdControlMap } from './lib/control-map.mjs';
24
+ import { createCmdCloseoutReport } from './lib/closeout-report.mjs';
25
+ import { createCmdNext } from './lib/next.mjs';
24
26
  import { resolveWorkspaceContext } from './lib/workspace-root.mjs';
25
-
27
+ import { FRAMEWORK_VERSION, WORKFLOWS } from './lib/workflows.mjs';
26
28
  const __filename = fileURLToPath(import.meta.url);
27
29
  const __dirname = dirname(__filename);
28
30
  const DISTILLED_DIR = join(__dirname, '..', 'distilled');
@@ -32,34 +34,6 @@ const IS_MAIN = process.argv[1] ? realpathSync(process.argv[1]) === realpathSync
32
34
 
33
35
  const [,, command, ...args] = process.argv;
34
36
 
35
- function defineWorkflow({ mutatesArtifacts = true, ...workflow }) {
36
- return {
37
- ...workflow,
38
- mutatesArtifacts,
39
- agent: mutatesArtifacts ? 'Code' : 'Plan',
40
- opencodeType: mutatesArtifacts ? 'edit' : 'plan',
41
- };
42
- }
43
-
44
- const WORKFLOWS = [
45
- defineWorkflow({ name: 'gsdd-new-project', workflow: 'new-project.md', description: 'New project - questioning, codebase audit, research, spec, roadmap' }),
46
- defineWorkflow({ name: 'gsdd-map-codebase', workflow: 'map-codebase.md', description: 'Map or refresh codebase - 4 parallel mappers, staleness check, secrets scan' }),
47
- defineWorkflow({ name: 'gsdd-plan', workflow: 'plan.md', description: 'Plan a phase - research check, backward planning, task creation' }),
48
- defineWorkflow({ name: 'gsdd-execute', workflow: 'execute.md', description: 'Execute a phase plan - implement tasks, verify changes, follow repo git conventions' }),
49
- defineWorkflow({ name: 'gsdd-verify', workflow: 'verify.md', description: 'Verify a completed phase - 3-level checks, anti-pattern scan' }),
50
- defineWorkflow({ name: 'gsdd-verify-work', workflow: 'verify-work.md', description: 'Conversational UAT testing - validate user-facing behavior with structured gap tracking' }),
51
- defineWorkflow({ name: 'gsdd-audit-milestone', workflow: 'audit-milestone.md', description: 'Audit a completed milestone - cross-phase integration, requirements coverage, E2E flows' }),
52
- defineWorkflow({ name: 'gsdd-complete-milestone', workflow: 'complete-milestone.md', description: 'Complete milestone - archive, evolve spec, collapse roadmap' }),
53
- defineWorkflow({ name: 'gsdd-new-milestone', workflow: 'new-milestone.md', description: 'New milestone - gather goals, define requirements, create roadmap phases' }),
54
- defineWorkflow({ name: 'gsdd-plan-milestone-gaps', workflow: 'plan-milestone-gaps.md', description: 'Plan gap closure phases from audit results' }),
55
- defineWorkflow({ name: 'gsdd-quick', workflow: 'quick.md', description: 'Quick task - plan and execute a sub-hour task outside the phase cycle' }),
56
- defineWorkflow({ name: 'gsdd-pause', workflow: 'pause.md', description: 'Pause work - save session context for seamless resumption' }),
57
- defineWorkflow({ name: 'gsdd-resume', workflow: 'resume.md', description: 'Resume work - restore context and route to next action' }),
58
- defineWorkflow({ name: 'gsdd-progress', workflow: 'progress.md', description: 'Check progress - show project status and route to next action', mutatesArtifacts: false }),
59
- ];
60
-
61
- const FRAMEWORK_VERSION = 'v1.4';
62
-
63
37
  function createCliContext(cwd = process.cwd()) {
64
38
  return {
65
39
  cwd,
@@ -70,6 +44,9 @@ function createCliContext(cwd = process.cwd()) {
70
44
  packageVersion: PACKAGE_JSON.version,
71
45
  workflows: WORKFLOWS,
72
46
  frameworkVersion: FRAMEWORK_VERSION,
47
+ loadProjectModelConfig,
48
+ getRuntimeModelOverride,
49
+ resolveRuntimeAgentModel,
73
50
  adapters: createAdapterRegistry({
74
51
  cwd,
75
52
  workflows: WORKFLOWS,
@@ -88,7 +65,10 @@ function createCliContext(cwd = process.cwd()) {
88
65
 
89
66
  const INIT_CONTEXT = createCliContext(process.cwd());
90
67
  const cmdInit = createCmdInit(INIT_CONTEXT);
68
+ const cmdInstall = createCmdInstall(INIT_CONTEXT);
91
69
  const cmdHealth = createCmdHealth(INIT_CONTEXT);
70
+ const cmdCloseoutReport = createCmdCloseoutReport(INIT_CONTEXT);
71
+ const cmdNext = createCmdNext(INIT_CONTEXT);
92
72
 
93
73
  const cmdUpdate = (...updateArgs) => {
94
74
  const { args: normalizedArgs, workspaceRoot, invalid, error } = resolveWorkspaceContext(updateArgs, { cwd: INIT_CONTEXT.cwd });
@@ -99,17 +79,20 @@ const cmdUpdate = (...updateArgs) => {
99
79
  }
100
80
  return createCmdUpdate(createCliContext(workspaceRoot))(...normalizedArgs);
101
81
  };
102
-
103
82
  const COMMANDS = {
104
83
  init: cmdInit,
84
+ install: cmdInstall,
105
85
  update: cmdUpdate,
106
86
  models: cmdModels,
87
+ rigor: cmdRigor,
107
88
  health: cmdHealth,
89
+ next: cmdNext,
108
90
  'file-op': cmdFileOp,
109
91
  'lifecycle-preflight': cmdLifecyclePreflight,
110
92
  'session-fingerprint': cmdSessionFingerprint,
111
93
  'ui-proof': cmdUiProof,
112
94
  'control-map': cmdControlMap,
95
+ 'closeout-report': cmdCloseoutReport,
113
96
  'find-phase': cmdFindPhase,
114
97
  'phase-status': cmdPhaseStatus,
115
98
  verify: cmdVerify,
@@ -136,4 +119,4 @@ async function runCli(cliCommand = command, ...cliArgs) {
136
119
  }
137
120
 
138
121
  if (IS_MAIN) await runCli();
139
- export { cmdHelp, cmdInit, cmdUpdate, cmdModels, cmdHealth, cmdFileOp, cmdLifecyclePreflight, cmdSessionFingerprint, cmdUiProof, cmdControlMap, cmdFindPhase, cmdPhaseStatus, cmdVerify, cmdScaffold, runCli, FRAMEWORK_VERSION, createCliContext };
122
+ export { cmdHelp, cmdInit, cmdInstall, cmdUpdate, cmdModels, cmdRigor, cmdHealth, cmdNext, cmdFileOp, cmdLifecyclePreflight, cmdSessionFingerprint, cmdUiProof, cmdControlMap, cmdCloseoutReport, cmdFindPhase, cmdPhaseStatus, cmdVerify, cmdScaffold, runCli, FRAMEWORK_VERSION, createCliContext };
@@ -0,0 +1,318 @@
1
+ // closeout-report.mjs - read-only post-merge closure replay report
2
+
3
+ import { output, parseFlagValue } from './cli-utils.mjs';
4
+ import { buildControlMap } from './control-map.mjs';
5
+ import { evaluateLifecyclePreflight } from './lifecycle-preflight.mjs';
6
+ import { evaluateLifecycleState, normalizePhaseToken } from './lifecycle-state.mjs';
7
+ import { buildPhaseVerificationReport } from './phase.mjs';
8
+ import { resolveWorkspaceContext } from './workspace-root.mjs';
9
+
10
+ const USAGE = 'Usage: gsdd closeout-report [--json] [--phase <N>]';
11
+
12
+ function severityFromRisk(risk) {
13
+ if (risk.severity === 'block') return 'blocker';
14
+ if (risk.severity === 'warn') return 'warn';
15
+ return risk.severity || 'info';
16
+ }
17
+
18
+ function notice(source, severity, entry) {
19
+ return {
20
+ source,
21
+ severity,
22
+ code: entry.code || entry.id || 'unknown',
23
+ message: entry.message,
24
+ fix: entry.fix_hint || entry.fix || null,
25
+ path: entry.path || null,
26
+ };
27
+ }
28
+
29
+ function latestCompletedPhase(lifecycle) {
30
+ const completed = lifecycle.phases.filter((phase) => phase.status === 'done');
31
+ return completed.length > 0 ? completed[completed.length - 1] : null;
32
+ }
33
+
34
+ async function buildHealthReportSafe(ctx, args) {
35
+ try {
36
+ const health = await import('./health.mjs');
37
+ return health.buildHealthReport(ctx, args);
38
+ } catch (error) {
39
+ return {
40
+ status: 'degraded',
41
+ errors: [],
42
+ warnings: [{
43
+ id: 'W_CLOSEOUT_HEALTH_UNAVAILABLE',
44
+ severity: 'WARN',
45
+ message: `Health report builder unavailable in this helper runtime: ${error.message}`,
46
+ fix: 'Run the source CLI `gsdd health --json` for full health diagnostics, or refresh generated helper support.',
47
+ }],
48
+ info: [],
49
+ };
50
+ }
51
+ }
52
+
53
+ function summarizeControlMap(map) {
54
+ return {
55
+ status: map.risks.some((risk) => risk.severity === 'block')
56
+ ? 'blocked'
57
+ : map.risks.some((risk) => risk.severity === 'warn')
58
+ ? 'warnings'
59
+ : 'clear',
60
+ authority: map.authority,
61
+ canonical_worktree: {
62
+ branch: map.canonical_worktree.branch,
63
+ head: map.canonical_worktree.head,
64
+ dirty: map.canonical_worktree.dirty,
65
+ ahead_behind: map.canonical_worktree.ahead_behind,
66
+ },
67
+ worktree_count: map.worktrees.length,
68
+ risks: map.risks,
69
+ interventions: map.interventions,
70
+ };
71
+ }
72
+
73
+ function summarizePreflight(preflight) {
74
+ return {
75
+ status: preflight.status,
76
+ allowed: preflight.allowed,
77
+ reason: preflight.reason,
78
+ blockers: preflight.blockers,
79
+ warnings: preflight.warnings,
80
+ };
81
+ }
82
+
83
+ function summarizePhaseVerification(report) {
84
+ if (!report.ok) {
85
+ return {
86
+ status: 'blocked',
87
+ verified: false,
88
+ error: report.error,
89
+ blocked_on: ['verification'],
90
+ };
91
+ }
92
+ return {
93
+ status: report.result.verified ? 'passed' : 'blocked',
94
+ verified: report.result.verified,
95
+ phase_artifacts_present: report.result.phase_artifacts_present,
96
+ prerequisite_status: report.result.prerequisite_status,
97
+ artifact_status: report.result.artifact_status,
98
+ blocked_on: report.result.blocked_on,
99
+ blocks_verification: report.result.blocks_verification,
100
+ };
101
+ }
102
+
103
+ function collectBlockers({ health, preflight, phaseReport }) {
104
+ const blockers = [];
105
+ const uiProofErrors = phaseReport.result?.uiProof?.errors || [];
106
+ const uiProofGate = phaseReport.result?.ui_proof || {};
107
+
108
+ blockers.push(...health.errors.map((entry) => notice('health', 'blocker', entry)));
109
+ blockers.push(...preflight.blockers.map((entry) => notice('preflight', 'blocker', entry)));
110
+
111
+ if (!phaseReport.ok) {
112
+ blockers.push(notice('phase_verification', 'blocker', {
113
+ code: 'phase_verification_error',
114
+ message: phaseReport.error,
115
+ fix_hint: 'Run the direct verify command for the selected phase and repair the reported prerequisite.',
116
+ }));
117
+ } else {
118
+ for (const blockerEntry of phaseReport.result.prerequisite_status.blockers || []) {
119
+ blockers.push(notice('phase_verification', 'blocker', blockerEntry));
120
+ }
121
+ for (const artifact of phaseReport.result.artifact_status.unsatisfied || []) {
122
+ blockers.push(notice('phase_verification', 'blocker', {
123
+ code: 'unsatisfied_plan_artifact',
124
+ message: `${artifact.file} is not ${artifact.expected}.`,
125
+ fix_hint: artifact.fix_hint,
126
+ path: artifact.file,
127
+ }));
128
+ }
129
+ for (const entry of uiProofErrors) {
130
+ blockers.push(notice('ui_proof', entry.severity === 'warn' ? 'warn' : 'blocker', entry));
131
+ }
132
+ if (uiProofErrors.length === 0 && uiProofGate.blocks_verification) {
133
+ blockers.push(notice('ui_proof', 'blocker', {
134
+ code: uiProofGate.required_block || 'ui_proof_verification_failed',
135
+ message: `UI proof verification is required for phase ${phaseReport.result.phase} but reported status ${uiProofGate.status}.`,
136
+ fix_hint: 'Run the phase-specific UI proof checks and supply a passing observed proof bundle for each declared slot.',
137
+ }));
138
+ }
139
+ }
140
+
141
+ return blockers.filter((entry) => entry.severity === 'blocker');
142
+ }
143
+
144
+ function collectWarnings({ controlMap, health, preflight, phaseReport }) {
145
+ const warnings = [];
146
+ warnings.push(...controlMap.risks
147
+ .filter((risk) => risk.severity !== 'block')
148
+ .map((risk) => notice('control_map', severityFromRisk(risk), risk)));
149
+ warnings.push(...health.warnings.map((entry) => notice('health', 'warn', entry)));
150
+ warnings.push(...preflight.warnings
151
+ .filter((entry) => entry.source !== 'control-map')
152
+ .map((entry) => notice('preflight', entry.severity === 'info' ? 'info' : 'warn', entry)));
153
+ if (phaseReport.ok) {
154
+ warnings.push(...(phaseReport.result.uiProof?.warnings || []).map((entry) => notice('ui_proof', 'warn', entry)));
155
+ }
156
+ return warnings;
157
+ }
158
+
159
+ function nextSafeAction({ blockers, warnings, phaseNumber }) {
160
+ if (blockers.length > 0) {
161
+ return {
162
+ command: `gsdd verify ${phaseNumber}`,
163
+ reason: 'Fix blockers first, then re-run closeout replay.',
164
+ };
165
+ }
166
+ const hasWarn = warnings.some((entry) => entry.severity === 'warn');
167
+ if (warnings.length > 0 && hasWarn) {
168
+ const sources = new Set(warnings.filter((entry) => entry.severity === 'warn').map((entry) => entry.source));
169
+ if (sources.has('health')) {
170
+ return {
171
+ command: 'gsdd health --json',
172
+ reason: 'Resolve workspace health warnings before claiming the environment is clean.',
173
+ };
174
+ }
175
+ if (sources.has('ui_proof') || sources.has('phase_verification')) {
176
+ return {
177
+ command: `gsdd verify ${phaseNumber}`,
178
+ reason: 'Resolve phase verification warnings before claiming closeout is replay-clean.',
179
+ };
180
+ }
181
+ return {
182
+ command: 'gsdd control-map --json',
183
+ reason: 'Resolve local state warnings before claiming the environment is clean.',
184
+ };
185
+ }
186
+ if (warnings.length > 0) {
187
+ return {
188
+ command: 'gsdd control-map --json',
189
+ reason: 'Review the informational notices before claiming the local environment is clean.',
190
+ };
191
+ }
192
+ return {
193
+ command: `gsdd verify ${phaseNumber}`,
194
+ reason: 'Closeout replay is clean; run formal verification for closure if it has not already been recorded.',
195
+ };
196
+ }
197
+
198
+ export async function buildCloseoutReport(ctx = {}, args = []) {
199
+ const context = resolveWorkspaceContext(args, { cwd: ctx.cwd || process.cwd() });
200
+ if (context.invalid) {
201
+ return {
202
+ ok: false,
203
+ error: context.error,
204
+ exitCode: 1,
205
+ };
206
+ }
207
+
208
+ const phaseArg = parseFlagValue(context.args, '--phase');
209
+ if (phaseArg.invalid) {
210
+ return { ok: false, error: USAGE, exitCode: 1 };
211
+ }
212
+
213
+ const filteredArgs = context.args.filter((arg, index) => {
214
+ if (arg === '--json') return false;
215
+ if (arg === '--phase') return false;
216
+ if (index > 0 && context.args[index - 1] === '--phase') return false;
217
+ return true;
218
+ });
219
+ if (filteredArgs.length > 0) {
220
+ return { ok: false, error: USAGE, exitCode: 1 };
221
+ }
222
+
223
+ const lifecycle = evaluateLifecycleState({ planningDir: context.planningDir });
224
+ const selectedPhase = phaseArg.present
225
+ ? normalizePhaseToken(phaseArg.value)
226
+ : latestCompletedPhase(lifecycle)?.number || null;
227
+ if (!selectedPhase) {
228
+ return {
229
+ ok: false,
230
+ error: 'No completed phase found. Pass --phase <N> to replay a specific phase.',
231
+ exitCode: 1,
232
+ };
233
+ }
234
+
235
+ const controlMap = buildControlMap({
236
+ workspaceRoot: context.workspaceRoot,
237
+ planningDir: context.planningDir,
238
+ });
239
+ const health = await buildHealthReportSafe(ctx, ['--workspace-root', context.workspaceRoot]);
240
+ const preflight = evaluateLifecyclePreflight({
241
+ planningDir: context.planningDir,
242
+ surface: 'verify',
243
+ phaseNumber: selectedPhase,
244
+ expectsMutation: 'phase-status',
245
+ controlMapReport: controlMap,
246
+ });
247
+ const phaseReport = buildPhaseVerificationReport('--workspace-root', context.workspaceRoot, selectedPhase);
248
+ const blockers = collectBlockers({ health, preflight, phaseReport });
249
+ const warnings = collectWarnings({ controlMap, health, preflight, phaseReport });
250
+ const status = blockers.length > 0 ? 'blocked' : warnings.length > 0 ? 'warnings' : 'clear';
251
+
252
+ return {
253
+ ok: true,
254
+ exitCode: blockers.length > 0 ? 1 : 0,
255
+ result: {
256
+ schema_version: 1,
257
+ operation: 'closeout-report',
258
+ generated_at: new Date().toISOString(),
259
+ workspace_root: context.workspaceRoot.replace(/\\/g, '/'),
260
+ phase: selectedPhase,
261
+ scope: {
262
+ defaulted_to_latest_completed: !phaseArg.present,
263
+ latest_completed_phase: latestCompletedPhase(lifecycle)?.number || null,
264
+ },
265
+ status,
266
+ blockers,
267
+ warnings,
268
+ next_safe_action: nextSafeAction({ blockers, warnings, phaseNumber: selectedPhase }),
269
+ control_map: summarizeControlMap(controlMap),
270
+ health: {
271
+ status: health.status,
272
+ errors: health.errors,
273
+ warnings: health.warnings,
274
+ info: health.info,
275
+ },
276
+ preflight: summarizePreflight(preflight),
277
+ phase_verification: summarizePhaseVerification(phaseReport),
278
+ ui_proof: phaseReport.ok ? phaseReport.result.ui_proof : null,
279
+ },
280
+ };
281
+ }
282
+
283
+ function printHuman(report) {
284
+ console.log('gsdd closeout-report - read-only closure replay\n');
285
+ console.log(`Phase: ${report.phase}`);
286
+ console.log(`Status: ${report.status}`);
287
+ if (report.blockers.length > 0) {
288
+ console.log('\nBlockers:');
289
+ for (const blocker of report.blockers) {
290
+ console.log(` - [${blocker.source}] ${blocker.code}: ${blocker.message}`);
291
+ if (blocker.fix) console.log(` Fix: ${blocker.fix}`);
292
+ }
293
+ }
294
+ if (report.warnings.length > 0) {
295
+ console.log('\nWarnings:');
296
+ for (const warning of report.warnings) {
297
+ console.log(` - [${warning.source}] ${warning.code}: ${warning.message}`);
298
+ if (warning.fix) console.log(` Fix: ${warning.fix}`);
299
+ }
300
+ }
301
+ console.log(`\nNext safe action: ${report.next_safe_action.command}`);
302
+ console.log(`Reason: ${report.next_safe_action.reason}`);
303
+ }
304
+
305
+ export function createCmdCloseoutReport(ctx = {}) {
306
+ return async function cmdCloseoutReport(...args) {
307
+ const jsonMode = args.includes('--json');
308
+ const report = await buildCloseoutReport(ctx, args);
309
+ if (!report.ok) {
310
+ console.error(report.error);
311
+ process.exitCode = report.exitCode;
312
+ return;
313
+ }
314
+ if (jsonMode) output(report.result);
315
+ else printHuman(report.result);
316
+ if (report.exitCode !== 0) process.exitCode = report.exitCode;
317
+ };
318
+ }