specrails-core 6.3.0 → 6.4.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.
@@ -158,7 +158,7 @@ export async function runRuntimeCommand(flags, positionals, emit = value => proc
158
158
  emit({ type: 'runtime-api', apiVersion: RUNTIME_API_VERSION, coreVersion: CORE_PACKAGE_VERSION, runtimeIdentity: coreRuntimeIdentity(), workflowVersions: [CORE_WORKFLOW_VERSION],
159
159
  engineVersion: 2, engines: RUNTIME_ENGINES, nodeKindsVersion: NODE_KINDS_VERSION, nodeKinds: validationPieceRegistry().kinds(),
160
160
  capabilities: { openRoles: 1, scopedRecovery: 1, efficientRoleExecution: 1, reproducibleVerification: 1, implementationEfficiencyMetrics: 1, compactAgentLoop: 1, configurableGuardrails: 1, compactOutputBudget: 1, roleThinkingControl: 1, repositoryScope: 1,
161
- engineV2: 1, workflowAgentSteps: 1, implementationSteps: 1, workflowDefinitions: 1, fanOut: 1, fork: 1, forkIdempotency: 1, steeringInbox: 1 }, guardrails: GUARDRAIL_CATALOG });
161
+ engineV2: 1, workflowAgentSteps: 1, implementationSteps: 1, workflowDefinitions: 1, fanOut: 1, fork: 1, forkIdempotency: 1, steeringInbox: 1, hostBlockers: 1, setupCommands: 1 }, guardrails: GUARDRAIL_CATALOG });
162
162
  return 0;
163
163
  }
