vgxness 1.15.0 → 1.17.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.
- package/README.md +2 -2
- package/dist/agents/canonical-agent-manifest.js +34 -25
- package/dist/cli/cli-help.js +1 -0
- package/dist/cli/home-tui-app.js +115 -15
- package/dist/cli/home-tui-controller.js +100 -3
- package/dist/cli/sdd-renderer.js +7 -0
- package/dist/mcp/control-plane.js +10 -1
- package/dist/mcp/schema.js +17 -0
- package/dist/mcp/stdio-server.js +1 -1
- package/dist/runs/execution-planning.js +67 -0
- package/dist/runs/run-service.js +244 -7
- package/dist/runs/sandbox-process-execution.js +7 -1
- package/dist/sdd/cockpit-read-model.js +6 -5
- package/dist/sdd/sdd-continuation-plan.js +46 -0
- package/dist/workflows/command-allowlist-adapter.js +100 -0
- package/dist/workflows/workflow-executor.js +7 -1
- package/dist/workspace-strategy/diagnostics.js +34 -0
- package/dist/workspace-strategy/index.js +3 -0
- package/dist/workspace-strategy/recommendation-policy.js +196 -0
- package/dist/workspace-strategy/schema.js +1 -0
- package/docs/architecture.md +14 -3
- package/docs/cli.md +21 -1
- package/docs/glossary.md +6 -2
- package/docs/mcp.md +3 -1
- package/docs/project-health-audit-v1.10.x.md +3 -3
- package/docs/project-health-audit-v1.14.x.md +2 -0
- package/docs/project-health-audit-v1.9.1.md +1 -1
- package/docs/providers.md +1 -1
- package/docs/roadmap.md +3 -3
- package/package.json +1 -1
- package/seeds/skills/skill-seed-v1.json +4 -4
package/dist/mcp/stdio-server.js
CHANGED
|
@@ -84,7 +84,7 @@ function descriptionForTool(publicToolName) {
|
|
|
84
84
|
if (publicToolName === 'sdd_reopen_artifact')
|
|
85
85
|
return 'Human-only mutating SDD reopen gate; records an explicit human decision to reopen an artifact and must not be called by agents on their own behalf. Use only after human instruction, then revise or re-review the phase.';
|
|
86
86
|
if (publicToolName === 'run_preflight')
|
|
87
|
-
return 'Agent-callable advisory/planning-only run preflight; evaluates permissions
|
|
87
|
+
return 'Agent-callable advisory/planning-only run preflight; evaluates permissions, records a plan, and returns a workspace strategy recommendation, but does not execute the checked shell/git/install/network/provider/secrets operation and does not create branches, worktrees, or pull requests. Use it to decide whether human approval or a separate executor step is required.';
|
|
88
88
|
const toolName = toInternalVgxMcpToolName(publicToolName);
|
|
89
89
|
return `VGX control-plane tool ${toolName}`;
|
|
90
90
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { recommendWorkspaceStrategy } from '../workspace-strategy/index.js';
|
|
3
4
|
import { planWorktreeSandbox } from './sandbox-worktree-planning.js';
|
|
4
5
|
const filesystemCategories = new Set(['read', 'edit', 'external-directory']);
|
|
5
6
|
export function planExecutionIsolation(input) {
|
|
@@ -23,6 +24,27 @@ export function planExecutionIsolation(input) {
|
|
|
23
24
|
createsWorktree: false,
|
|
24
25
|
};
|
|
25
26
|
}
|
|
27
|
+
export function recommendWorkspaceStrategyForExecution(input) {
|
|
28
|
+
return recommendWorkspaceStrategy(workspaceStrategyInputForExecution(input));
|
|
29
|
+
}
|
|
30
|
+
export function workspaceStrategyInputForExecution(input) {
|
|
31
|
+
const categories = workspaceStrategyCategories(input.operation, input.decision);
|
|
32
|
+
const mutationSignal = hasWorkspaceMutationSignal(input.operation, categories);
|
|
33
|
+
return {
|
|
34
|
+
categories,
|
|
35
|
+
intentSignals: workspaceStrategyIntentSignals(input.operation, input.decision),
|
|
36
|
+
risk: workspaceStrategyRisk(input.decision),
|
|
37
|
+
readOnly: isReadOnlyWorkspaceOperation(input.operation, input.decision),
|
|
38
|
+
expectedWrites: mutationSignal,
|
|
39
|
+
mutatesRepository: mutationSignal,
|
|
40
|
+
destructive: input.operation.destructive === true,
|
|
41
|
+
external: input.operation.external === true || input.operation.category === 'external-directory',
|
|
42
|
+
privileged: input.operation.privileged === true,
|
|
43
|
+
ambiguous: input.operation.ambiguous === true,
|
|
44
|
+
changeSize: workspaceStrategyChangeSize(input.decision),
|
|
45
|
+
...(mutationSignal ? { workspace: { checkout: 'unknown' } } : {}),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
26
48
|
function selectStrategy(operation, decision) {
|
|
27
49
|
if (operation.sandboxStrategy === 'worktree')
|
|
28
50
|
return 'worktree';
|
|
@@ -109,3 +131,48 @@ const defaultGitBoundaryInspector = ({ workspaceRoot }) => {
|
|
|
109
131
|
}
|
|
110
132
|
return { status: 'compatible', reason: 'workspace has no git metadata to conflict with a planned worktree boundary' };
|
|
111
133
|
};
|
|
134
|
+
function workspaceStrategyCategories(operation, decision) {
|
|
135
|
+
const categories = [operation.category];
|
|
136
|
+
if (decision.riskReasonCodes?.includes('git_write') === true && !categories.includes('git-write'))
|
|
137
|
+
categories.push('git-write');
|
|
138
|
+
return categories;
|
|
139
|
+
}
|
|
140
|
+
function workspaceStrategyIntentSignals(operation, decision) {
|
|
141
|
+
return [
|
|
142
|
+
`permission:${decision.reason}`,
|
|
143
|
+
...(decision.riskReasonCodes ?? []).map((code) => `risk:${code}`),
|
|
144
|
+
...(operation.workflow === undefined ? [] : [`workflow:${operation.workflow}`]),
|
|
145
|
+
...(operation.phase === undefined ? [] : [`phase:${operation.phase}`]),
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
function workspaceStrategyRisk(decision) {
|
|
149
|
+
if (decision.riskTier === 0)
|
|
150
|
+
return 'none';
|
|
151
|
+
if (decision.riskTier === 1)
|
|
152
|
+
return 'low';
|
|
153
|
+
if (decision.riskTier === 2)
|
|
154
|
+
return 'medium';
|
|
155
|
+
if (decision.riskTier === 3)
|
|
156
|
+
return 'high';
|
|
157
|
+
if (decision.riskTier === 4)
|
|
158
|
+
return 'critical';
|
|
159
|
+
return 'unknown';
|
|
160
|
+
}
|
|
161
|
+
function workspaceStrategyChangeSize(decision) {
|
|
162
|
+
if (decision.riskReasonCodes?.some((code) => code === 'architecture_change' || code === 'cross_surface_change' || code === 'governance_change') === true)
|
|
163
|
+
return 'large';
|
|
164
|
+
if (decision.riskTier === 2)
|
|
165
|
+
return 'medium';
|
|
166
|
+
if (decision.riskTier === 1)
|
|
167
|
+
return 'small';
|
|
168
|
+
return 'unknown';
|
|
169
|
+
}
|
|
170
|
+
function isReadOnlyWorkspaceOperation(operation, decision) {
|
|
171
|
+
return decision.riskTier === 0 || operation.category === 'read' || (operation.category === 'git' && decision.riskReasonCodes?.includes('git_write') !== true);
|
|
172
|
+
}
|
|
173
|
+
function hasWorkspaceMutationSignal(operation, categories) {
|
|
174
|
+
if (operation.destructive === true || operation.privileged === true || operation.external === true || operation.ambiguous === true)
|
|
175
|
+
return true;
|
|
176
|
+
return categories.some((category) => workspaceMutationCategories.has(category));
|
|
177
|
+
}
|
|
178
|
+
const workspaceMutationCategories = new Set(['edit', 'implementation-edit', 'spec-write', 'design-write', 'task-write', 'git-write']);
|
package/dist/runs/run-service.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { evaluatePermission } from '../permissions/policy-evaluator.js';
|
|
2
3
|
import { isRiskyPermissionCategory } from '../permissions/schema.js';
|
|
3
4
|
import { normalizeSddPhaseInput } from '../sdd/schema.js';
|
|
4
|
-
import { planExecutionIsolation } from './execution-planning.js';
|
|
5
|
+
import { planExecutionIsolation, recommendWorkspaceStrategyForExecution } from './execution-planning.js';
|
|
5
6
|
import { evaluateOperationRetry as evaluateOperationRetryPolicy } from './operation-retry.js';
|
|
7
|
+
import { AllowlistedApplyProgressCommandExecutor } from '../workflows/command-allowlist-adapter.js';
|
|
6
8
|
import { RunRepository, } from './repositories/runs.js';
|
|
7
9
|
import { buildRunInsights, buildRunOperatorResumePlan } from './run-insights.js';
|
|
8
10
|
const preflightSafety = {
|
|
@@ -45,6 +47,51 @@ export class RunService {
|
|
|
45
47
|
listRuns(filters = {}) {
|
|
46
48
|
return this.runs.list(filters);
|
|
47
49
|
}
|
|
50
|
+
getSddOperationalEvidence(input) {
|
|
51
|
+
const runs = this.runs.list({ project: input.project, limit: input.limit ?? 50 });
|
|
52
|
+
if (!runs.ok)
|
|
53
|
+
return runs;
|
|
54
|
+
const evidenceByPhase = {};
|
|
55
|
+
for (const run of runs.value) {
|
|
56
|
+
if (!isSddWorkflow(run.workflow))
|
|
57
|
+
continue;
|
|
58
|
+
const phase = normalizeSddPhaseInput(run.phase);
|
|
59
|
+
if (phase === undefined || evidenceByPhase[phase] !== undefined)
|
|
60
|
+
continue;
|
|
61
|
+
const details = this.runs.getDetails(run.id);
|
|
62
|
+
if (!details.ok)
|
|
63
|
+
return details;
|
|
64
|
+
const matchingCheckpoints = details.value.checkpoints.filter((checkpoint) => checkpointHasChangeId(checkpoint.state, input.change));
|
|
65
|
+
if (matchingCheckpoints.length === 0)
|
|
66
|
+
continue;
|
|
67
|
+
const latestMatchingCheckpoint = matchingCheckpoints.at(-1);
|
|
68
|
+
evidenceByPhase[phase] = {
|
|
69
|
+
latestRun: {
|
|
70
|
+
id: details.value.id,
|
|
71
|
+
status: details.value.status,
|
|
72
|
+
workflow: details.value.workflow,
|
|
73
|
+
phase: details.value.phase,
|
|
74
|
+
...(details.value.outcome === undefined ? {} : { outcome: details.value.outcome }),
|
|
75
|
+
createdAt: details.value.createdAt,
|
|
76
|
+
updatedAt: details.value.updatedAt,
|
|
77
|
+
...(details.value.completedAt === undefined ? {} : { completedAt: details.value.completedAt }),
|
|
78
|
+
},
|
|
79
|
+
approvalCounts: countByStatus(details.value.approvals.map((approval) => approval.status)),
|
|
80
|
+
attemptCounts: countByStatus(details.value.operationAttempts.map((attempt) => attempt.status)),
|
|
81
|
+
...(latestMatchingCheckpoint === undefined
|
|
82
|
+
? {}
|
|
83
|
+
: {
|
|
84
|
+
latestCheckpoint: {
|
|
85
|
+
id: latestMatchingCheckpoint.id,
|
|
86
|
+
label: latestMatchingCheckpoint.label,
|
|
87
|
+
sequence: latestMatchingCheckpoint.sequence,
|
|
88
|
+
createdAt: latestMatchingCheckpoint.createdAt,
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return { ok: true, value: evidenceByPhase };
|
|
94
|
+
}
|
|
48
95
|
listRecentInterruptedRuns(input) {
|
|
49
96
|
const runs = this.runs.list({ project: input.project, statuses: ['failed', 'blocked', 'needs-human'], limit: input.limit ?? 5 });
|
|
50
97
|
if (!runs.ok)
|
|
@@ -234,6 +281,7 @@ export class RunService {
|
|
|
234
281
|
const operation = operationFromPendingExecution(pendingExecutionEvent.payload);
|
|
235
282
|
if (operation === undefined)
|
|
236
283
|
return validationFailure('Pending operation metadata is incomplete and cannot be retried');
|
|
284
|
+
const replayGuard = replayGuardForApprovedOperation(approval.value, permissionEvent, pendingExecutionEvent, operation);
|
|
237
285
|
const attempts = details.value.operationAttempts.filter((attempt) => attempt.approvalId === approval.value.id);
|
|
238
286
|
const retryDecision = evaluateOperationRetryPolicy(input.policy === undefined ? { attempts } : { attempts, policy: input.policy });
|
|
239
287
|
if (!retryDecision.allowed || retryDecision.reasonCode !== 'status_allowed_by_policy')
|
|
@@ -245,7 +293,7 @@ export class RunService {
|
|
|
245
293
|
pendingExecutionEventId: pendingExecutionEvent.id,
|
|
246
294
|
category: operation.category,
|
|
247
295
|
operation: operation.operation,
|
|
248
|
-
operationMetadata:
|
|
296
|
+
operationMetadata: operationMetadataWithReplayGuard(operation, replayGuard),
|
|
249
297
|
executorName: input.executorName ?? 'retry-admission',
|
|
250
298
|
});
|
|
251
299
|
if (!reserved.ok)
|
|
@@ -267,6 +315,8 @@ export class RunService {
|
|
|
267
315
|
retryReason: retryDecision.reason,
|
|
268
316
|
evaluatedAttemptCount: retryDecision.evaluatedAttemptCount,
|
|
269
317
|
retryableStatuses: [...retryDecision.retryableStatuses],
|
|
318
|
+
replayGuard,
|
|
319
|
+
operationFingerprint: replayGuard.operationFingerprint,
|
|
270
320
|
executorInvoked: false,
|
|
271
321
|
operationExecuted: false,
|
|
272
322
|
}),
|
|
@@ -292,6 +342,11 @@ export class RunService {
|
|
|
292
342
|
decision: permission.value.decision,
|
|
293
343
|
...(resolved.value.gitBoundaryInspector === undefined ? {} : { gitBoundaryInspector: resolved.value.gitBoundaryInspector }),
|
|
294
344
|
});
|
|
345
|
+
const workspaceStrategy = recommendWorkspaceStrategyForExecution({
|
|
346
|
+
operation: resolved.value,
|
|
347
|
+
decision: permission.value.decision,
|
|
348
|
+
...(resolved.value.gitBoundaryInspector === undefined ? {} : { gitBoundaryInspector: resolved.value.gitBoundaryInspector }),
|
|
349
|
+
});
|
|
295
350
|
const sandboxDecision = sandboxDecisionSummary(plan.sandbox);
|
|
296
351
|
const planEvent = this.runs.appendEvent({
|
|
297
352
|
runId: resolved.value.runId,
|
|
@@ -303,6 +358,7 @@ export class RunService {
|
|
|
303
358
|
decisionEventId: permission.value.event.id,
|
|
304
359
|
approvalId: permission.value.approval?.id ?? null,
|
|
305
360
|
plan: plan,
|
|
361
|
+
workspaceStrategy: workspaceStrategy,
|
|
306
362
|
...(sandboxDecision === undefined ? {} : { sandboxDecision: sandboxDecision }),
|
|
307
363
|
operationExecuted: false,
|
|
308
364
|
executorInvoked: false,
|
|
@@ -317,7 +373,7 @@ export class RunService {
|
|
|
317
373
|
});
|
|
318
374
|
if (!planEvent.ok)
|
|
319
375
|
return { ok: false, error: planEvent.error };
|
|
320
|
-
return { ok: true, value: { ...permission.value, plan, planEvent: planEvent.value } };
|
|
376
|
+
return { ok: true, value: { ...permission.value, plan, workspaceStrategy, planEvent: planEvent.value } };
|
|
321
377
|
}
|
|
322
378
|
preflightOperation(input) {
|
|
323
379
|
const resolved = this.buildPermissionContextForRun(input);
|
|
@@ -329,7 +385,7 @@ export class RunService {
|
|
|
329
385
|
const planned = this.planOperationExecution(resolved.value);
|
|
330
386
|
if (!planned.ok)
|
|
331
387
|
return planned;
|
|
332
|
-
const { decision, event, approval, plan, planEvent } = planned.value;
|
|
388
|
+
const { decision, event, approval, plan, workspaceStrategy, planEvent } = planned.value;
|
|
333
389
|
const audit = { permissionEventId: event.id, planEventId: planEvent.id };
|
|
334
390
|
if (approval !== undefined)
|
|
335
391
|
audit.approvalId = approval.id;
|
|
@@ -351,6 +407,7 @@ export class RunService {
|
|
|
351
407
|
permissionEvent: event,
|
|
352
408
|
...(approval === undefined ? {} : { approval }),
|
|
353
409
|
plan,
|
|
410
|
+
workspaceStrategy,
|
|
354
411
|
planEvent,
|
|
355
412
|
audit,
|
|
356
413
|
eligibleForFutureExecution: plan.executable && !sandboxRejected,
|
|
@@ -378,6 +435,10 @@ export class RunService {
|
|
|
378
435
|
const operation = operationFromPendingExecution(pendingExecutionEvent.payload);
|
|
379
436
|
if (operation === undefined)
|
|
380
437
|
return validationFailure('Pending operation metadata is incomplete and cannot be resumed');
|
|
438
|
+
const replayGuard = replayGuardForApprovedOperation(approval.value, permissionEvent, pendingExecutionEvent, operation);
|
|
439
|
+
const replayValidation = validateReplayGuard(pendingExecutionEvent, replayGuard, input.expectedOperationFingerprint);
|
|
440
|
+
if (!replayValidation.ok)
|
|
441
|
+
return replayValidation;
|
|
381
442
|
const retry = this.evaluateOperationRetry({ approvalId: approval.value.id });
|
|
382
443
|
if (!retry.ok)
|
|
383
444
|
return retry;
|
|
@@ -390,7 +451,7 @@ export class RunService {
|
|
|
390
451
|
pendingExecutionEventId: pendingExecutionEvent.id,
|
|
391
452
|
category: operation.category,
|
|
392
453
|
operation: operation.operation,
|
|
393
|
-
operationMetadata:
|
|
454
|
+
operationMetadata: operationMetadataWithReplayGuard(operation, replayGuard),
|
|
394
455
|
executorName: input.executor.name,
|
|
395
456
|
});
|
|
396
457
|
if (!reserved.ok)
|
|
@@ -413,6 +474,8 @@ export class RunService {
|
|
|
413
474
|
originalDecision: 'ask',
|
|
414
475
|
approvalStatus: 'approved',
|
|
415
476
|
policyReevaluated: false,
|
|
477
|
+
replayGuard,
|
|
478
|
+
operationFingerprint: replayGuard.operationFingerprint,
|
|
416
479
|
resumedFromApprovalId: approval.value.id,
|
|
417
480
|
executorInvoked: true,
|
|
418
481
|
error,
|
|
@@ -423,6 +486,9 @@ export class RunService {
|
|
|
423
486
|
if (!executionEvent.ok)
|
|
424
487
|
return { ok: false, error: executionEvent.error };
|
|
425
488
|
const linkedAttempt = this.runs.attachAttemptResultEvent(attempt.value.id, executionEvent.value.id);
|
|
489
|
+
const evidence = appendApplyProgressAllowlistEvidenceIfNeeded(this.runs, approval.value.id, permissionEvent, pendingExecutionEvent, linkedAttempt.ok ? linkedAttempt.value : attempt.value, executionEvent.value, replayGuard.operationFingerprint, input.executor.name, operation, undefined, error);
|
|
490
|
+
if (!evidence.ok)
|
|
491
|
+
return { ok: false, error: evidence.error };
|
|
426
492
|
return {
|
|
427
493
|
ok: true,
|
|
428
494
|
value: {
|
|
@@ -432,6 +498,7 @@ export class RunService {
|
|
|
432
498
|
attempt: linkedAttempt.ok ? linkedAttempt.value : attempt.value,
|
|
433
499
|
executionEvent: executionEvent.value,
|
|
434
500
|
status: 'failed',
|
|
501
|
+
...(evidence.value === undefined ? {} : evidence.value),
|
|
435
502
|
},
|
|
436
503
|
};
|
|
437
504
|
}
|
|
@@ -451,6 +518,8 @@ export class RunService {
|
|
|
451
518
|
originalDecision: 'ask',
|
|
452
519
|
approvalStatus: 'approved',
|
|
453
520
|
policyReevaluated: false,
|
|
521
|
+
replayGuard,
|
|
522
|
+
operationFingerprint: replayGuard.operationFingerprint,
|
|
454
523
|
resumedFromApprovalId: approval.value.id,
|
|
455
524
|
executorInvoked: true,
|
|
456
525
|
output: output ?? null,
|
|
@@ -461,6 +530,9 @@ export class RunService {
|
|
|
461
530
|
if (!executionEvent.ok)
|
|
462
531
|
return { ok: false, error: executionEvent.error };
|
|
463
532
|
const linkedAttempt = this.runs.attachAttemptResultEvent(attempt.value.id, executionEvent.value.id);
|
|
533
|
+
const evidence = appendApplyProgressAllowlistEvidenceIfNeeded(this.runs, approval.value.id, permissionEvent, pendingExecutionEvent, linkedAttempt.ok ? linkedAttempt.value : attempt.value, executionEvent.value, replayGuard.operationFingerprint, input.executor.name, operation, output, undefined);
|
|
534
|
+
if (!evidence.ok)
|
|
535
|
+
return { ok: false, error: evidence.error };
|
|
464
536
|
return {
|
|
465
537
|
ok: true,
|
|
466
538
|
value: {
|
|
@@ -471,9 +543,17 @@ export class RunService {
|
|
|
471
543
|
executionEvent: executionEvent.value,
|
|
472
544
|
status: 'succeeded',
|
|
473
545
|
...(output === undefined ? {} : { output }),
|
|
546
|
+
...(evidence.value === undefined ? {} : evidence.value),
|
|
474
547
|
},
|
|
475
548
|
};
|
|
476
549
|
}
|
|
550
|
+
resumeApprovedAllowlistedApplyProgressCommand(input) {
|
|
551
|
+
return this.resumeApprovedOperation({
|
|
552
|
+
approvalId: input.approvalId,
|
|
553
|
+
executor: new AllowlistedApplyProgressCommandExecutor(input.executorOptions),
|
|
554
|
+
...(input.expectedOperationFingerprint === undefined ? {} : { expectedOperationFingerprint: input.expectedOperationFingerprint }),
|
|
555
|
+
});
|
|
556
|
+
}
|
|
477
557
|
executeOperation(input) {
|
|
478
558
|
const permission = this.evaluatePermissionForRun({ runId: input.runId, ...input.operation, reusePendingApproval: true });
|
|
479
559
|
if (!permission.ok)
|
|
@@ -534,6 +614,7 @@ export class RunService {
|
|
|
534
614
|
: { ok: false, error: executionEvent.error };
|
|
535
615
|
}
|
|
536
616
|
if (permission.value.decision.decision === 'ask') {
|
|
617
|
+
const replayGuard = permission.value.approval === undefined ? undefined : replayGuardForApprovedOperation(permission.value.approval, permission.value.event, undefined, input.operation);
|
|
537
618
|
const executionEvent = this.runs.appendEvent({
|
|
538
619
|
runId: input.runId,
|
|
539
620
|
kind: 'operation-execution',
|
|
@@ -544,6 +625,7 @@ export class RunService {
|
|
|
544
625
|
decision: 'ask',
|
|
545
626
|
reason: permission.value.decision.reason,
|
|
546
627
|
message: permission.value.decision.message,
|
|
628
|
+
...(replayGuard === undefined ? {} : { replayGuard, operationFingerprint: replayGuard.operationFingerprint }),
|
|
547
629
|
executorInvoked: false,
|
|
548
630
|
operationExecuted: false,
|
|
549
631
|
}),
|
|
@@ -848,8 +930,49 @@ export class RunService {
|
|
|
848
930
|
return { ok: true, value: resolved[0] };
|
|
849
931
|
}
|
|
850
932
|
}
|
|
933
|
+
export function calculateStableOperationFingerprint(input) {
|
|
934
|
+
const canonical = {
|
|
935
|
+
runId: input.runId,
|
|
936
|
+
approvalId: input.approvalId,
|
|
937
|
+
category: input.category,
|
|
938
|
+
operation: input.operation,
|
|
939
|
+
};
|
|
940
|
+
if (input.phase !== undefined)
|
|
941
|
+
canonical.phase = input.phase;
|
|
942
|
+
if (input.agentId !== undefined)
|
|
943
|
+
canonical.agentId = input.agentId;
|
|
944
|
+
if (input.workspaceRoot !== undefined)
|
|
945
|
+
canonical.workspaceRoot = input.workspaceRoot;
|
|
946
|
+
if (input.targetPath !== undefined)
|
|
947
|
+
canonical.targetPath = input.targetPath;
|
|
948
|
+
if (input.input !== undefined)
|
|
949
|
+
canonical.input = input.input;
|
|
950
|
+
return `sha256:${createHash('sha256').update(stableJson(canonical)).digest('hex')}`;
|
|
951
|
+
}
|
|
851
952
|
function checkpointHasChangeId(state, change) {
|
|
852
|
-
return typeof state === 'object' && state !== null && !Array.isArray(state) && state.changeId === change;
|
|
953
|
+
return typeof state === 'object' && state !== null && !Array.isArray(state) && (state.changeId === change || state.change === change);
|
|
954
|
+
}
|
|
955
|
+
function countByStatus(statuses) {
|
|
956
|
+
const counts = { total: statuses.length };
|
|
957
|
+
for (const status of statuses) {
|
|
958
|
+
if (status === 'pending')
|
|
959
|
+
counts.pending = (counts.pending ?? 0) + 1;
|
|
960
|
+
else if (status === 'approved')
|
|
961
|
+
counts.approved = (counts.approved ?? 0) + 1;
|
|
962
|
+
else if (status === 'rejected')
|
|
963
|
+
counts.rejected = (counts.rejected ?? 0) + 1;
|
|
964
|
+
else if (status === 'cancelled')
|
|
965
|
+
counts.cancelled = (counts.cancelled ?? 0) + 1;
|
|
966
|
+
else if (status === 'reserved')
|
|
967
|
+
counts.reserved = (counts.reserved ?? 0) + 1;
|
|
968
|
+
else if (status === 'succeeded')
|
|
969
|
+
counts.succeeded = (counts.succeeded ?? 0) + 1;
|
|
970
|
+
else if (status === 'failed')
|
|
971
|
+
counts.failed = (counts.failed ?? 0) + 1;
|
|
972
|
+
else if (status === 'abandoned')
|
|
973
|
+
counts.abandoned = (counts.abandoned ?? 0) + 1;
|
|
974
|
+
}
|
|
975
|
+
return counts;
|
|
853
976
|
}
|
|
854
977
|
function preflightOutcome(decision) {
|
|
855
978
|
if (decision.decision === 'allow')
|
|
@@ -879,6 +1002,54 @@ function workflowConflictDecision(input, runWorkflow) {
|
|
|
879
1002
|
auditEvidence: [`runWorkflow:${runWorkflow}`, `explicitWorkflow:${input.workflow}`],
|
|
880
1003
|
};
|
|
881
1004
|
}
|
|
1005
|
+
function appendApplyProgressAllowlistEvidenceIfNeeded(runs, approvalId, permissionEvent, pendingExecutionEvent, attempt, executionEvent, operationFingerprint, executorName, operation, output, error) {
|
|
1006
|
+
if (!isApplyProgressAllowlistedCommandOperation(operation))
|
|
1007
|
+
return { ok: true, value: undefined };
|
|
1008
|
+
const base = {
|
|
1009
|
+
kind: 'apply-progress-operation',
|
|
1010
|
+
operationKind: 'allowlisted-command',
|
|
1011
|
+
runId: attempt.runId,
|
|
1012
|
+
approvalId,
|
|
1013
|
+
attemptId: attempt.id,
|
|
1014
|
+
pendingExecutionEventId: pendingExecutionEvent.id,
|
|
1015
|
+
permissionEventId: permissionEvent.id,
|
|
1016
|
+
executionEventId: executionEvent.id,
|
|
1017
|
+
operationFingerprint,
|
|
1018
|
+
executor: executorName,
|
|
1019
|
+
operation: operationMetadata(operation),
|
|
1020
|
+
status: attempt.status,
|
|
1021
|
+
output: output ?? null,
|
|
1022
|
+
error: error ?? null,
|
|
1023
|
+
};
|
|
1024
|
+
const evidence = runs.appendEvent({
|
|
1025
|
+
runId: attempt.runId,
|
|
1026
|
+
kind: 'evidence',
|
|
1027
|
+
title: `Apply-progress allowlisted command ${attempt.status}: ${operation.operation}`,
|
|
1028
|
+
payload: base,
|
|
1029
|
+
relatedType: 'operation-attempt',
|
|
1030
|
+
relatedId: attempt.id,
|
|
1031
|
+
});
|
|
1032
|
+
if (!evidence.ok)
|
|
1033
|
+
return { ok: false, error: evidence.error };
|
|
1034
|
+
const checkpoint = runs.appendCheckpoint({
|
|
1035
|
+
runId: attempt.runId,
|
|
1036
|
+
label: 'apply-progress-allowlisted-command-executed',
|
|
1037
|
+
state: { ...base, evidenceEventId: evidence.value.id },
|
|
1038
|
+
});
|
|
1039
|
+
if (!checkpoint.ok)
|
|
1040
|
+
return { ok: false, error: checkpoint.error };
|
|
1041
|
+
return { ok: true, value: { checkpoint: checkpoint.value, evidenceEvent: evidence.value } };
|
|
1042
|
+
}
|
|
1043
|
+
function isApplyProgressAllowlistedCommandOperation(operation) {
|
|
1044
|
+
if (operation.input === undefined)
|
|
1045
|
+
return false;
|
|
1046
|
+
if (!isObject(operation.input))
|
|
1047
|
+
return false;
|
|
1048
|
+
return (operation.phase === 'apply-progress' &&
|
|
1049
|
+
operation.input.kind === 'apply-progress-operation' &&
|
|
1050
|
+
operation.input.operationKind === 'allowlisted-command' &&
|
|
1051
|
+
/^command-allowlist:[A-Za-z0-9._/-]+$/u.test(operation.operation));
|
|
1052
|
+
}
|
|
882
1053
|
function executionPayload(operation, executorName, status, extra) {
|
|
883
1054
|
return {
|
|
884
1055
|
status,
|
|
@@ -889,6 +1060,65 @@ function executionPayload(operation, executorName, status, extra) {
|
|
|
889
1060
|
...extra,
|
|
890
1061
|
};
|
|
891
1062
|
}
|
|
1063
|
+
function operationMetadataWithReplayGuard(operation, replayGuard) {
|
|
1064
|
+
const metadata = operationMetadata(operation);
|
|
1065
|
+
if (!isObject(metadata))
|
|
1066
|
+
return metadata;
|
|
1067
|
+
return { ...metadata, replayGuard };
|
|
1068
|
+
}
|
|
1069
|
+
function replayGuardForApprovedOperation(approval, permissionEvent, pendingExecutionEvent, operation) {
|
|
1070
|
+
const fingerprintInput = fingerprintInputForApprovedOperation(approval, permissionEvent, operation);
|
|
1071
|
+
const operationFingerprint = calculateStableOperationFingerprint(fingerprintInput);
|
|
1072
|
+
return {
|
|
1073
|
+
version: 1,
|
|
1074
|
+
operationFingerprint,
|
|
1075
|
+
fingerprintAlgorithm: 'sha256:stable-json:v1',
|
|
1076
|
+
expectedPendingExecutionEventId: pendingExecutionEvent?.id ?? null,
|
|
1077
|
+
associated: fingerprintInput,
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
function fingerprintInputForApprovedOperation(approval, permissionEvent, operation) {
|
|
1081
|
+
const permissionPayload = isObject(permissionEvent.payload) ? permissionEvent.payload : {};
|
|
1082
|
+
const requestedOperation = permissionPayload.requestedOperation !== undefined && isObject(permissionPayload.requestedOperation) ? permissionPayload.requestedOperation : {};
|
|
1083
|
+
const agent = permissionPayload.agent !== undefined && isObject(permissionPayload.agent) ? permissionPayload.agent : undefined;
|
|
1084
|
+
const phase = stringField(operation.phase) ?? stringField(requestedOperation.phase) ?? stringField(permissionPayload.phase);
|
|
1085
|
+
const agentId = stringField(operation.agentId) ?? stringField(requestedOperation.agentId) ?? stringField(agent?.id);
|
|
1086
|
+
const workspaceRoot = stringField(operation.workspaceRoot) ?? stringField(requestedOperation.workspaceRoot);
|
|
1087
|
+
const targetPath = stringField(operation.targetPath) ?? stringField(requestedOperation.targetPath);
|
|
1088
|
+
const fingerprintInput = {
|
|
1089
|
+
runId: approval.runId,
|
|
1090
|
+
approvalId: approval.id,
|
|
1091
|
+
category: operation.category,
|
|
1092
|
+
operation: operation.operation,
|
|
1093
|
+
};
|
|
1094
|
+
if (phase !== undefined)
|
|
1095
|
+
fingerprintInput.phase = phase;
|
|
1096
|
+
if (agentId !== undefined)
|
|
1097
|
+
fingerprintInput.agentId = agentId;
|
|
1098
|
+
if (workspaceRoot !== undefined)
|
|
1099
|
+
fingerprintInput.workspaceRoot = workspaceRoot;
|
|
1100
|
+
if (targetPath !== undefined)
|
|
1101
|
+
fingerprintInput.targetPath = targetPath;
|
|
1102
|
+
if (operation.input !== undefined)
|
|
1103
|
+
fingerprintInput.input = operation.input;
|
|
1104
|
+
return fingerprintInput;
|
|
1105
|
+
}
|
|
1106
|
+
function validateReplayGuard(pendingExecutionEvent, replayGuard, expectedOperationFingerprint) {
|
|
1107
|
+
const actualFingerprint = replayGuard.operationFingerprint;
|
|
1108
|
+
if (expectedOperationFingerprint !== undefined && expectedOperationFingerprint !== actualFingerprint) {
|
|
1109
|
+
return validationFailure(`Operation fingerprint mismatch: expected ${expectedOperationFingerprint}, calculated ${actualFingerprint}`);
|
|
1110
|
+
}
|
|
1111
|
+
if (isObject(pendingExecutionEvent.payload)) {
|
|
1112
|
+
const stored = typeof pendingExecutionEvent.payload.operationFingerprint === 'string' ? pendingExecutionEvent.payload.operationFingerprint : undefined;
|
|
1113
|
+
const storedReplayGuard = pendingExecutionEvent.payload.replayGuard !== undefined && isObject(pendingExecutionEvent.payload.replayGuard) ? pendingExecutionEvent.payload.replayGuard : undefined;
|
|
1114
|
+
const storedReplayFingerprint = typeof storedReplayGuard?.operationFingerprint === 'string' ? storedReplayGuard.operationFingerprint : undefined;
|
|
1115
|
+
const storedFingerprint = stored ?? storedReplayFingerprint;
|
|
1116
|
+
if (storedFingerprint !== undefined && storedFingerprint !== actualFingerprint) {
|
|
1117
|
+
return validationFailure(`Stored operation fingerprint mismatch: stored ${storedFingerprint}, calculated ${actualFingerprint}`);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
return { ok: true, value: undefined };
|
|
1121
|
+
}
|
|
892
1122
|
function summarizeOperation(operation) {
|
|
893
1123
|
return {
|
|
894
1124
|
category: operation.category,
|
|
@@ -1078,6 +1308,10 @@ function operationFromPendingExecution(payload) {
|
|
|
1078
1308
|
operation.targetPath = requested.targetPath;
|
|
1079
1309
|
if (typeof requested.providerToolName === 'string')
|
|
1080
1310
|
operation.providerToolName = requested.providerToolName;
|
|
1311
|
+
if (typeof requested.phase === 'string')
|
|
1312
|
+
operation.phase = requested.phase;
|
|
1313
|
+
if (typeof requested.agentId === 'string')
|
|
1314
|
+
operation.agentId = requested.agentId;
|
|
1081
1315
|
if (typeof requested.destructive === 'boolean')
|
|
1082
1316
|
operation.destructive = requested.destructive;
|
|
1083
1317
|
if (typeof requested.external === 'boolean')
|
|
@@ -1088,10 +1322,13 @@ function operationFromPendingExecution(payload) {
|
|
|
1088
1322
|
operation.ambiguous = requested.ambiguous;
|
|
1089
1323
|
if ('input' in requested)
|
|
1090
1324
|
operation.input = requested.input;
|
|
1091
|
-
if ('input' in payload)
|
|
1325
|
+
if ('input' in payload && payload.input !== null)
|
|
1092
1326
|
operation.input = payload.input;
|
|
1093
1327
|
return operation;
|
|
1094
1328
|
}
|
|
1329
|
+
function stringField(value) {
|
|
1330
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
1331
|
+
}
|
|
1095
1332
|
function isObject(value) {
|
|
1096
1333
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1097
1334
|
}
|
|
@@ -160,10 +160,12 @@ function acceptedEvidence(workspaceRoot, cwd, checks) {
|
|
|
160
160
|
return {
|
|
161
161
|
strategy: 'bounded-process',
|
|
162
162
|
enforceable: true,
|
|
163
|
+
createsSandbox: false,
|
|
164
|
+
createsWorktree: false,
|
|
163
165
|
workspaceRoot,
|
|
164
166
|
cwd,
|
|
165
167
|
capabilities: {
|
|
166
|
-
sandboxEnforceable:
|
|
168
|
+
sandboxEnforceable: false,
|
|
167
169
|
processExecutionEnforceable: true,
|
|
168
170
|
workspaceBoundaryEnforced: true,
|
|
169
171
|
providerConfigWritesBlocked: true,
|
|
@@ -172,10 +174,14 @@ function acceptedEvidence(workspaceRoot, cwd, checks) {
|
|
|
172
174
|
limitedEnvironment: true,
|
|
173
175
|
timeoutEnforced: true,
|
|
174
176
|
outputCapEnforced: true,
|
|
177
|
+
createsSandbox: false,
|
|
178
|
+
createsWorktree: false,
|
|
175
179
|
filesystemIsolation: false,
|
|
176
180
|
networkIsolation: false,
|
|
177
181
|
},
|
|
178
182
|
limitations: [
|
|
183
|
+
'bounded-process does not create an OS-level sandbox',
|
|
184
|
+
'bounded-process does not create or manage a git worktree',
|
|
179
185
|
'bounded-process enforces local process launch constraints only; it is not an OS-level filesystem sandbox',
|
|
180
186
|
'bounded-process does not prove network isolation',
|
|
181
187
|
],
|
|
@@ -17,11 +17,11 @@ export class SddCockpitReadModelService {
|
|
|
17
17
|
return { ok: true, value: buildSddCockpitSurfaceResponse(cockpit.value) };
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
-
export function buildSddCockpitSurfaceResponse(cockpit) {
|
|
21
|
-
return { ...cockpit, readModel: buildSddCockpitReadModel(cockpit) };
|
|
20
|
+
export function buildSddCockpitSurfaceResponse(cockpit, options = {}) {
|
|
21
|
+
return { ...cockpit, readModel: buildSddCockpitReadModel(cockpit, options) };
|
|
22
22
|
}
|
|
23
|
-
export function buildSddCockpitReadModel(cockpit) {
|
|
24
|
-
const phases = cockpit.phases.map((phase) => toPhaseReadModel(phase));
|
|
23
|
+
export function buildSddCockpitReadModel(cockpit, options = {}) {
|
|
24
|
+
const phases = cockpit.phases.map((phase) => toPhaseReadModel(phase, options.operationalEvidenceByPhase?.[phase.phase]));
|
|
25
25
|
const blockers = toReadBlockers(cockpit);
|
|
26
26
|
const nextAction = chooseNextAction(cockpit, phases);
|
|
27
27
|
return {
|
|
@@ -36,7 +36,7 @@ export function buildSddCockpitReadModel(cockpit) {
|
|
|
36
36
|
contentIncluded: false,
|
|
37
37
|
};
|
|
38
38
|
}
|
|
39
|
-
function toPhaseReadModel(phase) {
|
|
39
|
+
function toPhaseReadModel(phase, operationalEvidence) {
|
|
40
40
|
const status = artifactReadStatus(phase);
|
|
41
41
|
const contentAvailable = phase.present && phase.artifact !== undefined;
|
|
42
42
|
const reasons = phase.blockers.map((blocker) => blocker.action ?? blocker.reason);
|
|
@@ -70,6 +70,7 @@ function toPhaseReadModel(phase) {
|
|
|
70
70
|
reasons,
|
|
71
71
|
},
|
|
72
72
|
...(phase.gates === undefined ? {} : { gates: phase.gates }),
|
|
73
|
+
...(operationalEvidence === undefined ? {} : { operationalEvidence }),
|
|
73
74
|
guidance: guidanceForPhase(phase, status, canAccept),
|
|
74
75
|
};
|
|
75
76
|
}
|
|
@@ -6,6 +6,7 @@ export function sddContinuationPlanFrom(input) {
|
|
|
6
6
|
const blockerGuidance = input.next.blockerGuidance ?? [];
|
|
7
7
|
const blockerActions = blockerGuidance.map((blocker) => continuationBlockerAction(input.project, input.next.change, blocker, dbFlag));
|
|
8
8
|
const recommendedActions = continuationRecommendedActions(input.project, input.next, blockerGuidance);
|
|
9
|
+
const advice = continuationAdvice(input.next, blockerGuidance);
|
|
9
10
|
const relatedRunContext = relatedRunContextView(input.project, input.relatedRunContext, dbFlag);
|
|
10
11
|
return {
|
|
11
12
|
kind: 'sdd-continuation-plan',
|
|
@@ -18,6 +19,7 @@ export function sddContinuationPlanFrom(input) {
|
|
|
18
19
|
reason: input.next.reason,
|
|
19
20
|
suggestedCommand,
|
|
20
21
|
inspectCommand,
|
|
22
|
+
advice,
|
|
21
23
|
blockerActions,
|
|
22
24
|
recommendedActions,
|
|
23
25
|
...(relatedRunContext === undefined ? {} : { relatedRunContext }),
|
|
@@ -30,6 +32,50 @@ export function sddContinuationPlanFrom(input) {
|
|
|
30
32
|
],
|
|
31
33
|
};
|
|
32
34
|
}
|
|
35
|
+
function continuationAdvice(next, blockerGuidance) {
|
|
36
|
+
const advice = [];
|
|
37
|
+
if (next.nextPhase === 'proposal' || hasProposalMissingBlocker(next, blockerGuidance))
|
|
38
|
+
advice.push(proposalQuestionRoundAdvice(next.change));
|
|
39
|
+
if (next.nextPhase === 'apply-progress')
|
|
40
|
+
advice.push(reviewWorkloadGuardAdvice(next.change));
|
|
41
|
+
return advice;
|
|
42
|
+
}
|
|
43
|
+
function hasProposalMissingBlocker(next, blockerGuidance) {
|
|
44
|
+
return (blockerGuidance.some((blocker) => blocker.phase === 'proposal' && blocker.reason === 'missing') ||
|
|
45
|
+
next.missingArtifactTopicKeys.some((topicKey) => topicKey.endsWith('/proposal')));
|
|
46
|
+
}
|
|
47
|
+
function proposalQuestionRoundAdvice(change) {
|
|
48
|
+
return {
|
|
49
|
+
id: `sdd.${change}.advice.proposal-question-round`,
|
|
50
|
+
kind: 'proposal-question-round',
|
|
51
|
+
title: 'Run proposal question round when product clarity is missing',
|
|
52
|
+
message: 'Before drafting or accepting the proposal, run a product/business question round if clarity is still missing; this advice does not claim low clarity from metadata alone.',
|
|
53
|
+
readOnly: true,
|
|
54
|
+
advisoryOnly: true,
|
|
55
|
+
mutating: false,
|
|
56
|
+
providerExecution: false,
|
|
57
|
+
artifactMutation: false,
|
|
58
|
+
runCreation: false,
|
|
59
|
+
openspecWrite: false,
|
|
60
|
+
reason: 'Proposal work benefits from explicit business rules, product outcomes, edge cases, non-goals, and tradeoff decisions before proposal drafting or acceptance.',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function reviewWorkloadGuardAdvice(change) {
|
|
64
|
+
return {
|
|
65
|
+
id: `sdd.${change}.advice.review-workload-guard`,
|
|
66
|
+
kind: 'review-workload-guard',
|
|
67
|
+
title: 'Inspect Review Workload Forecast before apply',
|
|
68
|
+
message: 'Before apply, inspect tasks/Review Workload Forecast and ask for a delivery decision if risk is high, estimated changes exceed 400 lines, chained PRs are recommended, or a decision is pending.',
|
|
69
|
+
readOnly: true,
|
|
70
|
+
advisoryOnly: true,
|
|
71
|
+
mutating: false,
|
|
72
|
+
providerExecution: false,
|
|
73
|
+
artifactMutation: false,
|
|
74
|
+
runCreation: false,
|
|
75
|
+
openspecWrite: false,
|
|
76
|
+
reason: 'Apply work can exceed review budget; continuation advice must surface the guard without executing providers, mutating artifacts, creating runs, or writing openspec files.',
|
|
77
|
+
};
|
|
78
|
+
}
|
|
33
79
|
function continuationRecommendedActions(project, next, blockerGuidance) {
|
|
34
80
|
const actions = [inspectCockpitAction(project, next.change)];
|
|
35
81
|
if (next.status === 'runnable' && next.nextPhase !== undefined)
|