vgxness 1.9.2 → 1.9.3

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 (39) hide show
  1. package/README.md +9 -4
  2. package/dist/agents/agent-resolver.js +33 -3
  3. package/dist/agents/canonical-agent-manifest.js +39 -18
  4. package/dist/agents/canonical-agent-projection.js +31 -4
  5. package/dist/cli/cli-help.js +14 -3
  6. package/dist/cli/commands/index.js +1 -0
  7. package/dist/cli/commands/interactive-entrypoint-dispatcher.js +8 -0
  8. package/dist/cli/commands/memory-sdd-dispatcher.js +71 -5
  9. package/dist/cli/commands/status-dispatcher.js +130 -0
  10. package/dist/cli/commands/workflow-dispatcher.js +11 -5
  11. package/dist/cli/dispatcher.js +9 -1
  12. package/dist/cli/product-resume-renderer.js +32 -0
  13. package/dist/cli/product-status-renderer.js +74 -0
  14. package/dist/cli/sdd-renderer.js +80 -3
  15. package/dist/code/cli/code-command.js +7 -4
  16. package/dist/code/reporting/summary.js +4 -1
  17. package/dist/code/runtime/code-runtime.js +27 -4
  18. package/dist/code/runtime/sdd-context.js +18 -2
  19. package/dist/governance/governance-report-builder.js +18 -7
  20. package/dist/mcp/claude-code-agent-config.js +10 -4
  21. package/dist/mcp/control-plane.js +56 -0
  22. package/dist/mcp/provider-status.js +86 -81
  23. package/dist/mcp/schema.js +25 -7
  24. package/dist/mcp/stdio-server.js +4 -0
  25. package/dist/mcp/validation.js +39 -3
  26. package/dist/resume/product-resume.js +166 -0
  27. package/dist/runs/repositories/runs.js +12 -1
  28. package/dist/runs/run-service.js +62 -5
  29. package/dist/sdd/schema.js +8 -0
  30. package/dist/sdd/sdd-continuation-plan.js +81 -0
  31. package/dist/sdd/sdd-workflow-service.js +103 -16
  32. package/dist/skills/skill-resolver.js +21 -4
  33. package/dist/status/product-status.js +117 -0
  34. package/docs/architecture.md +1 -1
  35. package/docs/cli.md +33 -8
  36. package/docs/code-runtime.md +3 -0
  37. package/docs/glossary.md +1 -1
  38. package/docs/mcp.md +18 -4
  39. package/package.json +1 -1