164
164
  if (command === 'validate') {
@@ -1,16 +1,29 @@
1
1
  import type { spawnSync } from 'node:child_process';
2
- interface EnvironmentInstall {
2
+ /** `timeoutMs` overrides the default bound for one plan (a browser download is far larger than a dependency install). */
3
+ export interface EnvironmentInstall {
3
4
  ecosystem: 'node' | 'python' | 'go' | 'rust';
4
5
  root: string;
5
6
  command: string;
6
7
  args: string[];
8
+ timeoutMs?: number;
9
+ installs?: string;
7
10
  }
8
11
  /** `precondition`: the install failed for a reason only the host can repair (see hostPreconditionFailure). */
9
- interface InstallOutcome extends EnvironmentInstall {
12
+ export interface InstallOutcome extends EnvironmentInstall {
10
13
  ok: boolean;
11
14
  detail: string;
12
15
  precondition?: string;
13
16
  }
17
+ export type HostPreconditionKind = 'network' | 'credential' | 'environment-variable' | 'toolchain' | 'setup' | 'environment';
18
+ export interface HostPrecondition {
19
+ kind: HostPreconditionKind;
20
+ reason: string;
21
+ requiredAction: string;
22
+ }
23
+ /** True when a verification failure names a missing Playwright browser build. */
24
+ export declare function isPlaywrightBrowserFailure(output: string): boolean;
25
+ /** The browser the missing build path names (`chromium_headless_shell-1243` → `chromium`), undefined when none is parsable. */
26
+ export declare function playwrightBrowser(output: string): 'chromium' | 'firefox' | 'webkit' | undefined;
14
27
  /** True when a verification failure looks like a missing toolchain/dependency rather than a code defect. */
15
28
  export declare function isEnvironmentFailure(exitCode: number | null | undefined, output: string): boolean;
16
29
  /**
@@ -23,6 +36,10 @@ export declare function isEnvironmentFailure(exitCode: number | null | undefined
23
36
  * again). Returns a short host-facing reason, or undefined.
24
37
  */
25
38
  export declare function hostPreconditionFailure(output: string): string | undefined;
39
+ /** The blocker kind the matched precondition belongs to; undefined when the output is not a host precondition. */
40
+ export declare function hostPreconditionKind(output: string): HostPreconditionKind | undefined;
41
+ /** Classifies a host precondition with its kind, host-facing reason and the action that unblocks it. */
42
+ export declare function hostPrecondition(output: string): HostPrecondition | undefined;
26
43
  /** Packages a failure output names explicitly (`Try \`npm i --save-dev @types/jest\``); the host installs exactly those. */
27
44
  export declare function suggestedPackages(output: string): string[];
28
45
  /** Dependencies package.json declares that node_modules does not hold (bounded). */
@@ -5,6 +5,27 @@ import path from 'node:path';
5
5
  // ENOENT there, which read as an environment failure of its own and sent the
6
6
  // install/check round in circles. cross-spawn resolves the shim on every OS.
7
7
  const defaultSpawn = crossSpawn.sync;
8
+ // A Playwright test run on a host whose browser cache was never populated (a
9
+ // fresh machine, a new Playwright version): the package is installed, the
10
+ // browser build it pins is not. `playwright install` is the documented fix.
11
+ const PLAYWRIGHT_SIGNATURES = [
12
+ /browserType\.launch: Executable doesn't exist at/i,
13
+ /Looks like Playwright was just installed or updated/i,
14
+ /ms-playwright[\\/](?:chromium|firefox|webkit|chromium_headless_shell)[-_]\d+/i,
15
+ ];
16
+ /** True when a verification failure names a missing Playwright browser build. */
17
+ export function isPlaywrightBrowserFailure(output) {
18
+ const tail = output.slice(-6000);
19
+ return PLAYWRIGHT_SIGNATURES.some(pattern => pattern.test(tail));
20
+ }
21
+ /** The browser the missing build path names (`chromium_headless_shell-1243` → `chromium`), undefined when none is parsable. */
22
+ export function playwrightBrowser(output) {
23
+ const match = /ms-playwright[\\/](chromium|firefox|webkit|chromium_headless_shell)[-_]\d+/i.exec(output.slice(-6000));
24
+ if (!match)
25
+ return undefined;
26
+ const name = match[1].toLowerCase();
27
+ return name === 'chromium_headless_shell' ? 'chromium' : name;
28
+ }
8
29
  const ENVIRONMENT_SIGNATURES = [
9
30
  /command not found/i,
10
31
  /is not recognized as an internal or external command/i,
@@ -21,6 +42,7 @@ const ENVIRONMENT_SIGNATURES = [
21
42
  /Module (?:[\w@./-]+) in the \w+ option was not found|Preset [\w@./-]+ not found|Cannot find module '(?:ts-jest|babel-jest|@swc\/jest|ts-node|tsx)'/i,
22
43
  // Yarn Berry refuses to run scripts in a checkout that was never installed.
23
44
  /doesn't seem to have been installed - running an install there might help|Couldn't find the node_modules state file - running an install might help/i,
45
+ ...PLAYWRIGHT_SIGNATURES,
24
46
  ];
25
47
  /** True when a verification failure looks like a missing toolchain/dependency rather than a code defect. */
26
48
  export function isEnvironmentFailure(exitCode, output) {
@@ -40,20 +62,39 @@ const REGISTRY = String.raw `(?:registry|npmjs\.org|yarnpkg\.com|npm\.pkg\.githu
40
62
  * again). Returns a short host-facing reason, or undefined.
41
63
  */
42
64
  export function hostPreconditionFailure(output) {
65
+ return hostPrecondition(output)?.reason;
66
+ }
67
+ /** The blocker kind the matched precondition belongs to; undefined when the output is not a host precondition. */
68
+ export function hostPreconditionKind(output) {
69
+ return hostPrecondition(output)?.kind;
70
+ }
71
+ const PLAYWRIGHT_CDN = String.raw `(?:cdn\.playwright\.dev|playwright\.azureedge\.net|playwright-akamai\.azureedge\.net|playwright-verizon\.azureedge\.net)`;
72
+ /** Classifies a host precondition with its kind, host-facing reason and the action that unblocks it. */
73
+ export function hostPrecondition(output) {
43
74
  const text = output.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, '').slice(-16_000);
44
75
  const variable = /Environment variable not found \(([A-Za-z_][A-Za-z0-9_]*)\)/.exec(text);
45
76
  if (variable)
46
- return `the environment variable ${variable[1]} is not available to verification commands, and a package-manager configuration file requires it`;
77
+ return { kind: 'environment-variable', reason: `the environment variable ${variable[1]} is not available to verification commands, and a package-manager configuration file requires it`,
78
+ requiredAction: `Make ${variable[1]} available to Specrails (for example in the login shell profile it loads), then retry the run.` };
47
79
  if (/\bYN0041\b/.test(text) || /YN0035[\s\S]{0,400}?Response Code: 40[13]\b/.test(text) || /Response code 40[13] \((?:Unauthorized|Forbidden)\)/i.test(text)
48
80
  || /npm (?:ERR!|error) code (?:E401|E403|ENEEDAUTH)\b/.test(text) || /ERR_PNPM_FETCH_40[13]\b/.test(text)
49
81
  || new RegExp(String.raw `\b40[13] (?:Unauthorized|Forbidden)\b[^\n]{0,80}https?://[^\s]*${REGISTRY}`, 'i').test(text)
50
82
  || /\b(?:401|403) Client Error: (?:Unauthorized|Forbidden) for url/.test(text))
51
- return 'the package registry rejected the credentials available to verification commands (HTTP 401/403)';
83
+ return { kind: 'credential', reason: 'the package registry rejected the credentials available to verification commands (HTTP 401/403)',
84
+ requiredAction: 'Refresh the registry credentials Specrails can use (for example a new token in the login shell profile it loads), then retry the run.' };
85
+ // The browser download is checked before registry reachability: its CDN is
86
+ // not a package registry, and the fix is a different command.
87
+ if (/Failed to download (?:Chrome for Testing|Chromium|Firefox|WebKit|chromium|firefox|webkit|FFMPEG|ffmpeg)\b/i.test(text)
88
+ || new RegExp(String.raw `Error: (?:getaddrinfo ENOTFOUND|connect ETIMEDOUT|connect ECONNREFUSED|ECONNRESET|ETIMEDOUT)[^\n]{0,300}${PLAYWRIGHT_CDN}`, 'i').test(text))
89
+ return { kind: 'network', reason: 'the Playwright browser download cannot reach its CDN from the verification environment',
90
+ requiredAction: 'Download the Playwright browsers with network access (`npx playwright install`), then retry the run.' };
52
91
  if (/npm (?:ERR!|error) code (?:ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ETIMEDOUT|ECONNRESET)\b/.test(text) || /ERR_PNPM_META_FETCH_FAIL\b/.test(text)
53
92
  || new RegExp(String.raw `\b(?:ENOTFOUND|EAI_AGAIN)\b[^\n]{0,200}${REGISTRY}|${REGISTRY}[^\n]{0,200}\b(?:ENOTFOUND|EAI_AGAIN)\b`, 'i').test(text))
54
- return 'the package registry cannot be reached from the verification environment';
93
+ return { kind: 'network', reason: 'the package registry cannot be reached from the verification environment',
94
+ requiredAction: 'Give the verification environment network access to the package registry, then retry the run.' };
55
95
  if (/fatal: could not read (?:Username|Password) for/.test(text) || /Permission denied \(publickey\)/.test(text))
56
- return 'git credentials needed to fetch a dependency are not available to verification commands';
96
+ return { kind: 'credential', reason: 'git credentials needed to fetch a dependency are not available to verification commands',
97
+ requiredAction: 'Make the git credentials that fetch the dependency (an SSH key or credential helper) available to Specrails, then retry the run.' };
57
98
  return undefined;
58
99
  }
59
100
  /** Packages a failure output names explicitly (`Try \`npm i --save-dev @types/jest\``); the host installs exactly those. */
@@ -91,6 +132,22 @@ export function missingNodeDependencies(root) {
91
132
  }
92
133
  return [...declared].filter(name => !existsSync(path.join(root, 'node_modules', name))).slice(0, 20);
93
134
  }
135
+ /** Browser builds are ~150 MB each; the default 5-minute install bound is too short on a slow link. */
136
+ const PLAYWRIGHT_INSTALL_TIMEOUT_MS = 10 * 60_000;
137
+ /** True when package.json declares `@playwright/test` or `playwright`; only those repositories get a browser install plan. */
138
+ function declaresPlaywright(root) {
139
+ let manifest;
140
+ try {
141
+ manifest = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'));
142
+ }
143
+ catch {
144
+ return false;
145
+ }
146
+ return ['dependencies', 'devDependencies', 'optionalDependencies'].some(field => {
147
+ const block = manifest[field];
148
+ return !!block && typeof block === 'object' && ['@playwright/test', 'playwright'].some(name => Object.hasOwn(block, name));
149
+ });
150
+ }
94
151
  /** The installs a repository root needs, judged from its manifests and the absence of their install output. */
95
152
  export function plannedInstalls(root, failureOutput = '') {
96
153
  const plans = [];
@@ -107,6 +164,14 @@ export function plannedInstalls(root, failureOutput = '') {
107
164
  const runner = has('pnpm-lock.yaml') ? 'pnpm' : has('yarn.lock') ? 'yarn' : 'npm';
108
165
  plans.push({ ecosystem: 'node', root, command: runner, args: runner === 'npm' ? ['install', '--no-audit', '--no-fund', '--loglevel=error'] : ['install'] });
109
166
  }
167
+ // After the dependency plan: a fresh worktree needs node_modules before
168
+ // `playwright install` can run at all.
169
+ if (isPlaywrightBrowserFailure(failureOutput) && declaresPlaywright(root)) {
170
+ const runner = has('pnpm-lock.yaml') ? 'pnpm' : has('yarn.lock') ? 'yarn' : 'npx';
171
+ const browser = playwrightBrowser(failureOutput);
172
+ plans.push({ ecosystem: 'node', root, command: runner, args: [...(runner === 'pnpm' ? ['exec'] : []), 'playwright', 'install', ...(browser ? [browser] : [])],
173
+ timeoutMs: PLAYWRIGHT_INSTALL_TIMEOUT_MS, installs: `Playwright ${browser ?? 'browsers'}` });
174
+ }
110
175
  if ((has('requirements.txt') || has('pyproject.toml')) && !has('.venv') && !has('venv')) {
111
176
  const python = process.platform === 'win32' ? 'python' : 'python3';
112
177
  plans.push({ ecosystem: 'python', root, command: python, args: has('requirements.txt') ? ['-m', 'pip', 'install', '-q', '-r', 'requirements.txt'] : ['-m', 'pip', 'install', '-q', '-e', '.'] });
@@ -152,7 +217,7 @@ export function installEnvironment(roots, io = {}) {
152
217
  io.onEvent?.({ kind: 'tool-start', tool: plan.command, detail: `${plan.args.join(' ')} (${path.basename(root)})` });
153
218
  const run = () => {
154
219
  try {
155
- return spawn(plan.command, plan.args, { cwd: root, encoding: 'utf8', timeout: io.timeoutMs ?? 5 * 60_000, windowsHide: true, env: process.env });
220
+ return spawn(plan.command, plan.args, { cwd: root, encoding: 'utf8', timeout: plan.timeoutMs ?? io.timeoutMs ?? 5 * 60_000, windowsHide: true, env: process.env });
156
221
  }
157
222
  catch (error) {
158
223
  return { status: null, error: error, stdout: '', stderr: '', pid: 0, output: [], signal: null };
@@ -182,7 +247,7 @@ export function installEnvironment(roots, io = {}) {
182
247
  }
183
248
  io.onEvent?.({ kind: 'tool-end', tool: plan.command });
184
249
  const ok = result.status === 0;
185
- const detail = ok ? `installed ${plan.ecosystem} dependencies in ${path.basename(root)}` : `${plan.command} ${plan.args.join(' ')} failed in ${path.basename(root)}: ${String(result.stderr || result.error?.message || `exit ${result.status}`).trim().slice(0, 400)}`;
250
+ const detail = ok ? `installed ${plan.installs ?? `${plan.ecosystem} dependencies`} in ${path.basename(root)}` : `${plan.command} ${plan.args.join(' ')} failed in ${path.basename(root)}: ${String(result.stderr || result.error?.message || `exit ${result.status}`).trim().slice(0, 400)}`;
186
251
  io.onEvent?.({ kind: 'text', text: `Environment: ${detail}` });
187
252
  const precondition = ok ? undefined : hostPreconditionFailure(`${result.stdout ?? ''}\n${result.stderr ?? ''}`);
188
253
  outcomes.push({ ...plan, ok, detail, ...(precondition ? { precondition } : {}) });
@@ -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,6 +1,7 @@
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';
@@ -10,7 +11,7 @@ import { boundedText, historyEntry, idSchema, json, paramsSchema, invocationTime
10
11
  const ajv = new Ajv2020({ allErrors: true, strict: true, validateFormats: false, ownProperties: true });
11
12
  export function roleTurnPiece(bindings) {
12
13
  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' },
14
+ 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
15
  getOutcomes: params => params.structuredOutput ? ['next', 'invalid', 'failed'] : ['next', 'failed'],
15
16
  getEffect: (params, roles) => {
16
17
  const role = roles[text(params.roleId)];
@@ -45,7 +46,8 @@ export async function executeRoleTurn(deps, params, context, options = {}) {
45
46
  const priorNote = !previous && descriptor.access === 'read' && saved?.session && typeof saved.notes?.value.text === 'string'
46
47
  ? saved.notes.value.text.slice(0, 4000) : '';
47
48
  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;
49
+ const verification = descriptor.access === 'write' ? hostVerificationPlan(deps, context, params) : undefined;
50
+ const full = roleInstructions(descriptor, deps.context, openspec?.[roleId]?.change, { definition: descriptor.prompt, verification }) + '\n## Current workflow task\n' + task;
49
51
  const result = await invoke(roleId, deps.stepContext(context), { prompt: previous ? task : full, ...(previous ? { resumeSessionId: previous, fallbackPrompt: full } : {}),
50
52
  structured: schema !== undefined, lenient: options.normalizeStructuredOutput !== undefined, outputSchema: schema, timeoutMs: params.timeoutMs, idleTimeoutMs: params.idleTimeoutMs }, (output, responseText) => {
51
53
  const normalized = options.normalizeStructuredOutput ? options.normalizeStructuredOutput(output, responseText) : output;
@@ -71,3 +73,34 @@ export async function executeRoleTurn(deps, params, context, options = {}) {
71
73
  return { outcome: 'next', output: { candidateHash: deps.executionSnapshot(context).candidate?.hash ?? null, text: boundedText(result.text), ...(result.value ? { structured: json(result.value) } : {}) },
72
74
  history: [historyEntry(context, result.text)], ...(params.sessionContinuity !== 'none' && session ? { session: { sessionId: session.sessionId, identity: session.identity } } : {}) };
73
75
  }
76
+ /** The complete plan the host will run for the repositories in scope: configured checks plus, when `verificationProposalsFrom`
77
+ * names a committed output, its proposals for repositories without a configured check (the rule the verify piece applies). */
78
+ function hostVerificationPlan(deps, context, params) {
79
+ const inScope = new Set(deps.context.repositories.map(repository => repository.id));
80
+ const commands = deps.config.verification.filter(command => inScope.has(command.repositoryId)).map(command => withScopeDefault(deps.context, command));
81
+ const configured = new Set(commands.map(command => command.repositoryId));
82
+ const seen = new Set(commands.map(planKey));
83
+ if (typeof params.verificationProposalsFrom === 'string') {
84
+ const source = context.state.$outputs[params.verificationProposalsFrom];
85
+ const proposals = Array.isArray(source?.structured?.verification) ? source.structured.verification.slice(0, 20) : [];
86
+ for (const proposal of proposals) {
87
+ if (!proposal || typeof proposal !== 'object' || Array.isArray(proposal))
88
+ continue;
89
+ const raw = proposal;
90
+ if (typeof raw.repositoryId !== 'string' || typeof raw.command !== 'string' || !Array.isArray(raw.args) || !raw.args.every(arg => typeof arg === 'string'))
91
+ continue;
92
+ if (!inScope.has(raw.repositoryId) || configured.has(raw.repositoryId))
93
+ continue;
94
+ const command = withScopeDefault(deps.context, { repositoryId: raw.repositoryId, command: raw.command, args: raw.args, ...(typeof raw.cwd === 'string' ? { cwd: raw.cwd } : {}) });
95
+ const key = planKey(command);
96
+ if (seen.has(key))
97
+ continue;
98
+ seen.add(key);
99
+ commands.push(command);
100
+ }
101
+ }
102
+ return commands;
103
+ }
104
+ function planKey(command) {
105
+ return JSON.stringify([command.repositoryId, command.cwd ?? '', command.command, command.args]);
106
+ }
@@ -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;
@@ -5,6 +5,9 @@ import { executeVerification, validateVerificationRequest } from '../../../pipel
5
5
  import { withScopeDefault } from '../../change-scope.js';
6
6
  import { verificationDiagnosticPriority, verificationFailureSummary } from '../../verification-diagnostics.js';
7
7
  import { boundedText, json, paramsSchema, positiveInteger } from './shared.js';
8
+ import { isEnvironmentFailure } from '../../compact/environment.js';
9
+ import { guardrailEnabled } from '../../guardrails.js';
10
+ import { checkoutRelative, preconditionBlock, repairEnvironment } from '../../verification-repair.js';
8
11
  /** Keep real subprocess diagnostics in committed outputs for subsequent agents.
9
12
  * The full command evidence remains in the receipt; prioritize failed checks
10
13
  * when the output budget is exhausted. */
@@ -75,50 +78,118 @@ export function verificationDeadline(deps, context) {
75
78
  const budget = deps.stepContext(context).remainingBudget();
76
79
  return budget.maxDurationMs === undefined ? undefined : Date.now() + Math.max(0, budget.maxDurationMs);
77
80
  }
81
+ const VERIFY_OUTCOMES = ['pass', 'fail', 'failed'];
82
+ const verificationCommandList = { type: 'array', maxItems: 100, items: verificationCommandSchema };
83
+ /**
84
+ * The complete host plan for a `verify` node: configured checks (or the inline
85
+ * list) plus the proposals of the node named in `additionalCommandsFrom` for
86
+ * repositories without a configured check. Role turns show write roles this
87
+ * same list, so the developer never passes a check the host does not run.
88
+ */
89
+ export function resolveVerificationPlan(deps, context, params) {
90
+ const configured = params.commands === undefined || params.commands === 'configured' ? deps.config.verification : params.commands;
91
+ const commands = configured.map(command => withScopeDefault(deps.context, command));
92
+ const hostRepositories = new Set(commands.map(command => command.repositoryId));
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
+ const raw = proposal;
104
+ if (!deps.context.repositories.some(repository => repository.id === raw.repositoryId))
105
+ throw new Error('Unknown verification repository');
106
+ if (!hostRepositories.has(raw.repositoryId))
107
+ commands.push(withScopeDefault(deps.context, raw));
108
+ }
109
+ }
110
+ return commands;
111
+ }
112
+ /** A setup command that exited non-zero: the host declared it, so only the host can fix or remove it. */
113
+ function setupBlocker(deps, command) {
114
+ const cwd = checkoutRelative(deps.context, command.cwd);
115
+ 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,
116
+ 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 } : {}) };
117
+ }
78
118
  export function verifyPiece(bindings) {
79
119
  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' },
120
+ descriptor: { kind: 'verify', paramsSchema: paramsSchema({ commands: { oneOf: [{ const: 'configured' }, verificationCommandList] },
121
+ additionalCommandsFrom: { type: 'string', minLength: 1, maxLength: 128 }, unverified: { type: 'boolean' }, maxConcurrency: { type: 'integer', minimum: 1, maximum: 4 },
122
+ setup: { oneOf: [{ const: 'configured' }, verificationCommandList] }, hostBlockers: { type: 'boolean' } }, ['commands']), outcomes: [...VERIFY_OUTCOMES, 'blocked'], effect: 'write', requiresAI: false, storeAccess: 'write' },
123
+ // `blocked` is opt-in so definitions published before host blockers keep their exact `ends`.
124
+ getOutcomes: params => params.hostBlockers === true ? [...VERIFY_OUTCOMES, 'blocked'] : VERIFY_OUTCOMES,
82
125
  async execute(params, context) {
83
126
  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
- }
127
+ const commands = resolveVerificationPlan(deps, context, params);
103
128
  const coversAll = deps.context.repositories.every(repository => commands.some(command => command.repositoryId === repository.id));
104
129
  const request = { kind: coversAll || params.unverified === true ? 'full' : 'scoped', commands, ...(params.unverified === true ? { unverified: true } : {}) };
105
130
  validateVerificationRequest(deps.context, request);
131
+ // Setup is validated before anything runs: a setup entry escaping the workspace fails exactly like a check would.
132
+ const setupCommands = (params.setup === 'configured' ? deps.config.setup ?? [] : params.setup ?? [])
133
+ .map(({ policy: _policy, ...command }) => withScopeDefault(deps.context, command));
134
+ const setupRequest = { kind: 'scoped', commands: setupCommands };
135
+ if (setupCommands.length)
136
+ validateVerificationRequest(deps.context, setupRequest);
137
+ const progress = (text) => context.progress({ type: 'verification-output', payload: { text } });
138
+ const concurrency = params.maxConcurrency ?? deps.config.efficiency?.verification?.maxConcurrency;
139
+ const run = (plan, maxConcurrency) => executeVerification(deps.context, plan, deps.verification(context), progress, context.signal, {
140
+ deadline: verificationDeadline(deps, context), maxConcurrency, idleTimeoutMs: deps.config.guardrails?.['verify-idle-timeout'] === false ? 0 : undefined,
141
+ onEvidence: async (kind, payload) => context.progress({ type: 'runtime-efficiency-event', payload: { kind, ...payload } }),
142
+ });
106
143
  const identity = { candidateHash: deps.executionSnapshot(context).candidate?.hash ?? null, scopeId: context.frame.scope.id,
107
144
  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 };
145
+ planHash: contentDigest(json(request)), concurrency: concurrency ?? 1 };
146
+ const infrastructureFailure = (receipt) => receipt.commands.some(command => command.exitCode === -1 && command.outcome !== 'cancelled');
147
+ const executionError = (receipt) => ({ status: 'failed', error: { code: 'verification_execution_error', message: receipt.reason ?? 'A verification command could not complete' } });
148
+ const blocked = (blocker, output) => ({
149
+ outcome: params.hostBlockers === true ? 'blocked' : 'fail',
150
+ ...(params.hostBlockers === true ? { status: 'blocked', error: { code: 'verification_host_precondition', message: `${blocker.reason} ${blocker.requiredAction}` } } : {}),
151
+ output: { ...output, valid: false, candidateHash: identity.candidateHash, blocker: json(blocker) }, verified: null,
152
+ });
153
+ // Setup never installs a verified candidate or feeds the no-progress
154
+ // fingerprint: it prepares the workspace, it does not judge the change.
155
+ let setup;
156
+ if (setupCommands.length) {
157
+ const receipt = await run(setupRequest, 1);
158
+ setup = verificationDiagnostics(receipt);
159
+ if (infrastructureFailure(receipt))
160
+ return { outcome: 'failed', ...executionError(receipt), output: { valid: false, candidateHash: identity.candidateHash, reason: receipt.reason ?? 'A setup command could not complete', setup }, verified: null };
161
+ const failed = receipt.commands.find(command => command.exitCode !== 0);
162
+ if (failed)
163
+ return blocked(setupBlocker(deps, failed), { reason: 'A setup command failed', setup });
164
+ }
109
165
  const memoryKey = contentDigest(identity);
110
166
  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
- });
167
+ let receipt = await run(request, concurrency);
168
+ let blocker = receipt.valid ? undefined : preconditionBlock(deps.context, receipt);
169
+ // A missing toolchain or dependency (exit 127, "command not found", a
170
+ // Playwright browser build) is the host's to fix, not the fixer's: a
171
+ // fresh worktree has no node_modules whatever wrote the code. Install
172
+ // once where the failing command runs and verify again; only the second
173
+ // receipt becomes feedback.
174
+ let environmentRepair;
175
+ if (!blocker && !receipt.valid && guardrailEnabled(deps.config.guardrails, 'environment-repair') && receipt.commands.some(command => isEnvironmentFailure(command.exitCode, command.output))) {
176
+ const repair = repairEnvironment(deps.context, receipt, deps.config.guardrails, progress);
177
+ 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) })) };
178
+ if (repair.refused)
179
+ blocker = repair.refused;
180
+ else if (repair.installs.some(install => install.ok)) {
181
+ progress('[environment] Verification failed on the environment (missing dependencies or tools); the host installed them and is verifying again.');
182
+ receipt = await run(request, concurrency);
183
+ environmentRepair.reverified = true;
184
+ blocker = receipt.valid ? undefined : preconditionBlock(deps.context, receipt);
185
+ }
186
+ }
116
187
  await advisoryMemory(context, () => deps.memory(context).put(['verification', 'known-commands'], memoryKey, {
117
188
  ...identity, observations: Math.min(1_000_000, typeof known?.value.observations === 'number' ? known.value.observations + 1 : 1),
118
189
  lastAttemptId: context.frame.attemptId, lastReceiptId: receipt.id, lastValid: receipt.valid,
119
190
  commands: receipt.commands.map(command => ({ repositoryId: command.repositoryId, command: command.command, exitCode: command.exitCode })),
120
191
  }));
121
- const infrastructure = receipt.commands.some(command => command.exitCode === -1 && command.outcome !== 'cancelled');
192
+ const infrastructure = infrastructureFailure(receipt);
122
193
  const outcome = infrastructure ? 'failed' : receipt.valid ? 'pass' : 'fail';
123
194
  const progressKey = 'verification-' + contentDigest(context.frame.nodePath).slice(0, 20);
124
195
  const fingerprint = contentDigest(json({ candidateHash: identity.candidateHash,
@@ -127,8 +198,14 @@ export function verifyPiece(bindings) {
127
198
  const count = receipt.valid ? 0 : previous?.fingerprint === fingerprint ? (previous.count ?? 0) + 1 : 1;
128
199
  const stalled = !infrastructure && !receipt.valid && count >= 3;
129
200
  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) },
201
+ 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' } : {}),
202
+ ...(setup ? { setup } : {}), ...(environmentRepair ? { environmentRepair } : {}), ...verificationDiagnostics(receipt) };
203
+ const vars = { [progressKey]: { fingerprint, count } };
204
+ // Without the opt-in outcome a blocker still travels in the output, and the no-progress stop keeps bounding the loop.
205
+ if (blocker && (params.hostBlockers === true || !stalled))
206
+ return { ...blocked(blocker, output), vars, receipt: receiptEvidence(receipt) };
207
+ 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) : {}),
208
+ output: { ...output, ...(blocker ? { blocker: json(blocker) } : {}) },
132
209
  receipt: receiptEvidence(receipt), ...(verified ? { verified: { receiptId: receipt.id, candidateHash: receipt.candidateHash, atTransition: context.frame.transition, revision: context.frame.transition } } : { verified: null }) };
