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.
@@ -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 and records a plan but does not execute the checked shell/git/install/network/provider/secrets operation. Use it to decide whether human approval or a separate executor step is required.';
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']);
@@ -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: operationMetadata(operation),
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: operationMetadata(operation),
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: true,
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)