@@ -0,0 +1,166 @@
1
+ import { basename, resolve } from 'node:path';
2
+ const candidateLimit = 5;
3
+ const baseSafety = [
4
+ 'Read-only resume cockpit: does not execute providers, retry operations, edit files, write provider config, create sandboxes, or create worktrees.',
5
+ 'This command only helps inspect interrupted work; any continuation remains an explicit human decision.',
6
+ ];
7
+ export function buildProductResume(input) {
8
+ const initialProject = resolveProject(input);
9
+ if (input.runId === undefined) {
10
+ return buildOrientationResume(input, initialProject);
11
+ }
12
+ if (input.databaseError !== undefined) {
13
+ return blockedResume({
14
+ project: initialProject,
15
+ runId: input.runId,
16
+ blocker: `Unable to read local memory store${input.databasePath === undefined ? '' : ` at ${input.databasePath}`}: ${input.databaseError}`,
17
+ why: 'Run inspection requires a readable local memory store.',
18
+ command: `vgxness resume --project ${initialProject.value} --run-id ${input.runId} --db <path>`,
19
+ safety: baseSafety,
20
+ });
21
+ }
22
+ if (input.runs?.getRunOperatorResumePlan === undefined) {
23
+ return blockedResume({
24
+ project: initialProject,
25
+ runId: input.runId,
26
+ blocker: 'Run resume inspection service is not available for this request.',
27
+ why: 'Use the installed CLI or provide a readable local memory store.',
28
+ command: `vgxness resume --project ${initialProject.value} --run-id ${input.runId}`,
29
+ safety: baseSafety,
30
+ });
31
+ }
32
+ const inspected = input.runs.getRunOperatorResumePlan(input.runId);
33
+ if (!inspected.ok) {
34
+ return blockedResume({
35
+ project: initialProject,
36
+ runId: input.runId,
37
+ blocker: inspected.error.message,
38
+ why: 'VGXNESS could not find or inspect the requested run.',
39
+ command: `vgxness runs list --project ${initialProject.value}`,
40
+ safety: baseSafety,
41
+ });
42
+ }
43
+ return fromRunOperatorResumePlan(inspected.value, initialProject, input.databasePath !== undefined);
44
+ }
45
+ function buildOrientationResume(input, project) {
46
+ const dbHint = input.databasePath === undefined ? '' : ` --db ${input.databasePath}`;
47
+ const command = `vgxness runs list --project ${project.value} --status failed${dbHint}`;
48
+ const manualNextCommands = [
49
+ `vgxness runs list --project ${project.value} --status failed${dbHint}`,
50
+ `vgxness runs list --project ${project.value} --status blocked${dbHint}`,
51
+ `vgxness runs list --project ${project.value} --status needs-human${dbHint}`,
52
+ `vgxness resume --project ${project.value} --run-id <id>${dbHint}`,
53
+ ];
54
+ if (input.databaseError !== undefined) {
55
+ return {
56
+ version: 1,
57
+ kind: 'product-resume',
58
+ mode: 'orientation',
59
+ project,
60
+ resumable: false,
61
+ resume: ['Find an interrupted run, then inspect it before deciding whether to continue manually.'],
62
+ why: ['No --run-id was provided, so VGXNESS cannot inspect checkpoint, approval, or blocker state for a selected run yet.'],
63
+ blockers: [`Unable to read local memory store${input.databasePath === undefined ? '' : ` at ${input.databasePath}`}: ${input.databaseError}`],
64
+ command,
65
+ manualNextCommands,
66
+ safety: baseSafety,
67
+ candidateRuns: [],
68
+ };
69
+ }
70
+ const listed = input.runs?.listRecentInterruptedRuns?.({ project: project.value, limit: candidateLimit });
71
+ if (listed !== undefined && !listed.ok) {
72
+ return {
73
+ version: 1,
74
+ kind: 'product-resume',
75
+ mode: 'orientation',
76
+ project,
77
+ resumable: false,
78
+ resume: ['Find an interrupted run, then inspect it before deciding whether to continue manually.'],
79
+ why: ['No --run-id was provided, so VGXNESS cannot inspect checkpoint, approval, or blocker state for a selected run yet.'],
80
+ blockers: [listed.error.message],
81
+ command,
82
+ manualNextCommands,
83
+ safety: baseSafety,
84
+ candidateRuns: [],
85
+ };
86
+ }
87
+ const candidateRuns = (listed?.value ?? []).map((candidate) => ({
88
+ ...candidate,
89
+ ...(candidate.userIntent === undefined ? {} : { userIntent: shorten(candidate.userIntent, 72) }),
90
+ command: `vgxness resume --project ${project.value} --run-id ${candidate.runId}${dbHint}`,
91
+ }));
92
+ const why = listed === undefined
93
+ ? ['No --run-id was provided, so VGXNESS cannot inspect checkpoint, approval, or blocker state for a selected run yet.']
94
+ : ['No --run-id was provided, so VGXNESS is showing recent failed, blocked, or needs-human runs for this project.'];
95
+ return {
96
+ version: 1,
97
+ kind: 'product-resume',
98
+ mode: 'orientation',
99
+ project,
100
+ resumable: false,
101
+ resume: candidateRuns.length === 0
102
+ ? ['Find an interrupted run, then inspect it before deciding whether to continue manually.']
103
+ : ['Recent interrupted runs were found. Inspect one before deciding whether to continue manually.'],
104
+ why,
105
+ blockers: [],
106
+ command,
107
+ manualNextCommands,
108
+ safety: [listed === undefined ? 'Did not open the local memory store because no project-specific run lookup was requested.' : 'Opened the local memory store read-only to list recent interrupted runs.', ...baseSafety],
109
+ candidateRuns,
110
+ };
111
+ }
112
+ function resolveProject(input) {
113
+ if (input.project !== undefined && input.project.trim().length > 0)
114
+ return { value: input.project.trim(), source: 'flag' };
115
+ const directoryName = basename(resolve(input.cwd));
116
+ return { value: directoryName.length > 0 ? directoryName : 'unknown-project', source: 'cwd' };
117
+ }
118
+ function fromRunOperatorResumePlan(inspect, fallbackProject, includeDbHint) {
119
+ const project = { value: inspect.run.project || fallbackProject.value, source: 'run' };
120
+ const blockerLines = inspect.blockers.map((blocker) => (blocker.relatedId === undefined ? blocker.message : `${blocker.message} (${blocker.relatedId})`));
121
+ const latestCheckpoint = inspect.latestCheckpoint === null ? 'none' : `${inspect.latestCheckpoint.label} (${inspect.latestCheckpoint.id})`;
122
+ const dbHint = includeDbHint ? ' --db <path>' : '';
123
+ return {
124
+ version: 1,
125
+ kind: 'product-resume',
126
+ mode: 'inspect',
127
+ project,
128
+ runId: inspect.run.id,
129
+ resumable: inspect.resumePlan.resumable,
130
+ resume: [inspect.resumePlan.recommendedAction],
131
+ why: [
132
+ `Run: ${inspect.run.id}`,
133
+ `Status: ${inspect.run.status}`,
134
+ `Workflow: ${inspect.run.workflow} / ${inspect.run.phase}`,
135
+ `Latest checkpoint: ${latestCheckpoint}`,
136
+ inspect.debug.summary,
137
+ ],
138
+ blockers: blockerLines,
139
+ command: `vgxness resume --project ${project.value} --run-id ${inspect.run.id}${dbHint}`,
140
+ manualNextCommands: inspect.manualNextCommands,
141
+ safety: baseSafety,
142
+ candidateRuns: [],
143
+ inspect,
144
+ };
145
+ }
146
+ function blockedResume(input) {
147
+ return {
148
+ version: 1,
149
+ kind: 'product-resume',
150
+ mode: 'inspect',
151
+ project: input.project,
152
+ runId: input.runId,
153
+ resumable: false,
154
+ resume: ['Resolve the blocker, then inspect the run again before deciding whether to continue manually.'],
155
+ why: [`Run: ${input.runId}`, input.why],
156
+ blockers: [input.blocker],
157
+ command: input.command,
158
+ manualNextCommands: [input.command],
159
+ safety: input.safety,
160
+ candidateRuns: [],
161
+ };
162
+ }
163
+ function shorten(value, maxLength) {
164
+ const normalized = value.replace(/\s+/g, ' ').trim();
165
+ return normalized.length <= maxLength ? normalized : `${normalized.slice(0, maxLength - 3)}...`;
166
+ }
@@ -70,13 +70,24 @@ export class RunRepository {
70
70
  where.push('status=@status');
71
71
  parameters.status = filters.status;
72
72
  }
