specrails-core 6.3.0 → 6.5.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.
Files changed (102) hide show
  1. package/dist/agent-runtime/capabilities.js +22 -5
  2. package/dist/agent-runtime/cli-executor.js +8 -3
  3. package/dist/agent-runtime/cli.d.ts +1 -1
  4. package/dist/agent-runtime/cli.js +50 -3
  5. package/dist/agent-runtime/compact/environment.d.ts +49 -4
  6. package/dist/agent-runtime/compact/environment.js +191 -8
  7. package/dist/agent-runtime/config.js +68 -53
  8. package/dist/agent-runtime/engine/compiler.js +10 -2
  9. package/dist/agent-runtime/engine/contracts.d.ts +16 -0
  10. package/dist/agent-runtime/engine/pieces/control.js +11 -2
  11. package/dist/agent-runtime/engine/pieces/role-turn.d.ts +5 -0
  12. package/dist/agent-runtime/engine/pieces/role-turn.js +69 -3
  13. package/dist/agent-runtime/engine/pieces/verify.d.ts +10 -3
  14. package/dist/agent-runtime/engine/pieces/verify.js +105 -30
  15. package/dist/agent-runtime/executor-types.d.ts +2 -0
  16. package/dist/agent-runtime/graph/nodes.js +9 -55
  17. package/dist/agent-runtime/prompts.d.ts +1 -1
  18. package/dist/agent-runtime/prompts.js +18 -12
  19. package/dist/agent-runtime/session/application/active-session.d.ts +176 -0
  20. package/dist/agent-runtime/session/application/active-session.js +701 -0
  21. package/dist/agent-runtime/session/application/delegation.d.ts +73 -0
  22. package/dist/agent-runtime/session/application/delegation.js +171 -0
  23. package/dist/agent-runtime/session/application/output-buffer.d.ts +43 -0
  24. package/dist/agent-runtime/session/application/output-buffer.js +65 -0
  25. package/dist/agent-runtime/session/application/session-service.d.ts +121 -0
  26. package/dist/agent-runtime/session/application/session-service.js +256 -0
  27. package/dist/agent-runtime/session/application/timers.d.ts +15 -0
  28. package/dist/agent-runtime/session/application/timers.js +35 -0
  29. package/dist/agent-runtime/session/domain/descriptor.d.ts +6 -0
  30. package/dist/agent-runtime/session/domain/descriptor.js +32 -0
  31. package/dist/agent-runtime/session/domain/errors.d.ts +9 -0
  32. package/dist/agent-runtime/session/domain/errors.js +16 -0
  33. package/dist/agent-runtime/session/domain/events.d.ts +147 -0
  34. package/dist/agent-runtime/session/domain/events.js +24 -0
  35. package/dist/agent-runtime/session/domain/fingerprint.d.ts +2 -0
  36. package/dist/agent-runtime/session/domain/fingerprint.js +10 -0
  37. package/dist/agent-runtime/session/domain/interruption.d.ts +13 -0
  38. package/dist/agent-runtime/session/domain/interruption.js +39 -0
  39. package/dist/agent-runtime/session/domain/policy.d.ts +42 -0
  40. package/dist/agent-runtime/session/domain/policy.js +104 -0
  41. package/dist/agent-runtime/session/domain/snapshot.d.ts +100 -0
  42. package/dist/agent-runtime/session/domain/snapshot.js +234 -0
  43. package/dist/agent-runtime/session/domain/state-machines.d.ts +21 -0
  44. package/dist/agent-runtime/session/domain/state-machines.js +41 -0
  45. package/dist/agent-runtime/session/domain/types.d.ts +130 -0
  46. package/dist/agent-runtime/session/domain/types.js +15 -0
  47. package/dist/agent-runtime/session/domain/usage.d.ts +54 -0
  48. package/dist/agent-runtime/session/domain/usage.js +86 -0
  49. package/dist/agent-runtime/session/drivers/claude/argv.d.ts +19 -0
  50. package/dist/agent-runtime/session/drivers/claude/argv.js +67 -0
  51. package/dist/agent-runtime/session/drivers/claude/driver.d.ts +19 -0
  52. package/dist/agent-runtime/session/drivers/claude/driver.js +134 -0
  53. package/dist/agent-runtime/session/drivers/claude/translator.d.ts +40 -0
  54. package/dist/agent-runtime/session/drivers/claude/translator.js +247 -0
  55. package/dist/agent-runtime/session/drivers/codex/argv.d.ts +12 -0
  56. package/dist/agent-runtime/session/drivers/codex/argv.js +76 -0
  57. package/dist/agent-runtime/session/drivers/codex/driver.d.ts +19 -0
  58. package/dist/agent-runtime/session/drivers/codex/driver.js +193 -0
  59. package/dist/agent-runtime/session/drivers/codex/rpc.d.ts +28 -0
  60. package/dist/agent-runtime/session/drivers/codex/rpc.js +81 -0
  61. package/dist/agent-runtime/session/drivers/codex/translator.d.ts +53 -0
  62. package/dist/agent-runtime/session/drivers/codex/translator.js +267 -0
  63. package/dist/agent-runtime/session/drivers/common/process.d.ts +37 -0
  64. package/dist/agent-runtime/session/drivers/common/process.js +116 -0
  65. package/dist/agent-runtime/session/drivers/executor/driver.d.ts +22 -0
  66. package/dist/agent-runtime/session/drivers/executor/driver.js +163 -0
  67. package/dist/agent-runtime/session/drivers/registry.d.ts +32 -0
  68. package/dist/agent-runtime/session/drivers/registry.js +38 -0
  69. package/dist/agent-runtime/session/host/protocol.d.ts +18 -0
  70. package/dist/agent-runtime/session/host/protocol.js +88 -0
  71. package/dist/agent-runtime/session/host/server.d.ts +57 -0
  72. package/dist/agent-runtime/session/host/server.js +229 -0
  73. package/dist/agent-runtime/session/index.d.ts +45 -0
  74. package/dist/agent-runtime/session/index.js +74 -0
  75. package/dist/agent-runtime/session/journal/schema.d.ts +7 -0
  76. package/dist/agent-runtime/session/journal/schema.js +44 -0
  77. package/dist/agent-runtime/session/journal/sqlite-journal.d.ts +70 -0
  78. package/dist/agent-runtime/session/journal/sqlite-journal.js +279 -0
  79. package/dist/agent-runtime/session/ports.d.ts +192 -0
  80. package/dist/agent-runtime/session/ports.js +1 -0
  81. package/dist/agent-runtime/session/testing/driver-conformance.d.ts +21 -0
  82. package/dist/agent-runtime/session/testing/driver-conformance.js +97 -0
  83. package/dist/agent-runtime/session/testing/fake-clock.d.ts +20 -0
  84. package/dist/agent-runtime/session/testing/fake-clock.js +34 -0
  85. package/dist/agent-runtime/session/testing/fixture-replayer.d.ts +41 -0
  86. package/dist/agent-runtime/session/testing/fixture-replayer.js +172 -0
  87. package/dist/agent-runtime/session/testing/index.d.ts +9 -0
  88. package/dist/agent-runtime/session/testing/index.js +9 -0
  89. package/dist/agent-runtime/session/testing/memory-journal.d.ts +27 -0
  90. package/dist/agent-runtime/session/testing/memory-journal.js +70 -0
  91. package/dist/agent-runtime/session/testing/scripted-driver.d.ts +30 -0
  92. package/dist/agent-runtime/session/testing/scripted-driver.js +77 -0
  93. package/dist/agent-runtime/verification-proposals.d.ts +9 -0
  94. package/dist/agent-runtime/verification-proposals.js +69 -0
  95. package/dist/agent-runtime/verification-repair.d.ts +26 -0
  96. package/dist/agent-runtime/verification-repair.js +88 -0
  97. package/dist/installer/util/registry.js +3 -3
  98. package/dist/shared/specrails-home.d.ts +21 -0
  99. package/dist/shared/specrails-home.js +36 -0
  100. package/integration-contract.json +43 -2
  101. package/package.json +11 -1
  102. package/schemas/agent-runtime.schema.json +9 -7