133
210
  },
134
211
  };
@@ -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?: {
@@ -1,14 +1,14 @@
1
1
  import { guardrailEnabled } from '../guardrails.js';
2
2
  import { verificationFailureSummary } from '../verification-diagnostics.js';
3
3
  import { addDeveloperChecks, bindPlan, expandedPlanCommands, initializeVerificationPlan, readVerificationPlan, validateProposedChecks } from '../verification-plan.js';
4
- import { existsSync } from 'node:fs';
5
4
  import path from 'node:path';
6
5
  import { OpenSpecTools } from '../openspec.js';
7
6
  import { candidateManifest, fingerprintCandidate, frozenAcceptanceCriteria, inspectPipeline, recordAcceptance, transitionPipeline, validateAcceptanceReport, verifyPipeline, } from '../../pipeline/pipeline-state.js';
8
7
  import { ARCHITECT_OUTPUT_SCHEMA, DEVELOPER_OUTPUT_SCHEMA, REVIEW_OUTPUT_SCHEMA, correctionInstructions, deepenInstructions, roleInstructions } from '../prompts.js';
9
8
  import { archive, child, journal, object, parseArchitecture, proposedVerification, write, writeDesignConfidence } from './artifacts.js';
10
9
  import { evaluateReview } from './review-policy.js';
11
- import { hostPreconditionFailure, installEnvironment, isEnvironmentFailure } from '../compact/environment.js';
10
+ import { isEnvironmentFailure } from '../compact/environment.js';
11
+ import { hostPreconditionMessage, preconditionBlock, repairEnvironment } from '../verification-repair.js';
12
12
  import { unreachedTestFiles, unreachedTestsReason } from '../compact/test-reachability.js';
13
13
  import { exitCodeContradiction, exitHonestyReason } from '../compact/exit-code-honesty.js';
14
14
  import { boundedReviewManifest, reviewChanges } from '../review-context.js';
@@ -287,52 +287,6 @@ function fixerNode(deps) {
287
287
  },
288
288
  };