73
+ if (filters.statuses !== undefined && filters.statuses.length > 0) {
74
+ const statusPlaceholders = filters.statuses.map((_, index) => `@status${index}`);
75
+ where.push(`status IN (${statusPlaceholders.join(', ')})`);
76
+ for (const [index, status] of filters.statuses.entries())
77
+ parameters[`status${index}`] = status;
78
+ }
79
+ const limitClause = filters.limit === undefined ? '' : 'LIMIT @limit';
80
+ const queryParameters = { ...parameters };
81
+ if (filters.limit !== undefined)
82
+ queryParameters.limit = filters.limit;
73
83
  const rows = this.db.connection
74
84
  .prepare(`
75
85
  SELECT * FROM runs
76
86
  ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
77
87
  ORDER BY created_at DESC
88
+ ${limitClause}
78
89
  `)
79
- .all(parameters);
90
+ .all(queryParameters);
80
91
  return ok(rows.map(mapRun));
81
92
  }
82
93
  catch (cause) {
@@ -1,5 +1,6 @@
1
1
  import { evaluatePermission } from '../permissions/policy-evaluator.js';
2
2
  import { isRiskyPermissionCategory } from '../permissions/schema.js';
3
+ import { normalizeSddPhaseInput } from '../sdd/schema.js';
3
4
  import { planExecutionIsolation } from './execution-planning.js';
4
5
  import { evaluateOperationRetry as evaluateOperationRetryPolicy } from './operation-retry.js';
5
6
  import { RunRepository, } from './repositories/runs.js';
@@ -28,7 +29,7 @@ export class RunService {
28
29
  this.runs = new RunRepository(database);
29
30
  }
30
31
  createRun(input) {
31
- return this.runs.create(input);
32
+ return this.runs.create({ ...input, phase: canonicalRunPhase(input.workflow, input.phase) ?? input.phase });
32
33
  }
33
34
  getRun(id) {
34
35
  return this.runs.getDetails(id);
@@ -44,6 +45,53 @@ export class RunService {
44
45
  listRuns(filters = {}) {
45
46
  return this.runs.list(filters);
46
47
  }
48
+ listRecentInterruptedRuns(input) {
49
+ const runs = this.runs.list({ project: input.project, statuses: ['failed', 'blocked', 'needs-human'], limit: input.limit ?? 5 });
50
+ if (!runs.ok)
51
+ return runs;
52
+ const candidates = [];
53
+ for (const run of runs.value) {
54
+ const details = this.runs.getDetails(run.id);
55
+ if (!details.ok)
56
+ return details;
57
+ const latestCheckpoint = details.value.checkpoints.at(-1);
58
+ const userIntent = details.value.userIntent.trim();
59
+ candidates.push({
60
+ runId: details.value.id,
61
+ status: details.value.status,
62
+ workflow: details.value.workflow,
63
+ phase: details.value.phase,
64
+ ...(userIntent.length === 0 ? {} : { userIntent }),
65
+ ...(latestCheckpoint === undefined ? {} : { latestCheckpointLabel: latestCheckpoint.label }),
66
+ });
67
+ }
68
+ return { ok: true, value: candidates };
69
+ }
70
+ findRelatedInterruptedSddRun(input) {
71
+ const runs = this.runs.list({ project: input.project, statuses: ['failed', 'blocked', 'needs-human'], limit: input.limit ?? 25 });
72
+ if (!runs.ok)
73
+ return runs;
74
+ for (const run of runs.value) {
75
+ const details = this.runs.getDetails(run.id);
76
+ if (!details.ok)
77
+ return details;
78
+ const hasMatchingCheckpoint = details.value.checkpoints.some((checkpoint) => checkpointHasChangeId(checkpoint.state, input.change));
79
+ if (!hasMatchingCheckpoint)
80
+ continue;
81
+ const latestCheckpoint = details.value.checkpoints.at(-1);
82
+ return {
83
+ ok: true,
84
+ value: {
85
+ runId: details.value.id,
86
+ status: details.value.status,
87
+ workflow: details.value.workflow,
88
+ phase: details.value.phase,
89
+ ...(latestCheckpoint === undefined ? {} : { latestCheckpointLabel: latestCheckpoint.label }),
90
+ },
91
+ };
92
+ }
93
+ return { ok: true, value: undefined };
94
+ }
47
95
  appendEvent(input) {
48
96
  return this.runs.appendEvent(input);
49
97
  }
@@ -695,10 +743,11 @@ export class RunService {
695
743
  const run = this.runs.getById(input.runId);
696
744
  if (!run.ok)
697
745
  return { ok: false, error: run.error };
698
- const phase = input.phase ?? canonicalRunPhase(run.value.phase);
746
+ const workflow = input.workflow ?? run.value.workflow;
747
+ const phase = canonicalRunPhase(workflow, input.phase) ?? canonicalRunPhase(run.value.workflow, run.value.phase);
699
748
  const merged = {
700
749
  ...input,
701
- workflow: input.workflow ?? run.value.workflow,
750
+ workflow,
702
751
  ...(phase === undefined ? {} : { phase }),
703
752
  agentId: input.agentId ?? input.agent?.id ?? run.value.selectedAgentId,
704
753
  };
@@ -799,6 +848,9 @@ export class RunService {
799
848
  return { ok: true, value: resolved[0] };
800
849
  }
801
850
  }
851
+ function checkpointHasChangeId(state, change) {
852
+ return typeof state === 'object' && state !== null && !Array.isArray(state) && state.changeId === change;
853
+ }
802
854
  function preflightOutcome(decision) {
803
855
  if (decision.decision === 'allow')
804
856
  return 'allowed';
@@ -883,10 +935,15 @@ function resumeGateManualNextCommands(approval, details, operation) {
883
935
  commands.push(`Review operation manually: ${operation.category} ${operation.operation}`);
884
936
  return commands;
885
937
  }
886
- function canonicalRunPhase(phase) {
938
+ function canonicalRunPhase(workflow, phase) {
887
939
  if (phase === undefined || phase.trim().length === 0)
888
940
  return undefined;
889
- return phase === 'apply' ? 'apply-progress' : phase;
941
+ if (!isSddWorkflow(workflow))
942
+ return phase;
943
+ return normalizeSddPhaseInput(phase) ?? phase;
944
+ }
945
+ function isSddWorkflow(workflow) {
946
+ return workflow?.trim().toLowerCase() === 'sdd';
890
947
  }
891
948
  function operationMetadata(input) {
892
949
  const metadata = { category: input.category, name: input.operation };
@@ -98,6 +98,14 @@ export const sddPrerequisites = {
98
98
  export function isSddPhase(value) {
99
99
  return sddPhases.includes(value);
100
100
  }
101
+ export function normalizeSddPhaseInput(value) {
102
+ const normalized = value.trim();
103
+ if (normalized === 'apply')
104
+ return 'apply-progress';
105
+ if (isSddPhase(normalized))
106
+ return normalized;
107
+ return undefined;
108
+ }
101
109
  export function sddTopicKey(change, phase) {
102
110
  return `sdd/${change}/${phase}`;
103
111
  }
@@ -0,0 +1,81 @@
1
+ export function sddContinuationPlanFrom(input) {
2
+ const nextPhase = input.next.nextPhase;
3
+ const dbFlag = continuationDbFlag(input.explicitDatabasePath);
4
+ const inspectCommand = `vgxness sdd cockpit --project ${input.project} --change ${input.next.change} --json${dbFlag}`;
5
+ const suggestedCommand = suggestedContinuationCommand(input.project, input.next, inspectCommand, dbFlag);
6
+ const blockerGuidance = input.next.blockerGuidance ?? [];
7
+ const blockerActions = blockerGuidance.map((blocker) => continuationBlockerAction(input.project, input.next.change, input.next.nextPhase, blocker, dbFlag));
8
+ const hasDraftRunAction = blockerActions.some((action) => action.draftRunCommand !== undefined);
9
+ const relatedRunContext = relatedRunContextView(input.project, input.relatedRunContext, dbFlag);
10
+ return {
11
+ kind: 'sdd-continuation-plan',
12
+ project: input.project,
13
+ change: input.next.change,
14
+ status: input.next.status,
15
+ ...(nextPhase === undefined ? {} : { nextPhase }),
16
+ ...(input.cockpit.actionablePhase === undefined ? {} : { actionablePhase: input.cockpit.actionablePhase }),
17
+ recommendedAction: input.cockpit.recommendedAction,
18
+ reason: input.next.reason,
19
+ suggestedCommand,
20
+ inspectCommand,
21
+ blockerActions,
22
+ ...(relatedRunContext === undefined ? {} : { relatedRunContext }),
23
+ ...(input.explicitDatabasePath === undefined ? {} : { explicitDatabasePath: input.explicitDatabasePath }),
24
+ safety: [
25
+ 'Read-only planner: this command does not execute providers or mutate SDD artifacts, runs, provider config, or openspec/ files.',
26
+ 'Human acceptance remains explicit; artifact presence is not treated as acceptance.',
27
+ ...(hasDraftRunAction ? [draftRunWarning()] : []),
28
+ 'No openspec/ files are created or written.',
29
+ ],
30
+ };
31
+ }
32
+ function relatedRunContextView(project, relatedRunContext, dbFlag) {
33
+ if (relatedRunContext === undefined)
34
+ return undefined;
35
+ return {
36
+ ...relatedRunContext,
37
+ recommendation: 'Related interrupted run found by checkpoint changeId; inspect or resume it before starting duplicate SDD work. Advisory only.',
38
+ resumeCommand: `vgxness resume --project ${project} --run-id ${relatedRunContext.runId}${dbFlag}`,
39
+ };
40
+ }
41
+ function suggestedContinuationCommand(project, next, fallbackInspectCommand, dbFlag) {
42
+ if (next.status === 'runnable' && next.nextPhase !== undefined)
43
+ return runPhaseCommand(project, next.change, next.nextPhase, dbFlag);
44
+ return fallbackInspectCommand;
45
+ }
46
+ function continuationBlockerAction(project, change, blockedPhase, blocker, dbFlag) {
47
+ const draftRunCommand = blocker.reason === 'draft' && isDraftRunPlanningPhase(blockedPhase) ? draftRunPhaseCommand(project, change, blockedPhase, dbFlag) : undefined;
48
+ return {
49
+ phase: blocker.phase,
50
+ topicKey: blocker.topicKey,
51
+ reason: blocker.reason,
52
+ action: blocker.action,
53
+ command: blockerCommand(project, change, blocker, dbFlag),
54
+ ...(draftRunCommand === undefined ? {} : { draftRunCommand, warning: draftRunWarning() }),
55
+ ...(blocker.artifactId === undefined ? {} : { artifactId: blocker.artifactId }),
56
+ };
57
+ }
58
+ function blockerCommand(project, change, blocker, dbFlag) {
59
+ if (blocker.reason === 'missing')
60
+ return runPhaseCommand(project, change, blocker.phase, dbFlag);
61
+ if (blocker.reason === 'draft' || blocker.reason === 'legacy')
62
+ return `vgxness sdd accept-artifact --project ${project} --change ${change} --phase ${blocker.phase} --actor <human-id>${dbFlag}`;
63
+ if (blocker.reason === 'rejected')
64
+ return `vgxness sdd reopen-artifact --project ${project} --change ${change} --phase ${blocker.phase} --actor <human-id>${dbFlag}`;
65
+ return `vgxness sdd get-artifact --project ${project} --change ${change} --phase ${blocker.phase} --json${dbFlag}`;
66
+ }
67
+ function runPhaseCommand(project, change, phase, dbFlag) {
68
+ return `vgxness code sdd ${change} ${phase} --project ${project} --save-artifact${dbFlag}`;
69
+ }
70
+ function draftRunPhaseCommand(project, change, phase, dbFlag) {
71
+ return `vgxness code sdd ${change} ${phase} --project ${project} --draft-run --save-artifact${dbFlag}`;
72
+ }
73
+ function isDraftRunPlanningPhase(phase) {
74
+ return phase === 'explore' || phase === 'proposal' || phase === 'spec' || phase === 'design' || phase === 'tasks';
75
+ }
76
+ function draftRunWarning() {
77
+ return 'Draft-run is planning-only; human acceptance is still required; apply-progress remains gated.';
78
+ }
79
+ function continuationDbFlag(explicitDatabasePath) {
80
+ return explicitDatabasePath === undefined ? '' : ` --db ${explicitDatabasePath}`;
81
+ }
@@ -1,5 +1,5 @@
1
1
  import { summarizePayloadContent } from '../payload/payload-summary.js';
2
- import { isSddPhase, normalizeSddArtifact, sddPhases, sddPrerequisites, sddTopicKey, } from './schema.js';
2
+ import { normalizeSddArtifact, normalizeSddPhaseInput, sddPhases, sddPrerequisites, sddTopicKey, } from './schema.js';
3
3
  const defaultContext = { actor: 'sdd-workflow-service' };
4
4
  const validChangePattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
5
5
  function readAutoAdvanceFromEnv() {
@@ -103,7 +103,7 @@ export class SddWorkflowService {
103
103
  const artifacts = cockpitPhases.map((phase) => phase.artifact).filter((artifact) => artifact !== undefined);
104
104
  const aggregateBlockers = dedupeCockpitBlockers([
105
105
  ...cockpitPhases.flatMap((phase) => phase.blockers),
106
- ...next.missingArtifactTopicKeys.map((topicKey) => blockerFromTopicKey(validated.value.change, topicKey)),
106
+ ...(next.missingPrerequisiteTopicKeys ?? []).map((topicKey) => blockerFromTopicKey(validated.value.change, topicKey)),
107
107
  ...(next.blockedPrerequisites ?? []).map((blocker) => ({
108
108
  kind: 'readiness',
109
109
  phase: blocker.phase,
@@ -314,9 +314,10 @@ export class SddWorkflowService {
314
314
  const validated = validateProjectAndChange(input.project, input.change);
315
315
  if (!validated.ok)
316
316
  return validated;
317
- if (!isSddPhase(input.phase))
317
+ const phase = normalizeSddPhaseInput(input.phase);
318
+ if (phase === undefined)
318
319
  return validationFailure(`Unknown SDD phase: ${input.phase}`);
319
- return { ok: true, value: { ...validated.value, phase: input.phase } };
320
+ return { ok: true, value: { ...validated.value, phase } };
320
321
  }
321
322
  getPhaseStatuses(project, change) {
322
323
  const snapshot = this.loadPhaseSnapshot(project, change);
@@ -367,9 +368,11 @@ export function nextDecisionFromStatuses(change, phases, options = {}) {
367
368
  status: 'blocked',
368
369
  nextPhase: blockedPresentPhase.phase,
369
370
  missingArtifactTopicKeys: readiness.missingArtifactTopicKeys,
371
+ missingPrerequisiteTopicKeys: readiness.missingPrerequisiteTopicKeys ?? [],
370
372
  blockedPrerequisites: blockers,
373
+ blockerGuidance: readiness.blockerGuidance ?? [],
371
374
  reason: `${blockedPresentPhase.phase} is blocked by prerequisite artifacts that are not accepted: ${formatBlockers(blockers)}.`,
372
- recommendedAction: `Create, restore, or accept ${readiness.missingArtifactTopicKeys.join(', ')} before continuing.`,
375
+ recommendedAction: blockedRecommendedAction(readiness, 'continuing'),
373
376
  };
374
377
  }
375
378
  const nextMissingPhase = phases.find((status) => !status.present)?.phase;
@@ -381,6 +384,7 @@ export function nextDecisionFromStatuses(change, phases, options = {}) {
381
384
  status: 'blocked',
382
385
  nextPhase: firstUnacceptedPhase.phase,
383
386
  missingArtifactTopicKeys: [firstUnacceptedPhase.topicKey],
387
+ missingPrerequisiteTopicKeys: [],
384
388
  blockedPrerequisites: [
385
389
  {
386
390
  phase: firstUnacceptedPhase.phase,
@@ -389,8 +393,9 @@ export function nextDecisionFromStatuses(change, phases, options = {}) {
389
393
  ...(firstUnacceptedPhase.artifactId === undefined ? {} : { artifactId: firstUnacceptedPhase.artifactId }),
390
394
  },
391
395
  ],
396
+ blockerGuidance: [blockerGuidanceForPhaseStatus(firstUnacceptedPhase)],
392
397
  reason: `${firstUnacceptedPhase.phase} is present but ${firstUnacceptedPhase.state ?? 'draft'}; accepted governance status is required before the change is complete.`,
393
- recommendedAction: `Accept or replace ${firstUnacceptedPhase.topicKey} before archiving this SDD change.`,
398
+ recommendedAction: `${blockerActionForStatus(firstUnacceptedPhase)} before archiving this SDD change.`,
394
399
  };
395
400
  }
396
401
  return {
@@ -410,9 +415,11 @@ export function nextDecisionFromStatuses(change, phases, options = {}) {
410
415
  status: 'blocked',
411
416
  nextPhase: nextMissingPhase,
412
417
  missingArtifactTopicKeys: readiness.missingArtifactTopicKeys,
418
+ missingPrerequisiteTopicKeys: readiness.missingPrerequisiteTopicKeys ?? [],
413
419
  blockedPrerequisites: blockers,
420
+ blockerGuidance: readiness.blockerGuidance ?? [],
414
421
  reason: `${nextMissingPhase} is blocked by prerequisite artifacts that are not accepted: ${formatBlockers(blockers)}.`,
415
- recommendedAction: `Create, restore, or accept ${readiness.missingArtifactTopicKeys.join(', ')} before running ${nextMissingPhase}.`,
422
+ recommendedAction: blockedRecommendedAction(readiness, `running ${nextMissingPhase}`),
416
423
  };
417
424
  }
418
425
  return {
@@ -439,7 +446,9 @@ function statusFromPhases(change, phases, options = {}) {
439
446
  function getReadinessFromStatuses(change, phase, phases, options = {}) {
440
447
  const satisfiedPrerequisites = [];
441
448
  const missingArtifactTopicKeys = [];
449
+ const missingPrerequisiteTopicKeys = [];
442
450
  const blockedPrerequisites = [];
451
+ const blockerGuidance = [];
443
452
  const useAutoAdvance = options.autoAdvance === true;
444
453
  for (const prerequisite of sddPrerequisites[phase]) {
445
454
  const status = phases.find((candidate) => candidate.phase === prerequisite);
@@ -449,19 +458,26 @@ function getReadinessFromStatuses(change, phase, phases, options = {}) {
449
458
  continue;
450
459
  }
451
460
  const topicKey = status?.topicKey ?? sddTopicKey(change, prerequisite);
461
+ const reason = status === undefined ? 'missing' : blockerReasonForStatus(status);
452
462
  missingArtifactTopicKeys.push(topicKey);
453
- blockedPrerequisites.push({
463
+ if (reason === 'missing')
464
+ missingPrerequisiteTopicKeys.push(topicKey);
465
+ const blocker = {
454
466
  phase: prerequisite,
455
467
  topicKey,
456
- reason: status === undefined ? 'missing' : blockerReasonForStatus(status),
468
+ reason,
457
469
  ...(status?.artifactId === undefined ? {} : { artifactId: status.artifactId }),
458
- });
470
+ };
471
+ blockedPrerequisites.push(blocker);
472
+ blockerGuidance.push(blockerGuidanceForBlocker(blocker, phase));
459
473
  }
460
474
  return {
461
475
  ready: blockedPrerequisites.length === 0,
462
476
  satisfiedPrerequisites,
463
477
  missingArtifactTopicKeys,
478
+ missingPrerequisiteTopicKeys,
464
479
  blockedPrerequisites,
480
+ blockerGuidance,
465
481
  };
466
482
  }
467
483
  function compactArtifactProjection(artifact, change, phase, readiness) {
@@ -518,13 +534,20 @@ function compactGovernanceArtifact(artifact) {
518
534
  function cockpitBlockersForPhase(status, readiness) {
519
535
  const blockers = [];
520
536
  if (!status.present)
521
- blockers.push({ kind: 'missing-topic-key', phase: status.phase, topicKey: status.topicKey, reason: 'Canonical SDD artifact is missing.' });
537
+ blockers.push({
538
+ kind: 'missing-topic-key',
539
+ phase: status.phase,
540
+ topicKey: status.topicKey,
541
+ reason: 'Canonical SDD artifact is missing.',
542
+ action: blockerActionForBlocker({ phase: status.phase, topicKey: status.topicKey, reason: 'missing' }),
543
+ });
522
544
  if (status.present && status.accepted !== true)
523
545
  blockers.push({
524
546
  kind: 'unaccepted-phase',
525
547
  phase: status.phase,
526
548
  topicKey: status.topicKey,
527
549
  reason: `${status.phase} is ${status.state ?? 'draft'}; explicit human acceptance is required.`,
550
+ action: blockerActionForStatus(status),
528
551
  ...(status.artifactId === undefined ? {} : { artifactId: status.artifactId }),
529
552
  });
530
553
  if (status.legacy === true)
@@ -533,6 +556,7 @@ function cockpitBlockersForPhase(status, readiness) {
533
556
  phase: status.phase,
534
557
  topicKey: status.topicKey,
535
558
  reason: 'Artifact has legacy or missing governance metadata and is treated as not accepted.',
559
+ action: blockerActionForStatus(status),
536
560
  ...(status.artifactId === undefined ? {} : { artifactId: status.artifactId }),
537
561
  });
538
562
  for (const blocker of readiness.blockedPrerequisites ?? []) {
@@ -541,6 +565,7 @@ function cockpitBlockersForPhase(status, readiness) {
541
565
  phase: blocker.phase,
542
566
  topicKey: blocker.topicKey,
543
567
  reason: blocker.reason,
568
+ action: blockerActionForBlocker(blocker),
544
569
  ...(blocker.artifactId === undefined ? {} : { artifactId: blocker.artifactId }),
545
570
  });
546
571
  }
@@ -548,7 +573,13 @@ function cockpitBlockersForPhase(status, readiness) {
548
573
  }
549
574
  function blockerFromTopicKey(change, topicKey) {
550
575
  const phase = phaseFromTopicKey(change, topicKey);
551
- return { kind: 'missing-topic-key', phase, topicKey, reason: 'Required SDD topic key is missing or not accepted.' };
576
+ return {
577
+ kind: 'missing-topic-key',
578
+ phase,
579
+ topicKey,
580
+ reason: 'Required SDD topic key is missing or not accepted.',
581
+ action: blockerActionForBlocker({ phase, topicKey, reason: 'missing' }),
582
+ };
552
583
  }
553
584
  function dedupeCockpitBlockers(blockers) {
554
585
  const seen = new Set();
@@ -567,16 +598,72 @@ function cockpitRecommendedAction(next, blockers) {
567
598
  return next.recommendedAction;
568
599
  const legacy = blockers.find((blocker) => blocker.kind === 'legacy-artifact');
569
600
  if (legacy !== undefined)
570
- return `Review and explicitly accept or replace legacy artifact ${legacy.topicKey}.`;
601
+ return legacy.action ?? `Inspect and explicitly accept or replace legacy artifact ${legacy.topicKey}.`;
571
602
  const unaccepted = blockers.find((blocker) => blocker.kind === 'unaccepted-phase');
572
603
  if (unaccepted !== undefined)
573
- return `Accept or replace ${unaccepted.topicKey} before continuing.`;
604
+ return unaccepted.action ?? `Accept or replace ${unaccepted.topicKey} before continuing.`;
574
605
  return next.recommendedAction;
575
606
  }
607
+ function blockedRecommendedAction(readiness, context) {
608
+ const firstGuidance = readiness.blockerGuidance?.[0];
609
+ if (firstGuidance !== undefined)
610
+ return `${firstGuidance.action} before ${context}.`;
611
+ const firstBlocker = readiness.blockedPrerequisites?.[0];
612
+ if (firstBlocker !== undefined)
613
+ return `${blockerActionForBlocker(firstBlocker)} before ${context}.`;
614
+ return `Resolve prerequisite blockers before ${context}.`;
615
+ }
616
+ function blockerGuidanceForPhaseStatus(status) {
617
+ return blockerGuidanceForBlocker({
618
+ phase: status.phase,
619
+ topicKey: status.topicKey,
620
+ reason: blockerReasonForStatus(status),
621
+ ...(status.artifactId === undefined ? {} : { artifactId: status.artifactId }),
622
+ });
623
+ }
624
+ function blockerGuidanceForBlocker(blocker, blockedPhase) {
625
+ return {
626
+ phase: blocker.phase,
627
+ topicKey: blocker.topicKey,
628
+ reason: blocker.reason,
629
+ action: blockerActionForBlocker(blocker, blockedPhase),
630
+ ...(blocker.artifactId === undefined ? {} : { artifactId: blocker.artifactId }),
631
+ };
632
+ }
633
+ function blockerActionForStatus(status) {
634
+ return blockerActionForBlocker({
635
+ phase: status.phase,
636
+ topicKey: status.topicKey,
637
+ reason: blockerReasonForStatus(status),
638
+ ...(status.artifactId === undefined ? {} : { artifactId: status.artifactId }),
639
+ });
640
+ }
641
+ function blockerActionForBlocker(blocker, blockedPhase) {
642
+ switch (blocker.reason) {
643
+ case 'missing':
644
+ return `Run or create the ${blocker.phase} SDD phase artifact at ${blocker.topicKey}`;
645
+ case 'draft':
646
+ if (blockedPhase !== undefined && isDraftRunPlanningPhase(blockedPhase))
647
+ return `Accept ${blocker.topicKey} if it is ready, or intentionally continue planning ${blockedPhase} with --draft-run; draft-run is planning-only, human acceptance is still required, and apply-progress remains gated`;
648
+ return `Accept ${blocker.topicKey}, or continue and re-run the ${blocker.phase} phase if the draft is incomplete`;
649
+ case 'accepted':
650
+ return `Inspect ${blocker.topicKey}; it is marked accepted but still appears in blocker data`;
651
+ case 'legacy':
652
+ return `Inspect ${blocker.topicKey} and explicitly accept or replace the legacy artifact`;
653
+ case 'rejected':
654
+ return `Reopen ${blocker.topicKey} for revision, or replace it with a new accepted artifact`;
655
+ case 'superseded':
656
+ return `Use the current replacement for ${blocker.topicKey}, or create and accept a new canonical ${blocker.phase} artifact`;
657
+ }
658
+ }
659
+ function isDraftRunPlanningPhase(phase) {
660
+ return phase === 'explore' || phase === 'proposal' || phase === 'spec' || phase === 'design' || phase === 'tasks';
661
+ }
576
662
  function phaseFromTopicKey(change, topicKey) {
577
663
  const suffix = topicKey.slice(`sdd/${change}/`.length);
578
- if (isSddPhase(suffix))
579
- return suffix;
664
+ const phase = normalizeSddPhaseInput(suffix);
665
+ if (phase !== undefined)
666
+ return phase;
580
667
  return 'explore';
581
668
  }
582
669
  function blockerReasonForStatus(status) {