@@ -60,9 +60,66 @@ function score(value, field, floor) {
60
60
  fail(field, `must be at least ${floor}, Core's own review gate`);
61
61
  return value;
62
62
  }
63
+ /** `verification` and `setup` share one command shape and one set of rules, including the credential refusal. */
64
+ function validateCommandList(value, list) {
65
+ if (!Array.isArray(value))
66
+ fail(list, 'expected an array of verification commands');
67
+ return value.map((item, i) => {
68
+ const field = `${list}[${i}]`, command = object(item, field);
69
+ keys(command, ['repositoryId', 'command', 'args', 'cwd', 'env', 'timeoutMs', 'key', 'label', 'policy'], field);
70
+ if (command.key !== undefined)
71
+ identifier(command.key, `${field}.key`);
72
+ if (command.label !== undefined && string(command.label, `${field}.label`).length > 256)
73
+ fail(`${field}.label`, 'maximum 256 characters');
74
+ if (command.policy !== undefined) {
75
+ const policy = object(command.policy, `${field}.policy`);
76
+ keys(policy, ['reuse', 'inputs', 'deterministic', 'readOnly', 'toolchainInputs', 'independentGroup', 'resources'], `${field}.policy`);
77
+ if (policy.reuse !== undefined)
78
+ choices(policy.reuse, ['never', 'snapshot-local'], `${field}.policy.reuse`);
79
+ for (const key of ['deterministic', 'readOnly'])
80
+ if (policy[key] !== undefined)
81
+ boolean(policy[key], `${field}.policy.${key}`);
82
+ if (policy.independentGroup !== undefined)
83
+ identifier(policy.independentGroup, `${field}.policy.independentGroup`);
84
+ for (const key of ['inputs', 'toolchainInputs', 'resources']) {
85
+ const values = policy[key];
86
+ if (values === undefined)
87
+ continue;
88
+ if (!Array.isArray(values) || values.length > 256)
89
+ fail(`${field}.policy.${key}`, 'expected at most 256 entries');
90
+ for (const value of values)
91
+ if (string(value, `${field}.policy.${key}`).length > 4096)
92
+ fail(`${field}.policy.${key}`, 'entry exceeds 4096 characters');
93
+ }
94
+ }
95
+ identifier(command.repositoryId, `${field}.repositoryId`);
96
+ string(command.command, `${field}.command`);
97
+ if (!Array.isArray(command.args) || !command.args.every(arg => typeof arg === 'string' && !arg.includes('\0')))
98
+ fail(`${field}.args`, 'expected a string array');
99
+ if (command.cwd !== undefined)
100
+ string(command.cwd, `${field}.cwd`);
101
+ if (command.timeoutMs !== undefined)
102
+ positive(command.timeoutMs, `${field}.timeoutMs`);
103
+ if (command.env !== undefined) {
104
+ const env = object(command.env, `${field}.env`);
105
+ for (const [key, value] of Object.entries(env)) {
106
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || typeof value !== 'string' || value.includes('\0'))
107
+ fail(`${field}.env`, 'expected environment names and string values');
108
+ if (/(?:token|secret|password|api_?key|credential)/i.test(key))
109
+ fail(`${field}.env.${key}`, 'credentials must be inherited from the process environment, never saved');
110
+ }
111
+ }
112
+ return structuredClone(command);
113
+ });
114
+ }
115
+ function uniqueKeys(commands, list) {
116
+ const checkKeys = commands.flatMap(check => check.key ? [check.key] : []);
117
+ if (new Set(checkKeys).size !== checkKeys.length)
118
+ fail(list, 'duplicate check key');
119
+ }
63
120
  export function validateRuntimeConfig(input, options = {}) {
64
121
  const config = object(input, '$');
65
- keys(config, ['schemaVersion', 'enabled', 'providers', 'agents', 'roles', 'fixer', 'limits', 'verification', 'approvalBeforeArchive', 'review', 'architect', 'rolePrompts', 'efficiency', 'guardrails'], '$');
122
+ keys(config, ['schemaVersion', 'enabled', 'providers', 'agents', 'roles', 'fixer', 'limits', 'verification', 'setup', 'approvalBeforeArchive', 'review', 'architect', 'rolePrompts', 'efficiency', 'guardrails'], '$');
66
123
  if (config.schemaVersion !== 1)
67
124
  fail('schemaVersion', 'expected 1');
68
125
  if (config.enabled !== undefined && typeof config.enabled !== 'boolean')
@@ -176,58 +233,15 @@ export function validateRuntimeConfig(input, options = {}) {
176
233
  for (const [key, value] of Object.entries(limits))
177
234
  positive(value, `limits.${key}`, key !== 'maxCostUsd');
178
235
  }
179
- if (!Array.isArray(config.verification))
180
- fail('verification', 'expected an array of verification commands');
181
- const verification = config.verification.map((item, i) => {
182
- const field = `verification[${i}]`, command = object(item, field);
183
- keys(command, ['repositoryId', 'command', 'args', 'cwd', 'env', 'timeoutMs', 'key', 'label', 'policy'], field);
184
- if (command.key !== undefined)
185
- identifier(command.key, `${field}.key`);
186
- if (command.label !== undefined && string(command.label, `${field}.label`).length > 256)
187
- fail(`${field}.label`, 'maximum 256 characters');
188
- if (command.policy !== undefined) {
189
- const policy = object(command.policy, `${field}.policy`);
190
- keys(policy, ['reuse', 'inputs', 'deterministic', 'readOnly', 'toolchainInputs', 'independentGroup', 'resources'], `${field}.policy`);
191
- if (policy.reuse !== undefined)
192
- choices(policy.reuse, ['never', 'snapshot-local'], `${field}.policy.reuse`);
193
- for (const key of ['deterministic', 'readOnly'])
194
- if (policy[key] !== undefined)
195
- boolean(policy[key], `${field}.policy.${key}`);
196
- if (policy.independentGroup !== undefined)
197
- identifier(policy.independentGroup, `${field}.policy.independentGroup`);
198
- for (const key of ['inputs', 'toolchainInputs', 'resources']) {
199
- const values = policy[key];
200
- if (values === undefined)
201
- continue;
202
- if (!Array.isArray(values) || values.length > 256)
203
- fail(`${field}.policy.${key}`, 'expected at most 256 entries');
204
- for (const value of values)
205
- if (string(value, `${field}.policy.${key}`).length > 4096)
206
- fail(`${field}.policy.${key}`, 'entry exceeds 4096 characters');
207
- }
208
- }
209
- identifier(command.repositoryId, `${field}.repositoryId`);
210
- string(command.command, `${field}.command`);
211
- if (!Array.isArray(command.args) || !command.args.every(arg => typeof arg === 'string' && !arg.includes('\0')))
212
- fail(`${field}.args`, 'expected a string array');
213
- if (command.cwd !== undefined)
214
- string(command.cwd, `${field}.cwd`);
215
- if (command.timeoutMs !== undefined)
216
- positive(command.timeoutMs, `${field}.timeoutMs`);
217
- if (command.env !== undefined) {
218
- const env = object(command.env, `${field}.env`);
219
- for (const [key, value] of Object.entries(env)) {
220
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || typeof value !== 'string' || value.includes('\0'))
221
- fail(`${field}.env`, 'expected environment names and string values');
222
- if (/(?:token|secret|password|api_?key|credential)/i.test(key))
223
- fail(`${field}.env.${key}`, 'credentials must be inherited from the process environment, never saved');
224
- }
225
- }
226
- return structuredClone(command);
227
- });
228
- const checkKeys = verification.flatMap(check => check.key ? [check.key] : []);
229
- if (new Set(checkKeys).size !== checkKeys.length)
230
- fail('verification', 'duplicate check key');
236
+ const verification = validateCommandList(config.verification, 'verification');
237
+ const setup = config.setup === undefined ? undefined : validateCommandList(config.setup, 'setup');
238
+ uniqueKeys(verification, 'verification');
239
+ if (setup) {
240
+ uniqueKeys(setup, 'setup');
241
+ // A key names one check in receipts and host UIs: it cannot be both a setup command and a verification check.
242
+ if (setup.some(command => command.key && verification.some(check => check.key === command.key)))
243
+ fail('setup', 'duplicate check key shared with verification');
244
+ }
231
245
  if (config.efficiency !== undefined) {
232
246
  const policy = object(config.efficiency, 'efficiency');
233
247
  keys(policy, ['schemaVersion', 'contextMode', 'reviewMode', 'planning', 'acceptDeveloperChecks', 'verification'], 'efficiency');
@@ -295,6 +309,7 @@ export function validateRuntimeConfig(input, options = {}) {
295
309
  }
296
310
  }
