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.
- package/README.md +2 -2
- package/dist/agent-runtime/cli.js +1 -1
- package/dist/agent-runtime/compact/environment.d.ts +19 -2
- package/dist/agent-runtime/compact/environment.js +71 -6
- package/dist/agent-runtime/config.js +68 -53
- package/dist/agent-runtime/engine/compiler.js +10 -2
- package/dist/agent-runtime/engine/contracts.d.ts +16 -0
- package/dist/agent-runtime/engine/pieces/control.js +11 -2
- package/dist/agent-runtime/engine/pieces/role-turn.js +35 -2
- package/dist/agent-runtime/engine/pieces/verify.d.ts +10 -3
- package/dist/agent-runtime/engine/pieces/verify.js +107 -30
- package/dist/agent-runtime/executor-types.d.ts +2 -0
- package/dist/agent-runtime/graph/nodes.js +9 -55
- package/dist/agent-runtime/prompts.d.ts +1 -1
- package/dist/agent-runtime/prompts.js +58 -35
- package/dist/agent-runtime/verification-repair.d.ts +26 -0
- package/dist/agent-runtime/verification-repair.js +88 -0
- package/dist/installer/commands/framework.d.ts +7 -3
- package/dist/installer/commands/framework.js +6 -8
- package/dist/installer/commands/init.d.ts +8 -15
- package/dist/installer/commands/init.js +22 -64
- package/dist/installer/phases/install-config.d.ts +8 -2
- package/dist/installer/phases/install-config.js +24 -17
- package/dist/installer/phases/scaffold.d.ts +27 -79
- package/dist/installer/phases/scaffold.js +100 -535
- package/integration-contract.json +7 -10
- package/package.json +1 -1
- package/schemas/agent-runtime.schema.json +9 -7
- package/templates/agents/sr-architect.md +0 -29
- package/templates/agents/sr-developer.md +0 -29
- package/templates/agents/sr-reviewer.md +0 -28
- package/templates/codex-skills/rails/sr-architect/SKILL.md +0 -29
- package/templates/codex-skills/rails/sr-developer/SKILL.md +0 -29
- 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
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
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:
|
|
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;
|