289
289
  }
290
- /** Where a missing dependency install belongs: the nearest directory with a lockfile or manifest above the failing command, else the repository root. */
291
- const INSTALL_MARKERS = ['package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lock', 'bun.lockb', 'requirements.txt', 'pyproject.toml', 'go.mod', 'Cargo.toml'];
292
- function installRoots(context, commands) {
293
- const roots = [];
294
- for (const command of commands) {
295
- const repository = context.repositories.find(repo => repo.id === command.repositoryId);
296
- if (!repository)
297
- continue;
298
- let directory = command.cwd;
299
- let chosen = repository.path;
300
- for (;;) {
301
- const relative = path.relative(repository.path, directory);
302
- if (relative.startsWith('..') || path.isAbsolute(relative))
303
- break;
304
- if (INSTALL_MARKERS.some(name => existsSync(path.join(directory, name)))) {
305
- chosen = directory;
306
- break;
307
- }
308
- if (!relative)
309
- break;
310
- directory = path.dirname(directory);
311
- }
312
- roots.push(chosen);
313
- }
314
- // Every repository root stays a candidate: its plan is a no-op when it is already installed.
315
- return [...new Set([...roots, ...context.repositories.map(repository => repository.path)])];
316
- }
317
- /** A directory as the log should name it: relative to the checkout that contains it. */
318
- function checkoutRelative(context, directory) {
319
- const repository = context.repositories.find(repo => { const relative = path.relative(repo.path, directory); return !relative.startsWith('..') && !path.isAbsolute(relative); });
320
- return repository ? path.relative(repository.path, directory).split(path.sep).join('/') || '.' : directory;
321
- }
322
- function hostPreconditionMessage(reason, command, args, directory) {
323
- return `Verification cannot run in this environment: ${reason} (\`${[command, ...args].join(' ')}\` in ${directory}). This is not a defect in the change, so no correction round was started. Make it available to Specrails (for example in the login shell profile Specrails loads, or with a refreshed registry token), then resume.`;
324
- }
325
- /** A failed check the host must unblock (credentials, registry access, a missing variable): no correction round can repair it. */
326
- function preconditionBlock(context, receipt) {
327
- for (const command of receipt.commands) {
328
- if (command.exitCode === 0)
329
- continue;
330
- const reason = hostPreconditionFailure(command.output);
331
- if (reason)
332
- return hostPreconditionMessage(reason, command.command, command.args, checkoutRelative(context, command.cwd));
333
- }
334
- return undefined;
335
- }
336
290
  function verifyNode(deps) {
337
291
  const { context, note, config } = deps;
338
292
  const roots = context.repositories.map(repository => repository.path);
@@ -388,25 +342,25 @@ function verifyNode(deps) {
388
342
  note('fixer', 'Verification failed on the environment, not on the change; stopping for the host instead of starting a correction round.');
389
343
  return { status: 'blocked', error: reason, usage: NO_SPEND };
390
344
  };
345
+ const blockerMessage = (blocker) => hostPreconditionMessage(blocker.reason, blocker.command, blocker.args, blocker.cwd);
391
346
  let precondition = receipt.valid ? undefined : preconditionBlock(context, receipt);
392
347
  if (precondition)
393
- return hostBlocked(precondition);
348
+ return hostBlocked(blockerMessage(precondition));
394
349
  // A failure that is really a missing toolchain or dependency (exit 127,
395
350
  // "command not found", "Cannot find module"…) is the HOST's to fix, not
396
351
  // the model's — for every developer: a fresh worktree has no
397
352
  // node_modules whatever wrote the code. Install once where the failing
398
353
  // command runs and re-verify; only a second failure becomes feedback.
399
354
  if (!receipt.valid && guardrailEnabled(config.guardrails, 'environment-repair') && receipt.commands.some(command => isEnvironmentFailure(command.exitCode, command.output))) {
400
- const installs = installEnvironment(installRoots(context, receipt.commands.filter(command => command.exitCode !== 0)), { failureOutput: receipt.commands.map(command => command.output).join('\n'), lockfileRepair: guardrailEnabled(config.guardrails, 'lockfile-repair'), onEvent: event => note('developer', event.kind === 'text' ? event.text ?? '' : `[environment] ${event.tool ?? ''} ${event.detail ?? ''}`.trim()) });
401
- const refused = installs.find(outcome => outcome.precondition);
402
- if (refused)
403
- return hostBlocked(hostPreconditionMessage(`installing its dependencies failed because ${refused.precondition}`, refused.command, refused.args, checkoutRelative(context, refused.root)));
404
- if (installs.some(outcome => outcome.ok)) {
355
+ const repair = repairEnvironment(context, receipt, config.guardrails, text => note('developer', text));
356
+ if (repair.refused)
357
+ return hostBlocked(blockerMessage(repair.refused));
358
+ if (repair.installs.some(outcome => outcome.ok)) {
405
359
  note('developer', 'Verification failed on the environment (missing dependencies or tools); the host installed them and is verifying again.');
406
360
  receipt = await run();
407
361
  precondition = receipt.valid ? undefined : preconditionBlock(context, receipt);
408
362
  if (precondition)
409
- return hostBlocked(precondition);
363
+ return hostBlocked(blockerMessage(precondition));
410
364
  }
411
365
  }
412
366
  const evidence = {
@@ -3,7 +3,7 @@ import type { PipelineContext, VerificationCommand } from '../pipeline/pipeline-
3
3
  import { type ReviewPolicy } from './graph/review-policy.js';
4
4
  import type { DeveloperRecord } from './graph/state.js';
5
5
  /** Bump whenever the wording changes: the version is part of the frozen run identity. */
6
- export declare const ROLE_INSTRUCTIONS_VERSION = "13";
6
+ export declare const ROLE_INSTRUCTIONS_VERSION = "14";
7
7
  export interface RoleFeedback {
8
8
  verification?: unknown;
9
9
  review?: unknown;
@@ -1,7 +1,7 @@
1
1
  import { isBuiltinRole } from './executor-types.js';
2
2
  import { DEFAULT_REVIEW_POLICY, REVIEW_ASPECTS } from './graph/review-policy.js';
3
3
  /** Bump whenever the wording changes: the version is part of the frozen run identity. */
4
- export const ROLE_INSTRUCTIONS_VERSION = '13';
4
+ export const ROLE_INSTRUCTIONS_VERSION = '14';
5
5
  const OUTPUT_TAIL = 6_000;
6
6
  /** One senior engineer, rendered once per role prompt (never repeated inside it): the identity the implement pipeline's roles share. */
7
7
  const ENGINEER = 'a T-shaped principal engineer with decades of delivery across web, mobile and backend systems: deep in software design and testing, broad across product, UX, data, security, infrastructure and operations; fluent in hexagonal (ports and adapters) architecture, SOLID, design patterns, Clean Code, The Pragmatic Programmer, refactoring, legacy-code seams and AI-assisted development. You work for the user, the operator and the next maintainer, and you treat AI-generated code, including your own, with the scepticism owed to any untrusted contribution.';
@@ -116,7 +116,7 @@ function boundarySection(role, custom = false) {
116
116
  role === 'developer'
117
117
  ? [
118
118
  '- Edit only the repositories in scope, and only inside a repository\'s scope when it declares one. Prefer the smallest change that fully satisfies the tasks; do not refactor, reformat, rename or upgrade unrelated code, tests or configuration. On a fixer turn, a failing host test in that scope may receive the minimal compatibility repair described in the correction instructions; preserve its required behavior and assertions. Other unrelated problems belong in your summary.',
119
- '- Never edit package-manager, registry, credential, CI or environment configuration (for example `.npmrc`, `.yarnrc*`, `.env*`, CI workflows) to make a command work. A missing credential, environment variable, registry access or tool is the host\'s to fix: report it under `incomplete` with the exact error.',
119
+ '- Never edit package-manager, registry, credential, CI or environment configuration (for example `.npmrc`, `.yarnrc*`, `.env*`, CI workflows) to make a command work. A missing credential, environment variable, registry access or tool is the host\'s to fix: report it under `incomplete` with the exact error. Installing a documented, idempotent toolchain artifact inside the admitted workspace (for example a Playwright browser with `npx playwright install <browser>`, or a Python virtualenv) is allowed and must be reported under verification; editing package-manager, registry, credential, CI or environment configuration remains forbidden.',
120
120
  '- Check with the narrowest command that covers what you touched (the specific test files or test names), never the whole checkout or every workspace; Core runs the complete verification plan after your turn.',
121
121
  ].join('\n')
122
122
  : role === 'architect' ? '- Read code without modifying it. Author OpenSpec artifacts only through the supplied scoped workflow tools.' + (custom ? '' : ' Return confidence and verification metadata in JSON.') : '- This role is read-only. Do not create, edit or delete files' + (custom ? '.' : '; return your result as the requested JSON object.'),
@@ -191,7 +191,7 @@ function fixerSection(verification, definition) {
191
191
  '4. Fix the cause, not the symptom: a repair that satisfies the assertion while leaving the defect, or that special-cases the test input, is not a fix. Fix incorrect implementation behavior. A failing mandatory host test inside the admitted repository/workspace may also receive a minimal compatibility repair when you prove that its expected behavior is unchanged. For example, a source assertion broken only by formatting may tolerate whitespace while retaining every required operand and guard. This applies even to a pre-existing test outside the current changed-file list. Explain the original expectation and why the repair still rejects the prohibited behavior. Never weaken an assertion, delete or skip a test, hardcode success, change acceptance criteria, add dependencies or widen the repair beyond the diagnosed failure.',
192
192
  '5. A test file the host reports as never executed must be wired into the repository\'s test command (the test script or runner configuration) and then made to pass.',
193
193
  '6. Confirm the repair with the focused test command and a negative case for any repaired assertion: removing a required safety guard or returning the wrong value must still fail. Preserve the original test exit status; never pipe test execution through grep/head or another command that masks it. Capture stdout/stderr in a temporary log and inspect that log separately. Core runs the complete verification plan after your turn.',
194
- '7. Inspect only the reported files, direct dependencies and the evidence needed to establish the cause. Never edit outside the admitted repository/workspace, credentials, environment or unrelated configuration. For an external or unrepairable scope blocker, make no speculative edits and report the exact error, observed evidence and required action. `proposal.md`, `design.md` and the specs remain frozen; only tick completed tasks.',
194
+ '7. Inspect only the reported files, direct dependencies and the evidence needed to establish the cause. Never edit outside the admitted repository/workspace, credentials, environment or unrelated configuration. When the diagnosed cause is a host precondition or environment blocker (missing network, credentials, environment variable, toolchain, setup command or an out-of-scope repository), make no speculative edits, state in summary that the candidate was intentionally left unchanged, and return `blocker` as {"kind":"network|credential|environment-variable|toolchain|setup|environment|scope","command":"…","cwd":"…","evidence":"exact error","requiredAction":"one imperative sentence the host can act on"}. `proposal.md`, `design.md` and the specs remain frozen; only tick completed tasks.',
195
195
  '8. Return the requested JSON contract. Include the diagnosis, exact focused commands and original exit codes, repair and preserved assertion in summary. List every unresolved failure and its reason under incomplete, even when the implementation tasks are already ticked. A failed test remains failed until real host verification passes.',
196
196
  ] : [definition, ''];
197
197
  return [...lines, ...developerTail(verification)];
@@ -208,7 +208,7 @@ function developerSection(verification, corrections, definition) {
208
208
  '1. Load and execute openspec-apply-change through the supplied binding. Consult its status and instructions apply, read the context files OpenSpec returns, then the relevant existing code and tests.',
209
209
  '2. Work task by task in order. Use test-driven development: write or extend the test first, make it pass with the smallest correct change, then tidy up. Run only focused tests that cover what you touched while iterating. Core owns the complete verification plan and runs it after your turn; do not duplicate that full run. Fix the precise failures Core returns on a correction pass.',
210
210
  '3. Immediately after completing each task, mark it `- [x]` in `tasks.md`; do not postpone all progress updates until the end of the phase. Only mark tasks whose code and tests are complete. Change nothing else in `tasks.md`, and never edit `proposal.md`, `design.md` or the specs: those documents are frozen, and editing them invalidates the run. If a task cannot be completed, leave it `- [ ]` and list it under `incomplete` with the reason.',
211
- '4. Keep the implementation consistent with the repository: naming, error handling, import style, formatting and existing utilities. Do not add dependencies unless the design requires them. Change only what the tasks require: the diff should contain the requested change and its tests, nothing else.',
211
+ '4. Keep the implementation consistent with the repository: naming, error handling, import style, formatting and existing utilities. Do not add dependencies unless the design requires them. Change only what the tasks require: the diff should contain the requested change and its tests, nothing else. Never validate with a temporary configuration, alternate runner or local browser the host verification plan does not use, and never delete such a file to hide it: the host runs the plan as-is. When a required tool is missing, install it through the project\'s documented command or report it as a blocker.',
212
212
  'Blast radius: the files design.md and tasks.md name are your boundary. Touch a file outside it only when a task cannot be completed otherwise; keep that edit to the strict need and explain it in summary. Inside a file, change only the lines the task needs: no reformatting, import reordering, renames, type widening, comment rewrites or "while I am here" fixes. Leave an unrelated problem alone and mention it in summary. Keep public signatures, exported contracts, schemas and persisted formats as the design states. Never delete or rewrite a test a task does not name, and never regenerate lockfiles, snapshots or generated files unless the task\'s own change requires it.',
213
213
  'Quality bar: write code a senior maintainer would merge unchanged. Intention-revealing names from the domain vocabulary; small functions that do one thing at one level of abstraction; guard clauses over nested conditionals; no boolean flag parameters, magic values or hidden side effects; immutability by default and explicit types at module boundaries; parse and validate at the boundary, trust typed values inside; every error path handled the way neighbouring code handles it (fail fast at the boundary, never swallow, errors carry context); domain logic out of adapters and dependencies pointing inward as the repository already does; comments explain why, never what; no dead code, commented-out code, debug output, TODO placeholders or speculative options.',
214
214
  'Engineering judgement: understand before you change and never program by coincidence (if you cannot explain why it works, you are not done); never delete or bypass a guard, branch or workaround you cannot explain. Handle what tests rarely reach: empty, huge and malformed inputs, boundary values, time zones and Unicode, partial failures, retries with idempotency, races, cancellation, timeouts and resource cleanup (handles, listeners, subscriptions, temp files). Security hygiene is non-negotiable: parameterized queries, escaped output, no secrets or personal data in code or logs, least privilege, authorization where the repository enforces it. Logs and metrics follow the repository\'s conventions; migrations and persisted-format changes stay additive and backward compatible. Verify every API, signature and option against the source or installed types, never from memory. Do the simplest thing that fully works, then refactor only inside the blast radius. Tests are the specification: one behavior per test (arrange, act, assert) covering inputs, outputs, side effects and errors; no logic in tests; mock only at real boundaries; deterministic; failing without the change; in the repository\'s existing style. A task is done only when its code, tests and every artifact it makes stale (localized strings in every shipped locale, documentation, schemas, configuration examples) are updated and its focused checks pass.',
@@ -220,14 +220,18 @@ function developerSection(verification, corrections, definition) {
220
220
  return [...lines, ...developerTail(verification)];
221
221
  }
222
222
  /** Verification commands, durable-progress guidance and the output contract shared by the developer and fixer stances. */
223
+ /** The complete host verification plan a write role must see: what Core runs after the turn, so nothing is validated through a bypass the plan does not use. */
224
+ function hostPlanSection(verification) {
225
+ if (!verification?.length)
226
+ return [];
227
+ return [
228
+ '', 'Core owns these complete verification commands and will run them after your turn. Use focused tests while iterating instead of repeating this plan:',
229
+ ...verification.map(command => `- repository \`${command.repositoryId}\`${command.cwd ? ' in `' + command.cwd + '`' : ''}: \`${shellWords(command)}\``),
230
+ ];
231
+ }
223
232
  function developerTail(verification) {
224
- const lines = [];
225
- if (verification?.length) {
226
- lines.push('', 'Core owns these complete verification commands and will run them after your turn. Use focused tests while iterating instead of repeating this plan:');
227
- for (const command of verification)
228
- lines.push(`- repository \`${command.repositoryId}\`${command.cwd ? ' in `' + command.cwd + '`' : ''}: \`${shellWords(command)}\``);
229
- }
230
- lines.push('', '## Durable implementation progress', '', 'When loading the official apply skill, read its savedProgress handoff before repeating investigation. Reconcile it with the current diff and tasks; it is advisory history, never accepted verification evidence. Preserve completed work and previously discovered test commands or environment blockers. Recheck a blocker only when its relevant condition changed.', 'After each completed task, and whenever the next action or a blocker changes, call the scoped workflow action write_progress with progress: {summary, completedTasks, nextTasks, checks: [{command, outcome}], blockers}. Replace the previous handoff with a concise current account (at most 8 KB); include exact focused test commands, original exit outcomes, relevant paths and the next discriminating action. Keep nextTasks concrete enough for a fresh session to continue unfinished work. Do not include full logs, credentials or claims that old checks authorize acceptance. The host persists this record outside the frozen OpenSpec artifacts. Use read_progress to refresh it. If interrupted, the next session receives it from load_skill; do not write runtime files directly.', '', '## Output contract', '', 'Finish with exactly one JSON object and nothing after it: no prose after the object, no Markdown fence.', '', '```', '{"summary":"What you implemented and how","files":["src/feature.ts"],"tests":["src/feature.test.ts"],"verification":"npm test passed (12 tests)","incomplete":[{"task":"3. …","reason":"why it could not be completed"}]}', '```', '', '- `files` and `tests`: repository-relative paths you created or modified (test files appear in `tests`, other files in `files`).', '- `verification`: the commands you ran and their outcome, or `none` when no shell was available.', '- `incomplete`: every task still `- [ ]` in `tasks.md`, with its reason; an empty array when everything is done.', '- Do not paste full test logs; the reviewer reads the real verification evidence separately.', '');
233
+ const lines = [...hostPlanSection(verification)];
234
+ lines.push('', '## Durable implementation progress', '', 'When loading the official apply skill, read its savedProgress handoff before repeating investigation. Reconcile it with the current diff and tasks; it is advisory history, never accepted verification evidence. Preserve completed work and previously discovered test commands or environment blockers. Recheck a blocker only when its relevant condition changed.', 'After each completed task, and whenever the next action or a blocker changes, call the scoped workflow action write_progress with progress: {summary, completedTasks, nextTasks, checks: [{command, outcome}], blockers}. Replace the previous handoff with a concise current account (at most 8 KB); include exact focused test commands, original exit outcomes, relevant paths and the next discriminating action. Keep nextTasks concrete enough for a fresh session to continue unfinished work. Do not include full logs, credentials or claims that old checks authorize acceptance. The host persists this record outside the frozen OpenSpec artifacts. Use read_progress to refresh it. If interrupted, the next session receives it from load_skill; do not write runtime files directly.', '', '## Output contract', '', 'Finish with exactly one JSON object and nothing after it: no prose after the object, no Markdown fence.', '', '```', '{"summary":"What you implemented and how","files":["src/feature.ts"],"tests":["src/feature.test.ts"],"verification":"npm test passed (12 tests)","incomplete":[{"task":"3. …","reason":"why it could not be completed"}],"blocker":{"kind":"toolchain","command":"npx playwright test","cwd":".","evidence":"exact error","requiredAction":"Run npx playwright install chromium"}}', '```', '', '- `files` and `tests`: repository-relative paths you created or modified (test files appear in `tests`, other files in `files`).', '- `verification`: the commands you ran and their outcome, or `none` when no shell was available.', '- `incomplete`: every task still `- [ ]` in `tasks.md`, with its reason; an empty array when everything is done.', '- `blocker`: only when the cause lies outside the change; omit otherwise. The host ends the run with your `requiredAction` instead of starting another correction round.', '- Do not paste full test logs; the reviewer reads the real verification evidence separately.', '');
231
235
  return lines;
232
236
  }
233
237
  function reviewerSection(policy, criteria, definition) {
@@ -388,7 +392,9 @@ export function roleInstructions(roleOrDescriptor, context, change, options = {}
388
392
  `Workspace access: ${roleOrDescriptor.access}. OpenSpec artifact permission: ${roleOrDescriptor.artifacts}.`, '',
389
393
  'Execute only this assigned role. Traversal, retries, verification, approval, archive and delivery belong to Core. Do not spawn another role or change runtime metadata.',
390
394
  ...boundarySection(roleOrDescriptor.access === 'write' ? 'developer' : roleOrDescriptor.artifacts === 'all' ? 'architect' : 'reviewer', true),
391
- ...conventionsSection(), ...scopeSection(context, change), ...feedbackSection(options.feedback),
395
+ ...conventionsSection(), ...scopeSection(context, change),
396
+ ...(roleOrDescriptor.access === 'write' ? hostPlanSection(options.verification) : []),
397
+ ...feedbackSection(options.feedback),
392
398
  ].join('\n').trimEnd() + '\n';
393
399
  }
394
400
  const feedback = feedbackSection(options.feedback);
@@ -0,0 +1,26 @@
1
+ import type { spawnSync } from 'node:child_process';
2
+ import type { CommandReceipt, PipelineContext, VerificationReceipt } from '../pipeline/pipeline-state.js';
3
+ import { type InstallOutcome } from './compact/environment.js';
4
+ import type { HostBlocker } from './engine/contracts.js';
5
+ import { type GuardrailSettings } from './guardrails.js';
6
+ export type { HostBlocker } from './engine/contracts.js';
7
+ export declare function installRoots(context: PipelineContext, commands: readonly CommandReceipt[]): string[];
8
+ /** A directory as the log should name it: relative to the checkout that contains it. */
9
+ export declare function checkoutRelative(context: PipelineContext, directory: string): string;
10
+ /** The legacy host-facing sentence for a blocker; engine v2 hosts read the structured blocker instead. */
11
+ export declare function hostPreconditionMessage(reason: string, command: string, args: readonly string[], directory: string): string;
12
+ /** A failed check the host must unblock (credentials, registry access, a missing variable): no correction round can repair it. */
13
+ export declare function preconditionBlock(context: PipelineContext, receipt: VerificationReceipt): HostBlocker | undefined;
14
+ export interface EnvironmentRepair {
15
+ installs: InstallOutcome[];
16
+ refused?: HostBlocker;
17
+ }
18
+ /**
19
+ * Runs the planned installs for the failed commands' roots once, narrating each
20
+ * step through `note` exactly as the legacy graph logs it (`[environment] npm
21
+ * install (app)`, `Environment: installed …`). An install the host cannot
22
+ * complete for a precondition reason becomes `refused`.
23
+ */
24
+ export declare function repairEnvironment(context: PipelineContext, receipt: VerificationReceipt, guardrails: GuardrailSettings | undefined, note: (text: string) => void, spawn?: typeof spawnSync): EnvironmentRepair;
25
+ /** A blocker as a durable JSON value: known kind, bounded strings, at most 16 arguments. */
26
+ export declare function boundedBlocker(value: unknown): HostBlocker | undefined;
@@ -0,0 +1,88 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { hostPrecondition, installEnvironment } from './compact/environment.js';
4
+ import { guardrailEnabled } from './guardrails.js';
5
+ const BLOCKER_KINDS = new Set(['network', 'credential', 'environment-variable', 'toolchain', 'setup', 'environment', 'scope']);
6
+ const BLOCKER_TEXT_LIMIT = 2_000;
7
+ /** Where a missing dependency install belongs: the nearest directory with a lockfile or manifest above the failing command, else the repository root. */
8
+ const INSTALL_MARKERS = ['package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lock', 'bun.lockb', 'requirements.txt', 'pyproject.toml', 'go.mod', 'Cargo.toml'];
9
+ export function installRoots(context, commands) {
10
+ const roots = [];
11
+ for (const command of commands) {
12
+ const repository = context.repositories.find(repo => repo.id === command.repositoryId);
13
+ if (!repository)
14
+ continue;
15
+ let directory = command.cwd;
16
+ let chosen = repository.path;
17
+ for (;;) {
18
+ const relative = path.relative(repository.path, directory);
19
+ if (relative.startsWith('..') || path.isAbsolute(relative))
20
+ break;
21
+ if (INSTALL_MARKERS.some(name => existsSync(path.join(directory, name)))) {
22
+ chosen = directory;
23
+ break;
24
+ }
25
+ if (!relative)
26
+ break;
27
+ directory = path.dirname(directory);
28
+ }
29
+ roots.push(chosen);
30
+ }
31
+ // Every repository root stays a candidate: its plan is a no-op when it is already installed.
32
+ return [...new Set([...roots, ...context.repositories.map(repository => repository.path)])];
33
+ }
34
+ /** A directory as the log should name it: relative to the checkout that contains it. */
35
+ export function checkoutRelative(context, directory) {
36
+ const repository = context.repositories.find(repo => { const relative = path.relative(repo.path, directory); return !relative.startsWith('..') && !path.isAbsolute(relative); });
37
+ return repository ? path.relative(repository.path, directory).split(path.sep).join('/') || '.' : directory;
38
+ }
39
+ /** The legacy host-facing sentence for a blocker; engine v2 hosts read the structured blocker instead. */
40
+ export function hostPreconditionMessage(reason, command, args, directory) {
41
+ return `Verification cannot run in this environment: ${reason} (\`${[command, ...args].join(' ')}\` in ${directory}). This is not a defect in the change, so no correction round was started. Make it available to Specrails (for example in the login shell profile Specrails loads, or with a refreshed registry token), then resume.`;
42
+ }
43
+ /** A failed check the host must unblock (credentials, registry access, a missing variable): no correction round can repair it. */
44
+ export function preconditionBlock(context, receipt) {
45
+ for (const command of receipt.commands) {
46
+ if (command.exitCode === 0)
47
+ continue;
48
+ const precondition = hostPrecondition(command.output);
49
+ if (precondition)
50
+ return boundedBlocker({ kind: precondition.kind, reason: precondition.reason, command: command.command, args: command.args, cwd: checkoutRelative(context, command.cwd),
51
+ requiredAction: precondition.requiredAction, ...(command.evidenceId ? { evidenceId: command.evidenceId } : {}) });
52
+ }
53
+ return undefined;
54
+ }
55
+ /**
56
+ * Runs the planned installs for the failed commands' roots once, narrating each
57
+ * step through `note` exactly as the legacy graph logs it (`[environment] npm
58
+ * install (app)`, `Environment: installed …`). An install the host cannot
59
+ * complete for a precondition reason becomes `refused`.
60
+ */
61
+ export function repairEnvironment(context, receipt, guardrails, note, spawn) {
62
+ const failed = receipt.commands.filter(command => command.exitCode !== 0);
63
+ const installs = installEnvironment(installRoots(context, failed), { failureOutput: receipt.commands.map(command => command.output).join('\n'), lockfileRepair: guardrailEnabled(guardrails, 'lockfile-repair'),
64
+ ...(spawn ? { spawn } : {}), onEvent: event => note(event.kind === 'text' ? event.text ?? '' : `[environment] ${event.tool ?? ''} ${event.detail ?? ''}`.trim()) });
65
+ const refused = installs.find(outcome => outcome.precondition);
66
+ if (!refused)
67
+ return { installs };
68
+ const precondition = hostPrecondition(refused.detail) ?? { kind: 'environment', reason: refused.precondition, requiredAction: 'Repair the verification environment, then retry the run.' };
69
+ const cwd = checkoutRelative(context, refused.root);
70
+ const command = [refused.command, ...refused.args].join(' ');
71
+ return { installs, refused: boundedBlocker({ kind: precondition.kind, reason: `installing its dependencies failed because ${refused.precondition}`, command: refused.command, args: refused.args, cwd,
72
+ // A network blocker on an install names the exact command to repeat with connectivity; other kinds keep the classifier's action.
73
+ requiredAction: precondition.kind === 'network' ? `Run \`${command}\` in ${cwd} with network access, then retry the run.` : precondition.requiredAction }) };
74
+ }
75
+ /** A blocker as a durable JSON value: known kind, bounded strings, at most 16 arguments. */
76
+ export function boundedBlocker(value) {
77
+ if (!value || typeof value !== 'object' || Array.isArray(value))
78
+ return undefined;
79
+ const raw = value;
80
+ if (typeof raw.kind !== 'string' || !BLOCKER_KINDS.has(raw.kind))
81
+ return undefined;
82
+ const bounded = (field, limit = BLOCKER_TEXT_LIMIT) => typeof field === 'string' ? field.slice(0, limit) : '';
83
+ const args = Array.isArray(raw.args) ? raw.args.slice(0, 16).map(arg => bounded(arg, 256)) : [];
84
+ // A correction role reports free-text `evidence` instead of a host reason.
85
+ const reason = bounded(raw.reason) || bounded(raw.evidence);
86
+ return { kind: raw.kind, reason, command: bounded(raw.command, 512), args, cwd: bounded(raw.cwd, 512), requiredAction: bounded(raw.requiredAction),
87
+ ...(typeof raw.evidenceId === 'string' && raw.evidenceId ? { evidenceId: bounded(raw.evidenceId, 128) } : {}) };
88
+ }
@@ -293,7 +293,7 @@
293
293
  "specificationFormat": "Architect specs contain complete main-spec replacements, including unchanged requirements; archive does not merge partial delta snippets.",
294
294
  "compatibility": "Additive to integration schema 4 and execution context schema 1. Legacy workflows and profile v1 remain supported. Hosts must check RUNTIME_API_VERSION, not infer runtime support from Core major version.",
295
295
  "documentation": "docs/agent-runtime.md",
296
- "instructionsVersion": "13",
296
+ "instructionsVersion": "14",
297
297
  "workflowVersion": "7",
298
298
  "engines": [
299
299
  1,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specrails-core",
3
- "version": "6.3.0",
3
+ "version": "6.4.0",
4
4
  "description": "Agent workflow engine for specrails-desktop: provider-independent installer and programmatic agent runtime for Claude Code and Codex",
5
5
  "type": "module",
6
6
  "exports": {
@@ -134,13 +134,8 @@
134
134
  "maxAttempts": { "$ref": "#/definitions/positiveInteger" }, "maxTokens": { "$ref": "#/definitions/positiveInteger" },
135
135
  "maxCostUsd": { "type": "number", "exclusiveMinimum": 0 }, "timeoutMs": { "$ref": "#/definitions/positiveInteger" }
136
136
  } },
137
- "verification": { "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["repositoryId", "command", "args"], "properties": {
138
- "key": { "$ref": "#/definitions/id" }, "label": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "\\S" }, "policy": { "$ref": "#/definitions/checkPolicy" },
139
- "repositoryId": { "$ref": "#/definitions/id" }, "command": { "type": "string", "minLength": 1 },
140
- "args": { "type": "array", "items": { "type": "string" } }, "cwd": { "type": "string", "minLength": 1 },
141
- "env": { "type": "object", "propertyNames": { "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" }, "additionalProperties": { "type": "string" } },
142
- "timeoutMs": { "$ref": "#/definitions/positiveInteger" }
143
- } } },
137
+ "verification": { "type": "array", "items": { "$ref": "#/definitions/checkCommand" } },
138
+ "setup": { "type": "array", "description": "Idempotent commands the verify piece runs sequentially in the admitted workspace before each verification plan; a failing setup command blocks the run instead of starting a correction round.", "items": { "$ref": "#/definitions/checkCommand" } },
144
139
  "approvalBeforeArchive": { "type": "boolean" },
145
140
  "review": { "type": "object", "additionalProperties": false, "description": "Review gate thresholds; unset fields keep Core's defaults (score 70, security 75, other aspects 60).", "properties": {
146
141
  "minScore": { "$ref": "#/definitions/score" },
@@ -154,6 +149,13 @@
154
149
  } }
155
150
  },
156
151
  "definitions": {
152
+ "checkCommand": { "type": "object", "additionalProperties": false, "required": ["repositoryId", "command", "args"], "properties": {
153
+ "key": { "$ref": "#/definitions/id" }, "label": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "\\S" }, "policy": { "$ref": "#/definitions/checkPolicy" },
154
+ "repositoryId": { "$ref": "#/definitions/id" }, "command": { "type": "string", "minLength": 1 },
155
+ "args": { "type": "array", "items": { "type": "string" } }, "cwd": { "type": "string", "minLength": 1 },
156
+ "env": { "type": "object", "propertyNames": { "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" }, "additionalProperties": { "type": "string" } },
157
+ "timeoutMs": { "$ref": "#/definitions/positiveInteger" }
158
+ } },
157
159
  "effort": { "type": "string", "pattern": "^[a-z][a-z0-9_-]{0,31}$" },
158
160
  "inputList": { "type": "array", "maxItems": 256, "items": { "type": "string", "minLength": 1, "maxLength": 4096, "pattern": "\\S" } },
159
161
  "checkPolicy": { "type": "object", "additionalProperties": false, "properties": {