297
311
  return { schemaVersion: 1, enabled: true, providers, agents, verification,
312
+ ...(setup === undefined ? {} : { setup }),
298
313
  ...(roles === undefined ? {} : { roles }),
299
314
  ...(config.efficiency === undefined ? {} : { efficiency: structuredClone(config.efficiency) }),
300
315
  ...(rolePrompts === undefined ? {} : { rolePrompts }),
@@ -85,7 +85,7 @@ export function compileWorkflowDefinition(definition, registry, execution, optio
85
85
  if (!exit)
86
86
  throw new EngineError('component_exit_missing', `Component ${nodePath} did not produce an exit`);
87
87
  childEvidence = { $candidate: completed.$candidate, $verified: completed.$verified };
88
- result = { outcome: exit.outcome, status: exit.status, output: { outputs: completed.$outputs, completion: { ...exit.completion } },
88
+ result = { outcome: exit.outcome, status: exit.status, output: { outputs: completed.$outputs, completion: completionOutput(exit.completion) },
89
89
  usage: completed.$usage, completion: exit.completion,
90
90
  ...(exit.error ? { error: exit.error } : {}) };
91
91
  }
@@ -156,7 +156,7 @@ export function compileWorkflowDefinition(definition, registry, execution, optio
156
156
  throw new EngineError('component_exit_missing', 'Map body did not produce an exit');
157
157
  return { $branches: { [id]: { total: task.total, visitId: task.frame.visitId, transition: task.frame.transition,
158
158
  results: [{ id: task.frame.visitId + ':' + task.index, transition: task.frame.transition, attempt: task.frame.attempt, ordinal: task.index,
159
- index: task.index, outcome: completed.$exit.completion.ok ? 'next' : 'failed', output: boundPieceOutput({ outputs: completed.$outputs, completion: { ...completed.$exit.completion } }) }] } } };
159
+ index: task.index, outcome: completed.$exit.completion.ok ? 'next' : 'failed', output: boundPieceOutput({ outputs: completed.$outputs, completion: completionOutput(completed.$exit.completion) }) }] } } };
160
160
  }, { subgraphs: [child] });
161
161
  }
162
162
  }
@@ -236,6 +236,14 @@ function componentInput(state, frame, mapping, body) {
236
236
  input.$usage = state.$usage;
237
237
  return input;
238
238
  }
