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.
- package/README.md +9 -4
- package/dist/agents/agent-resolver.js +33 -3
- package/dist/agents/canonical-agent-manifest.js +39 -18
- package/dist/agents/canonical-agent-projection.js +31 -4
- package/dist/cli/cli-help.js +14 -3
- package/dist/cli/commands/index.js +1 -0
- package/dist/cli/commands/interactive-entrypoint-dispatcher.js +8 -0
- package/dist/cli/commands/memory-sdd-dispatcher.js +71 -5
- package/dist/cli/commands/status-dispatcher.js +130 -0
- package/dist/cli/commands/workflow-dispatcher.js +11 -5
- package/dist/cli/dispatcher.js +9 -1
- package/dist/cli/product-resume-renderer.js +32 -0
- package/dist/cli/product-status-renderer.js +74 -0
- package/dist/cli/sdd-renderer.js +80 -3
- package/dist/code/cli/code-command.js +7 -4
- package/dist/code/reporting/summary.js +4 -1
- package/dist/code/runtime/code-runtime.js +27 -4
- package/dist/code/runtime/sdd-context.js +18 -2
- package/dist/governance/governance-report-builder.js +18 -7
- package/dist/mcp/claude-code-agent-config.js +10 -4
- package/dist/mcp/control-plane.js +56 -0
- package/dist/mcp/provider-status.js +86 -81
- package/dist/mcp/schema.js +25 -7
- package/dist/mcp/stdio-server.js +4 -0
- package/dist/mcp/validation.js +39 -3
- package/dist/resume/product-resume.js +166 -0
- package/dist/runs/repositories/runs.js +12 -1
- package/dist/runs/run-service.js +62 -5
- package/dist/sdd/schema.js +8 -0
- package/dist/sdd/sdd-continuation-plan.js +81 -0
- package/dist/sdd/sdd-workflow-service.js +103 -16
- package/dist/skills/skill-resolver.js +21 -4
- package/dist/status/product-status.js +117 -0
- package/docs/architecture.md +1 -1
- package/docs/cli.md +33 -8
- package/docs/code-runtime.md +3 -0
- package/docs/glossary.md +1 -1
- package/docs/mcp.md +18 -4
- 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(
|
|
90
|
+
.all(queryParameters);
|
|
80
91
|
return ok(rows.map(mapRun));
|
|
81
92
|
}
|
|
82
93
|
catch (cause) {
|
package/dist/runs/run-service.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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 };
|
package/dist/sdd/schema.js
CHANGED
|
@@ -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 {
|
|
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.
|
|
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
463
|
+
if (reason === 'missing')
|
|
464
|
+
missingPrerequisiteTopicKeys.push(topicKey);
|
|
465
|
+
const blocker = {
|
|
454
466
|
phase: prerequisite,
|
|
455
467
|
topicKey,
|
|
456
|
-
reason
|
|
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({
|
|
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 {
|
|
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 `
|
|
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
|
-
|
|
579
|
-
|
|
664
|
+
const phase = normalizeSddPhaseInput(suffix);
|
|
665
|
+
if (phase !== undefined)
|
|
666
|
+
return phase;
|
|
580
667
|
return 'explore';
|
|
581
668
|
}
|
|
582
669
|
function blockerReasonForStatus(status) {
|