specrails-core 6.2.2 → 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.
Files changed (34) hide show
  1. package/README.md +2 -2
  2. package/dist/agent-runtime/cli.js +1 -1
  3. package/dist/agent-runtime/compact/environment.d.ts +19 -2
  4. package/dist/agent-runtime/compact/environment.js +71 -6
  5. package/dist/agent-runtime/config.js +68 -53
  6. package/dist/agent-runtime/engine/compiler.js +10 -2
  7. package/dist/agent-runtime/engine/contracts.d.ts +16 -0
  8. package/dist/agent-runtime/engine/pieces/control.js +11 -2
  9. package/dist/agent-runtime/engine/pieces/role-turn.js +35 -2
  10. package/dist/agent-runtime/engine/pieces/verify.d.ts +10 -3
  11. package/dist/agent-runtime/engine/pieces/verify.js +107 -30
  12. package/dist/agent-runtime/executor-types.d.ts +2 -0
  13. package/dist/agent-runtime/graph/nodes.js +9 -55
  14. package/dist/agent-runtime/prompts.d.ts +1 -1
  15. package/dist/agent-runtime/prompts.js +58 -35
  16. package/dist/agent-runtime/verification-repair.d.ts +26 -0
  17. package/dist/agent-runtime/verification-repair.js +88 -0
  18. package/dist/installer/commands/framework.d.ts +7 -3
  19. package/dist/installer/commands/framework.js +6 -8
  20. package/dist/installer/commands/init.d.ts +8 -15
  21. package/dist/installer/commands/init.js +22 -64
  22. package/dist/installer/phases/install-config.d.ts +8 -2
  23. package/dist/installer/phases/install-config.js +24 -17
  24. package/dist/installer/phases/scaffold.d.ts +27 -79
  25. package/dist/installer/phases/scaffold.js +100 -535
  26. package/integration-contract.json +7 -10
  27. package/package.json +1 -1
  28. package/schemas/agent-runtime.schema.json +9 -7
  29. package/templates/agents/sr-architect.md +0 -29
  30. package/templates/agents/sr-developer.md +0 -29
  31. package/templates/agents/sr-reviewer.md +0 -28
  32. package/templates/codex-skills/rails/sr-architect/SKILL.md +0 -29
  33. package/templates/codex-skills/rails/sr-developer/SKILL.md +0 -29
  34. package/templates/codex-skills/rails/sr-reviewer/SKILL.md +0 -28
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  The agent workflow engine behind [Specrails Desktop](https://github.com/fjpulidop/specrails-desktop). Core does two things:
8
8
 
9
- 1. **Installs** the specrails workflow into a project for Claude Code or Codex CLI: three roles (architect, developer, reviewer), the `implement` and `retry` entry points (`implement` runs one ticket or several tickets as one aggregate run) and the official OpenSpec skills.
9
+ 1. **Installs** the specrails workflow into a project for Claude Code, Codex CLI, Gemini CLI or Kimi Code: the `implement` and `retry` entry points (`implement` runs one ticket or several tickets as one aggregate run), provider settings and the official OpenSpec skills. The architect, developer and reviewer roles are defined by the runtime itself; no role file is installed.
10
10
  2. **Executes** Desktop-owned workflow definitions through the [programmatic agent runtime](docs/agent-runtime.md): independent agent and implementation operations, per-role providers, deterministic verification, durable recovery and OpenAI-compatible local models. Desktop owns workflow composition and editable loop agents; Core retains the old builtin for saved runs.
11
11
 
12
12
  Desktop bundles a pinned Core package and owns everything around it: worktrees, commits, pull requests, backlog and the user interface. Core never ships code on its own; it prepares a reviewed candidate for the host.
@@ -31,7 +31,7 @@ bin/specrails-core.mjs npm bin shim → dist/installer/cli.js
31
31
  src/installer/ CLI, framework lifecycle, provider rendering
32
32
  src/pipeline/ pipeline journal, gates and verification receipts
33
33
  src/agent-runtime/ programmatic runtime: graph, executors, compact loop, recovery
34
- templates/ role definitions, workflow commands, provider settings
34
+ templates/ workflow commands, provider settings, Kimi runner
35
35
  schemas/ runtime configuration schema and fixtures
36
36
  integration-contract.json Desktop ⇄ Core contract
37
37
  ```
@@ -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;