239
+ /** A completion as committed output: the optional blocker is present only when set, so the value stays plain JSON. */
240
+ function completionOutput(completion) {
241
+ const { blocker, ...rest } = completion;
242
+ if (!blocker)
243
+ return { ...rest };
244
+ const { evidenceId, ...fields } = blocker;
245
+ return { ...rest, blocker: { ...fields, ...(evidenceId === undefined ? {} : { evidenceId }) } };
246
+ }
239
247
  function implicitCompletion(result, state) {
240
248
  const candidate = result.candidate ?? state.$candidate;
241
249
  const verified = Object.hasOwn(result, 'verified') ? result.verified : state.$verified;
@@ -97,10 +97,26 @@ export interface ReceiptEvidence {
97
97
  scope: 'full' | 'scoped';
98
98
  evidence: JsonValue;
99
99
  }
100
+ /**
101
+ * A failure only the host can repair, reported structurally so hosts render the
102
+ * action instead of parsing prose. `cwd` is relative to the checkout. `scope`
103
+ * is reserved for a correction role that diagnoses the cause outside the change.
104
+ */
105
+ export interface HostBlocker {
106
+ kind: 'network' | 'credential' | 'environment-variable' | 'toolchain' | 'setup' | 'environment' | 'scope';
107
+ reason: string;
108
+ command: string;
109
+ args: string[];
110
+ cwd: string;
111
+ requiredAction: string;
112
+ evidenceId?: string;
113
+ }
100
114
  export interface EngineCompletion {
101
115
  ok: boolean;
102
116
  reasons: string[];
103
117
  verified: boolean;
118
+ /** Copied by an `end` piece from the node named in `blockerFrom`, when that output carries one. */
119
+ blocker?: HostBlocker;
104
120
  }
105
121
  export interface EngineAnswer {
106
122
  id: string;
@@ -1,6 +1,14 @@
1
1
  import { EngineError } from '../contracts.js';
2
2
  import { parseExpression } from '../expressions.js';
3
+ import { boundedBlocker } from '../../verification-repair.js';
3
4
  import { answerEntry, idSchema, paramsSchema, positiveInteger, stringSchema, text } from './shared.js';
5
+ /** A host blocker committed by `verify` (`output.blocker`) or by a correction role's structured JSON (`output.structured.blocker`). */
6
+ function committedBlocker(output) {
7
+ if (!output || typeof output !== 'object' || Array.isArray(output))
8
+ return undefined;
9
+ const structured = output.structured;
10
+ return boundedBlocker(output.blocker) ?? (structured && typeof structured === 'object' && !Array.isArray(structured) ? boundedBlocker(structured.blocker) : undefined);
11
+ }
4
12
  export function controlPieces(bindings) {
5
13
  const pieces = [{
6
14
  descriptor: { kind: 'condition', paramsSchema: paramsSchema({ expr: { ...stringSchema, minLength: 1, maxLength: 4096 } }, ['expr']), outcomes: ['true', 'false'], effect: 'read', requiresAI: false },
@@ -14,14 +22,15 @@ export function controlPieces(bindings) {
14
22
  }
15
23
  },
16
24
  }, {
17
- descriptor: { kind: 'end', paramsSchema: paramsSchema({ outcome: { enum: ['success', 'failure'] }, requiresVerified: { type: 'boolean' }, reason: stringSchema, exit: idSchema }, ['outcome']), outcomes: [], effect: 'read', requiresAI: false },
25
+ descriptor: { kind: 'end', paramsSchema: paramsSchema({ outcome: { enum: ['success', 'failure'] }, requiresVerified: { type: 'boolean' }, reason: stringSchema, exit: idSchema, blockerFrom: idSchema }, ['outcome']), outcomes: [], effect: 'read', requiresAI: false },
18
26
  async execute(params, context) {
19
27
  const { candidate, verified } = bindings().executionSnapshot(context);
20
28
  const current = verified !== null && candidate !== null && verified.candidateHash === candidate.hash;
21
29
  const reasons = typeof params.reason === 'string' && params.reason ? [params.reason] : [];
22
30
  if (params.requiresVerified && !current)
23
31
  reasons.push('unverified');
24
- return { outcome: text(params.outcome), completion: { ok: params.outcome === 'success' && !(params.requiresVerified && !current), verified: current, reasons } };
32
+ const blocker = typeof params.blockerFrom === 'string' ? committedBlocker(context.state.$outputs[params.blockerFrom]) : undefined;
33
+ return { outcome: text(params.outcome), completion: { ok: params.outcome === 'success' && !(params.requiresVerified && !current), verified: current, reasons, ...(blocker ? { blocker } : {}) } };
25
34
  },
26
35
  }];
27
36
  for (const kind of ['approval', 'question', 'gate']) {
@@ -1,7 +1,12 @@
1
+ import { type InstallOutcome } from '../../compact/environment.js';
1
2
  import { type JsonObject, type Piece, type PieceExecutionContext, type PieceResult } from '../contracts.js';
2
3
  import type { PieceDependencies, PieceDependencyProvider } from './ports.js';
3
4
  export declare function roleTurnPiece(bindings: PieceDependencyProvider): Piece;
4
5
  /** One invocation path for configured roles, including routing and exactly one repair. */
5
6
  export declare function executeRoleTurn(deps: PieceDependencies, params: JsonObject, context: PieceExecutionContext, options?: {
6
7
  normalizeStructuredOutput?: (output: Record<string, unknown> | undefined, text: string) => Record<string, unknown> | undefined;
8
+ /** Set on the single rerun after the host repaired an environment blocker the agent reported. */
9
+ environmentRetry?: {
10
+ installs: InstallOutcome[];
11
+ };
7
12
  }): Promise<PieceResult>;
@@ -1,16 +1,20 @@
1
1
  import { contentDigest } from '../canonical-json.js';
2
2
  import { advisoryMemory } from './project-memory.js';
3
3
  import { Ajv2020 } from 'ajv/dist/2020.js';
4
+ import { withScopeDefault } from '../../change-scope.js';
4
5
  import { resolveRoleDescriptor } from '../../config.js';
5
6
  import { AgentExecutionError } from '../../executor-types.js';
6
7
  import { createRoleInvoker } from '../../graph/roles.js';
8
+ import { admitVerificationProposals } from '../../verification-proposals.js';
9
+ import { guardrailEnabled } from '../../guardrails.js';
10
+ import { installEnvironment, prepareEnvironment } from '../../compact/environment.js';
7
11
  import { roleInstructions } from '../../prompts.js';
8
12
  import { EngineError } from '../contracts.js';
9
13
  import { boundedText, historyEntry, idSchema, json, paramsSchema, invocationTimers, stringSchema, text } from './shared.js';
10
14
  const ajv = new Ajv2020({ allErrors: true, strict: true, validateFormats: false, ownProperties: true });
11
15
  export function roleTurnPiece(bindings) {
12
16
  return {
13
- descriptor: { kind: 'role-turn', paramsSchema: paramsSchema({ ...invocationTimers, roleId: idSchema, prompt: { ...stringSchema, minLength: 1 }, structuredOutput: { type: 'object' }, sessionContinuity: { enum: ['run', 'none'] } }, ['roleId', 'prompt']), outcomes: ['next', 'invalid', 'failed'], effect: 'derived', requiresAI: true, storeAccess: 'write' },
17
+ descriptor: { kind: 'role-turn', paramsSchema: paramsSchema({ ...invocationTimers, roleId: idSchema, prompt: { ...stringSchema, minLength: 1 }, structuredOutput: { type: 'object' }, sessionContinuity: { enum: ['run', 'none'] }, verificationProposalsFrom: { type: 'string', minLength: 1, maxLength: 128 } }, ['roleId', 'prompt']), outcomes: ['next', 'invalid', 'failed'], effect: 'derived', requiresAI: true, storeAccess: 'write' },
14
18
  getOutcomes: params => params.structuredOutput ? ['next', 'invalid', 'failed'] : ['next', 'failed'],
15
19
  getEffect: (params, roles) => {
16
20
  const role = roles[text(params.roleId)];
@@ -24,6 +28,14 @@ export function roleTurnPiece(bindings) {
24
28
  /** One invocation path for configured roles, including routing and exactly one repair. */
25
29
  export async function executeRoleTurn(deps, params, context, options = {}) {
26
30
  const roleId = text(params.roleId), descriptor = resolveRoleDescriptor(deps.config, roleId);
31
+ // Agents run sandboxed and cannot install what the repository needs (Codex
32
+ // cannot even write the Playwright browser cache): the host prepares it
33
+ // before any writer turn and says so in the task.
34
+ const hostRepairs = descriptor.access === 'write' && guardrailEnabled(deps.config.guardrails, 'environment-repair');
35
+ const note = (line) => context.progress({ type: 'verification-output', payload: { text: line } });
36
+ const environment = options.environmentRetry
37
+ ? environmentNote(options.environmentRetry.installs, 'retry')
38
+ : hostRepairs ? environmentNote(prepareEnvironment(deps.context.repositories.map(repository => repository.path), environmentIo(deps, note)), 'prepared') : '';
27
39
  const schema = params.structuredOutput;
28
40
  const validate = schema ? ajv.compile(schema) : undefined;
29
41
  const roleState = deps.roleState(context);
@@ -44,8 +56,9 @@ export async function executeRoleTurn(deps, params, context, options = {}) {
44
56
  });
45
57
  const priorNote = !previous && descriptor.access === 'read' && saved?.session && typeof saved.notes?.value.text === 'string'
46
58
  ? saved.notes.value.text.slice(0, 4000) : '';
47
- const task = text(params.prompt) + (priorNote ? '\n\n## Prior project review note\nTreat this bounded note as prior evidence to check against the current task; frozen requirements remain authoritative.\n' + JSON.stringify(priorNote) : '');
48
- const full = roleInstructions(descriptor, deps.context, openspec?.[roleId]?.change, { definition: descriptor.prompt }) + '\n## Current workflow task\n' + task;
59
+ const task = text(params.prompt) + environment + (priorNote ? '\n\n## Prior project review note\nTreat this bounded note as prior evidence to check against the current task; frozen requirements remain authoritative.\n' + JSON.stringify(priorNote) : '');
60
+ const verification = descriptor.access === 'write' ? hostVerificationPlan(deps, context, params) : undefined;
61
+ const full = roleInstructions(descriptor, deps.context, openspec?.[roleId]?.change, { definition: descriptor.prompt, verification }) + '\n## Current workflow task\n' + task;
49
62
  const result = await invoke(roleId, deps.stepContext(context), { prompt: previous ? task : full, ...(previous ? { resumeSessionId: previous, fallbackPrompt: full } : {}),
50
63
  structured: schema !== undefined, lenient: options.normalizeStructuredOutput !== undefined, outputSchema: schema, timeoutMs: params.timeoutMs, idleTimeoutMs: params.idleTimeoutMs }, (output, responseText) => {
51
64
  const normalized = options.normalizeStructuredOutput ? options.normalizeStructuredOutput(output, responseText) : output;
@@ -58,6 +71,19 @@ export async function executeRoleTurn(deps, params, context, options = {}) {
58
71
  throw new AgentExecutionError(result.error, result.code);
59
72
  return { outcome: schema ? 'invalid' : 'failed', status: 'failed', output: { error: boundedText(result.error) }, error: { code: schema ? 'invalid_role_output' : 'role_failed', message: result.error } };
60
73
  }
74
+ // A writer that still hit a missing tool reports a structured blocker. When
75
+ // the host can install it, it does so and reruns the turn once instead of
76
+ // ending the run on something it can fix itself.
77
+ const blocker = hostRepairs && !options.environmentRetry ? repairableBlocker(result.value) : undefined;
78
+ if (blocker) {
79
+ const roots = deps.context.repositories.map(repository => repository.path);
80
+ const io = environmentIo(deps, note);
81
+ const installs = [...installEnvironment(roots, { ...io, failureOutput: blocker }), ...prepareEnvironment(roots, io)];
82
+ if (installs.some(install => install.ok)) {
83
+ note('[environment] The agent reported a missing dependency or tool; the host installed it and runs the turn again.');
84
+ return executeRoleTurn(deps, params, context, { ...options, environmentRetry: { installs } });
85
+ }
86
+ }
61
87
  const currentState = roleState.read(), session = currentState.sessions[roleId];
62
88
  const finalIdentity = { ...memoryIdentity, tier: currentState.routes[roleId]?.tier ?? 'base' }, finalKey = contentDigest(finalIdentity);
63
89
  await advisoryMemory(context, async () => {
@@ -71,3 +97,43 @@ export async function executeRoleTurn(deps, params, context, options = {}) {
71
97
  return { outcome: 'next', output: { candidateHash: deps.executionSnapshot(context).candidate?.hash ?? null, text: boundedText(result.text), ...(result.value ? { structured: json(result.value) } : {}) },
72
98
  history: [historyEntry(context, result.text)], ...(params.sessionContinuity !== 'none' && session ? { session: { sessionId: session.sessionId, identity: session.identity } } : {}) };
73
99
  }
100
+ const REPAIRABLE_BLOCKERS = new Set(['toolchain', 'setup', 'environment']);
101
+ /** The text of a structured `blocker` the host may be able to install away; undefined for other kinds (credentials, network, scope). */
102
+ function repairableBlocker(value) {
103
+ const blocker = value?.blocker;
104
+ if (!blocker || typeof blocker !== 'object' || !REPAIRABLE_BLOCKERS.has(String(blocker.kind)))
105
+ return undefined;
106
+ return ['evidence', 'reason', 'command', 'requiredAction'].map(field => typeof blocker[field] === 'string' ? blocker[field] : '').join('\n').slice(0, 8_000);
107
+ }
108
+ function environmentIo(deps, note) {
109
+ return { lockfileRepair: guardrailEnabled(deps.config.guardrails, 'lockfile-repair'),
110
+ onEvent: (event) => note(event.kind === 'text' ? event.text ?? '' : `[environment] ${event.tool ?? ''} ${event.detail ?? ''}`.trim()) };
111
+ }
112
+ /** What the host installed (or could not) for this turn, as task context; empty when nothing was needed. */
113
+ function environmentNote(installs, when) {
114
+ if (!installs.length)
115
+ return '';
116
+ const done = installs.filter(install => install.ok).map(install => install.installs ?? `${install.ecosystem} dependencies`);
117
+ const failed = installs.filter(install => !install.ok).map(install => install.detail);
118
+ return '\n\n## Host environment\n' + (when === 'retry' ? 'Your previous turn reported an environment blocker. ' : '')
119
+ + (done.length ? `The host installed ${done.join('; ')} for this workspace${when === 'retry' ? ': continue the remaining work and rerun the checks that were blocked.' : ' before this turn.'} ` : '')
120
+ + (failed.length ? `It could not install: ${failed.join('; ')}. ` : '')
121
+ + 'Do not install these yourself; if something is still missing, return the structured blocker.';
122
+ }
123
+ /** The complete plan the host will run for the repositories in scope: configured checks plus, when `verificationProposalsFrom`
124
+ * names a committed output, the proposals the verify piece admits (see admitVerificationProposals). */
125
+ function hostVerificationPlan(deps, context, params) {
126
+ const inScope = new Set(deps.context.repositories.map(repository => repository.id));
127
+ const commands = deps.config.verification.filter(command => inScope.has(command.repositoryId)).map(command => withScopeDefault(deps.context, command));
128
+ if (typeof params.verificationProposalsFrom === 'string') {
129
+ const source = context.state.$outputs[params.verificationProposalsFrom];
130
+ const proposals = (Array.isArray(source?.structured?.verification) ? source.structured.verification.slice(0, 20) : []).filter((proposal) => {
131
+ if (!proposal || typeof proposal !== 'object' || Array.isArray(proposal))
132
+ return false;
133
+ const raw = proposal;
134
+ return typeof raw.repositoryId === 'string' && inScope.has(raw.repositoryId) && typeof raw.command === 'string' && Array.isArray(raw.args) && raw.args.every(arg => typeof arg === 'string');
135
+ });
136
+ commands.push(...admitVerificationProposals(deps.context, commands, proposals, raw => withScopeDefault(deps.context, { repositoryId: raw.repositoryId, command: raw.command, args: raw.args, ...(typeof raw.cwd === 'string' ? { cwd: raw.cwd } : {}) })));
137
+ }
138
+ return commands;
139
+ }
@@ -1,7 +1,14 @@
1
- import { type VerificationReceipt } from '../../../pipeline/pipeline-state.js';
2
- import type { Piece, PieceExecutionContext, ReceiptEvidence } from '../contracts.js';
1
+ import { type VerificationCommand, type VerificationReceipt } from '../../../pipeline/pipeline-state.js';
2
+ import type { JsonObject, Piece, PieceExecutionContext, ReceiptEvidence } from '../contracts.js';
3
3
  import type { PieceDependencies, PieceDependencyProvider } from './ports.js';
4
- export declare const verificationCommandSchema: import("../contracts.js").JsonObject;
4
+ export declare const verificationCommandSchema: JsonObject;
5
5
  export declare function receiptEvidence(receipt: VerificationReceipt): ReceiptEvidence;
6
6
  export declare function verificationDeadline(deps: PieceDependencies, context: PieceExecutionContext): number | undefined;
7
+ /**
8
+ * The complete host plan for a `verify` node: configured checks (or the inline
9
+ * list) plus the proposals of the node named in `additionalCommandsFrom` for
10
+ * repositories without a configured check. Role turns show write roles this
11
+ * same list, so the developer never passes a check the host does not run.
12
+ */
13
+ export declare function resolveVerificationPlan(deps: PieceDependencies, context: PieceExecutionContext, params: JsonObject): VerificationCommand[];
7
14
  export declare function verifyPiece(bindings: PieceDependencyProvider): Piece;
@@ -1,3 +1,4 @@
1
+ import { admitVerificationProposals } from '../../verification-proposals.js';
1
2
  import { stripVTControlCharacters } from 'node:util';
2
3
  import { contentDigest } from '../canonical-json.js';
3
4
  import { advisoryMemory } from './project-memory.js';
@@ -5,6 +6,9 @@ import { executeVerification, validateVerificationRequest } from '../../../pipel
5
6
  import { withScopeDefault } from '../../change-scope.js';
6
7
  import { verificationDiagnosticPriority, verificationFailureSummary } from '../../verification-diagnostics.js';
7
8
  import { boundedText, json, paramsSchema, positiveInteger } from './shared.js';
9
+ import { isEnvironmentFailure } from '../../compact/environment.js';
10
+ import { guardrailEnabled } from '../../guardrails.js';
11
+ import { checkoutRelative, preconditionBlock, repairEnvironment } from '../../verification-repair.js';
8
12
  /** Keep real subprocess diagnostics in committed outputs for subsequent agents.
9
13
  * The full command evidence remains in the receipt; prioritize failed checks
10
14
  * when the output budget is exhausted. */
@@ -75,50 +79,115 @@ export function verificationDeadline(deps, context) {
75
79
  const budget = deps.stepContext(context).remainingBudget();
76
80
  return budget.maxDurationMs === undefined ? undefined : Date.now() + Math.max(0, budget.maxDurationMs);
77
81
  }
82
+ const VERIFY_OUTCOMES = ['pass', 'fail', 'failed'];
83
+ const verificationCommandList = { type: 'array', maxItems: 100, items: verificationCommandSchema };
84
+ /**
85
+ * The complete host plan for a `verify` node: configured checks (or the inline
86
+ * list) plus the proposals of the node named in `additionalCommandsFrom` for
87
+ * repositories without a configured check. Role turns show write roles this
88
+ * same list, so the developer never passes a check the host does not run.
89
+ */
90
+ export function resolveVerificationPlan(deps, context, params) {
91
+ const configured = params.commands === undefined || params.commands === 'configured' ? deps.config.verification : params.commands;
92
+ const commands = configured.map(command => withScopeDefault(deps.context, command));
93
+ if (typeof params.additionalCommandsFrom === 'string') {
94
+ const source = context.state.$outputs[params.additionalCommandsFrom];
95
+ const proposals = source?.structured?.verification;
96
+ if (!Array.isArray(proposals) || proposals.length > 20)
97
+ throw new Error('Verification proposals must be a bounded array from a committed agent output');
98
+ // Host checks always stay mandatory. The same scoped command validation
99
+ // applies to these actual subprocesses and to configured checks below.
100
+ for (const proposal of proposals) {
101
+ if (!proposal || typeof proposal !== 'object' || Array.isArray(proposal))
102
+ throw new Error('Invalid verification proposal');
103
+ if (!deps.context.repositories.some(repository => repository.id === proposal.repositoryId))
104
+ throw new Error('Unknown verification repository');
105
+ }
106
+ commands.push(...admitVerificationProposals(deps.context, commands, proposals, raw => withScopeDefault(deps.context, raw)));
107
+ }
108
+ return commands;
109
+ }
110
+ /** A setup command that exited non-zero: the host declared it, so only the host can fix or remove it. */
111
+ function setupBlocker(deps, command) {
112
+ const cwd = checkoutRelative(deps.context, command.cwd);
113
+ return { kind: 'setup', reason: `the setup command \`${[command.command, ...command.args].join(' ')}\` failed in ${cwd} (exit ${command.exitCode})`, command: command.command, args: command.args, cwd,
114
+ requiredAction: `Fix or remove the setup command \`${[command.command, ...command.args].join(' ')}\` in the runtime configuration, then retry the run.`, ...(command.evidenceId ? { evidenceId: command.evidenceId } : {}) };
115
+ }
78
116
  export function verifyPiece(bindings) {
79
117
  return {
80
- descriptor: { kind: 'verify', paramsSchema: paramsSchema({ commands: { oneOf: [{ const: 'configured' }, { type: 'array', maxItems: 100, items: verificationCommandSchema }] },
81
- additionalCommandsFrom: { type: 'string', minLength: 1, maxLength: 128 }, unverified: { type: 'boolean' }, maxConcurrency: { type: 'integer', minimum: 1, maximum: 4 } }, ['commands']), outcomes: ['pass', 'fail', 'failed'], effect: 'write', requiresAI: false, storeAccess: 'write' },
118
+ descriptor: { kind: 'verify', paramsSchema: paramsSchema({ commands: { oneOf: [{ const: 'configured' }, verificationCommandList] },
119
+ additionalCommandsFrom: { type: 'string', minLength: 1, maxLength: 128 }, unverified: { type: 'boolean' }, maxConcurrency: { type: 'integer', minimum: 1, maximum: 4 },
120
+ setup: { oneOf: [{ const: 'configured' }, verificationCommandList] }, hostBlockers: { type: 'boolean' } }, ['commands']), outcomes: [...VERIFY_OUTCOMES, 'blocked'], effect: 'write', requiresAI: false, storeAccess: 'write' },
121
+ // `blocked` is opt-in so definitions published before host blockers keep their exact `ends`.
122
+ getOutcomes: params => params.hostBlockers === true ? [...VERIFY_OUTCOMES, 'blocked'] : VERIFY_OUTCOMES,
82
123
  async execute(params, context) {
83
124
  const deps = bindings();
84
- const commands = (params.commands === 'configured' ? deps.config.verification : params.commands).map(command => withScopeDefault(deps.context, command));
85
- const hostRepositories = new Set(commands.map(command => command.repositoryId));
86
- if (typeof params.additionalCommandsFrom === 'string') {
87
- const source = context.state.$outputs[params.additionalCommandsFrom];
88
- const proposals = source?.structured?.verification;
89
- if (!Array.isArray(proposals) || proposals.length > 20)
90
- throw new Error('Verification proposals must be a bounded array from a committed agent output');
91
- // Host checks always stay mandatory. The same scoped command validation
92
- // applies to these actual subprocesses and to configured checks below.
93
- for (const proposal of proposals) {
94
- if (!proposal || typeof proposal !== 'object' || Array.isArray(proposal))
95
- throw new Error('Invalid verification proposal');
96
- const raw = proposal;
97
- if (!deps.context.repositories.some(repository => repository.id === raw.repositoryId))
98
- throw new Error('Unknown verification repository');
99
- if (!hostRepositories.has(raw.repositoryId))
100
- commands.push(withScopeDefault(deps.context, raw));
101
- }
102
- }
125
+ const commands = resolveVerificationPlan(deps, context, params);
103
126
  const coversAll = deps.context.repositories.every(repository => commands.some(command => command.repositoryId === repository.id));
104
127
  const request = { kind: coversAll || params.unverified === true ? 'full' : 'scoped', commands, ...(params.unverified === true ? { unverified: true } : {}) };
105
128
  validateVerificationRequest(deps.context, request);
129
+ // Setup is validated before anything runs: a setup entry escaping the workspace fails exactly like a check would.
130
+ const setupCommands = (params.setup === 'configured' ? deps.config.setup ?? [] : params.setup ?? [])
131
+ .map(({ policy: _policy, ...command }) => withScopeDefault(deps.context, command));
132
+ const setupRequest = { kind: 'scoped', commands: setupCommands };
133
+ if (setupCommands.length)
134
+ validateVerificationRequest(deps.context, setupRequest);
135
+ const progress = (text) => context.progress({ type: 'verification-output', payload: { text } });
136
+ const concurrency = params.maxConcurrency ?? deps.config.efficiency?.verification?.maxConcurrency;
137
+ const run = (plan, maxConcurrency) => executeVerification(deps.context, plan, deps.verification(context), progress, context.signal, {
138
+ deadline: verificationDeadline(deps, context), maxConcurrency, idleTimeoutMs: deps.config.guardrails?.['verify-idle-timeout'] === false ? 0 : undefined,
139
+ onEvidence: async (kind, payload) => context.progress({ type: 'runtime-efficiency-event', payload: { kind, ...payload } }),
140
+ });
106
141
  const identity = { candidateHash: deps.executionSnapshot(context).candidate?.hash ?? null, scopeId: context.frame.scope.id,
107
142
  repositories: deps.context.repositories.map(repository => ({ id: repository.id, path: repository.path })),
108
- planHash: contentDigest(json(request)), concurrency: params.maxConcurrency ?? deps.config.efficiency?.verification?.maxConcurrency ?? 1 };
143
+ planHash: contentDigest(json(request)), concurrency: concurrency ?? 1 };
144
+ const infrastructureFailure = (receipt) => receipt.commands.some(command => command.exitCode === -1 && command.outcome !== 'cancelled');
145
+ const executionError = (receipt) => ({ status: 'failed', error: { code: 'verification_execution_error', message: receipt.reason ?? 'A verification command could not complete' } });
146
+ const blocked = (blocker, output) => ({
147
+ outcome: params.hostBlockers === true ? 'blocked' : 'fail',
148
+ ...(params.hostBlockers === true ? { status: 'blocked', error: { code: 'verification_host_precondition', message: `${blocker.reason} ${blocker.requiredAction}` } } : {}),
149
+ output: { ...output, valid: false, candidateHash: identity.candidateHash, blocker: json(blocker) }, verified: null,
150
+ });
151
+ // Setup never installs a verified candidate or feeds the no-progress
152
+ // fingerprint: it prepares the workspace, it does not judge the change.
153
+ let setup;
154
+ if (setupCommands.length) {
155
+ const receipt = await run(setupRequest, 1);
156
+ setup = verificationDiagnostics(receipt);
157
+ if (infrastructureFailure(receipt))
158
+ return { outcome: 'failed', ...executionError(receipt), output: { valid: false, candidateHash: identity.candidateHash, reason: receipt.reason ?? 'A setup command could not complete', setup }, verified: null };
159
+ const failed = receipt.commands.find(command => command.exitCode !== 0);
160
+ if (failed)
161
+ return blocked(setupBlocker(deps, failed), { reason: 'A setup command failed', setup });
162
+ }
109
163
  const memoryKey = contentDigest(identity);
110
164
  const known = await advisoryMemory(context, () => deps.memory(context).get(['verification', 'known-commands'], memoryKey));
111
- const receipt = await executeVerification(deps.context, request, deps.verification(context), output => context.progress({ type: 'verification-output', payload: { text: output } }), context.signal, {
112
- deadline: verificationDeadline(deps, context), maxConcurrency: params.maxConcurrency ?? deps.config.efficiency?.verification?.maxConcurrency,
113
- idleTimeoutMs: deps.config.guardrails?.['verify-idle-timeout'] === false ? 0 : undefined,
114
- onEvidence: async (kind, payload) => context.progress({ type: 'runtime-efficiency-event', payload: { kind, ...payload } }),
115
- });
165
+ let receipt = await run(request, concurrency);
166
+ let blocker = receipt.valid ? undefined : preconditionBlock(deps.context, receipt);
167
+ // A missing toolchain or dependency (exit 127, "command not found", a
168
+ // Playwright browser build) is the host's to fix, not the fixer's: a
169
+ // fresh worktree has no node_modules whatever wrote the code. Install
170
+ // once where the failing command runs and verify again; only the second
171
+ // receipt becomes feedback.
172
+ let environmentRepair;
173
+ if (!blocker && !receipt.valid && guardrailEnabled(deps.config.guardrails, 'environment-repair') && receipt.commands.some(command => isEnvironmentFailure(command.exitCode, command.output))) {
174
+ const repair = repairEnvironment(deps.context, receipt, deps.config.guardrails, progress);
175
+ environmentRepair = { attempted: true, reverified: false, installs: repair.installs.map(install => ({ command: install.command, args: install.args, root: checkoutRelative(deps.context, install.root), ok: install.ok, detail: boundedText(install.detail, 1_000) })) };
176
+ if (repair.refused)
177
+ blocker = repair.refused;
178
+ else if (repair.installs.some(install => install.ok)) {
179
+ progress('[environment] Verification failed on the environment (missing dependencies or tools); the host installed them and is verifying again.');
180
+ receipt = await run(request, concurrency);
181
+ environmentRepair.reverified = true;
182
+ blocker = receipt.valid ? undefined : preconditionBlock(deps.context, receipt);
183
+ }
184
+ }
116
185
  await advisoryMemory(context, () => deps.memory(context).put(['verification', 'known-commands'], memoryKey, {
117
186
  ...identity, observations: Math.min(1_000_000, typeof known?.value.observations === 'number' ? known.value.observations + 1 : 1),
118
187
  lastAttemptId: context.frame.attemptId, lastReceiptId: receipt.id, lastValid: receipt.valid,
119
188
  commands: receipt.commands.map(command => ({ repositoryId: command.repositoryId, command: command.command, exitCode: command.exitCode })),
120
189
  }));
121
- const infrastructure = receipt.commands.some(command => command.exitCode === -1 && command.outcome !== 'cancelled');
190
+ const infrastructure = infrastructureFailure(receipt);
122
191
  const outcome = infrastructure ? 'failed' : receipt.valid ? 'pass' : 'fail';
123
192
  const progressKey = 'verification-' + contentDigest(context.frame.nodePath).slice(0, 20);
124
193
  const fingerprint = contentDigest(json({ candidateHash: identity.candidateHash,
@@ -127,8 +196,14 @@ export function verifyPiece(bindings) {
127
196
  const count = receipt.valid ? 0 : previous?.fingerprint === fingerprint ? (previous.count ?? 0) + 1 : 1;
128
197
  const stalled = !infrastructure && !receipt.valid && count >= 3;
129
198
  const verified = receipt.valid && receipt.commands.length > 0 && !receipt.unverifiedRepositories?.length;
130
- return { outcome: stalled ? 'failed' : outcome, vars: { [progressKey]: { fingerprint, count } }, ...(stalled ? { status: 'failed', error: { code: 'verification_no_progress', message: 'Verification failed three times on the same unchanged candidate. Stopping automatic corrections; inspect the failed checks.' } } : {}), ...(infrastructure ? { status: 'failed', error: { code: 'verification_execution_error', message: receipt.reason ?? 'A verification command could not complete' } } : {}),
131
- output: { receiptId: receipt.id, valid: receipt.valid, candidateHash: identity.candidateHash, noProgressCount: count, ...(receipt.reason ? { reason: receipt.reason } : {}), ...(stalled ? { reason: 'Verification failed three times without candidate changes' } : {}), ...verificationDiagnostics(receipt) },
199
+ const output = { receiptId: receipt.id, valid: receipt.valid, candidateHash: identity.candidateHash, noProgressCount: count, ...(receipt.reason ? { reason: receipt.reason } : {}), ...(stalled ? { reason: 'Verification failed three times without candidate changes' } : {}),
200
+ ...(setup ? { setup } : {}), ...(environmentRepair ? { environmentRepair } : {}), ...verificationDiagnostics(receipt) };
201
+ const vars = { [progressKey]: { fingerprint, count } };
202
+ // Without the opt-in outcome a blocker still travels in the output, and the no-progress stop keeps bounding the loop.
203
+ if (blocker && (params.hostBlockers === true || !stalled))
204
+ return { ...blocked(blocker, output), vars, receipt: receiptEvidence(receipt) };
205
+ return { outcome: stalled ? 'failed' : outcome, vars, ...(stalled ? { status: 'failed', error: { code: 'verification_no_progress', message: 'Verification failed three times on the same unchanged candidate. Stopping automatic corrections; inspect the failed checks.' } } : {}), ...(infrastructure ? executionError(receipt) : {}),
206
+ output: { ...output, ...(blocker ? { blocker: json(blocker) } : {}) },
132
207
  receipt: receiptEvidence(receipt), ...(verified ? { verified: { receiptId: receipt.id, candidateHash: receipt.candidateHash, atTransition: context.frame.transition, revision: context.frame.transition } } : { verified: null }) };
133
208
  },
134
209
  };
@@ -109,6 +109,8 @@ export interface RuntimeConfig {
109
109
  idleTimeoutMs?: number;
110
110
  };
111
111
  verification: VerificationCommand[];
112
+ /** Idempotent commands the `verify` piece runs sequentially before each verification plan (same shape as `verification`; `policy` is ignored). */
113
+ setup?: VerificationCommand[];
112
114
  approvalBeforeArchive?: boolean;
113
115
  /** Review gate thresholds (0–100); unset fields keep Core's defaults. */
114
116
  review?: {