specrails-core 6.0.0 → 6.1.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 +1 -1
- package/dist/agent-runtime/capabilities.js +3 -1
- package/dist/agent-runtime/change-scope.d.ts +63 -0
- package/dist/agent-runtime/change-scope.js +297 -0
- package/dist/agent-runtime/cli-executor.d.ts +3 -0
- package/dist/agent-runtime/cli-executor.js +50 -13
- package/dist/agent-runtime/cli-process.js +6 -2
- package/dist/agent-runtime/cli.d.ts +15 -0
- package/dist/agent-runtime/cli.js +43 -9
- package/dist/agent-runtime/compact/chat-client.js +2 -1
- package/dist/agent-runtime/compact/environment.d.ts +12 -0
- package/dist/agent-runtime/compact/environment.js +31 -1
- package/dist/agent-runtime/compact/test-reachability.js +6 -2
- package/dist/agent-runtime/config.d.ts +4 -1
- package/dist/agent-runtime/config.js +41 -3
- package/dist/agent-runtime/core-host.d.ts +10 -4
- package/dist/agent-runtime/core-host.js +22 -19
- package/dist/agent-runtime/efficiency-summary.d.ts +4 -1
- package/dist/agent-runtime/efficiency-summary.js +30 -27
- package/dist/agent-runtime/efficiency-types.d.ts +4 -4
- package/dist/agent-runtime/efficiency.d.ts +1 -1
- package/dist/agent-runtime/efficiency.js +7 -6
- package/dist/agent-runtime/engine/__fixtures__/implementation-component.json +90 -0
- package/dist/agent-runtime/engine/__fixtures__/implementation-fixture.d.ts +11 -0
- package/dist/agent-runtime/engine/__fixtures__/implementation-fixture.js +60 -0
- package/dist/agent-runtime/engine/__fixtures__/implementation.json +43 -0
- package/dist/agent-runtime/engine/budget.d.ts +9 -0
- package/dist/agent-runtime/engine/budget.js +33 -0
- package/dist/agent-runtime/engine/canonical-json.d.ts +9 -0
- package/dist/agent-runtime/engine/canonical-json.js +162 -0
- package/dist/agent-runtime/engine/checkpoint/database.d.ts +67 -0
- package/dist/agent-runtime/engine/checkpoint/database.js +304 -0
- package/dist/agent-runtime/engine/checkpoint/lease.d.ts +17 -0
- package/dist/agent-runtime/engine/checkpoint/lease.js +82 -0
- package/dist/agent-runtime/engine/checkpoint/ledger.d.ts +133 -0
- package/dist/agent-runtime/engine/checkpoint/ledger.js +510 -0
- package/dist/agent-runtime/engine/checkpoint/saver.d.ts +25 -0
- package/dist/agent-runtime/engine/checkpoint/saver.js +146 -0
- package/dist/agent-runtime/engine/cli.d.ts +10 -0
- package/dist/agent-runtime/engine/cli.js +172 -0
- package/dist/agent-runtime/engine/compiler.d.ts +23 -0
- package/dist/agent-runtime/engine/compiler.js +261 -0
- package/dist/agent-runtime/engine/composition.d.ts +16 -0
- package/dist/agent-runtime/engine/composition.js +28 -0
- package/dist/agent-runtime/engine/concurrency.d.ts +14 -0
- package/dist/agent-runtime/engine/concurrency.js +55 -0
- package/dist/agent-runtime/engine/contracts.d.ts +273 -0
- package/dist/agent-runtime/engine/contracts.js +11 -0
- package/dist/agent-runtime/engine/definition-schema.d.ts +250 -0
- package/dist/agent-runtime/engine/definition-schema.js +313 -0
- package/dist/agent-runtime/engine/definition-types.d.ts +84 -0
- package/dist/agent-runtime/engine/definition-types.js +3 -0
- package/dist/agent-runtime/engine/definition-validator.d.ts +7 -0
- package/dist/agent-runtime/engine/definition-validator.js +187 -0
- package/dist/agent-runtime/engine/efficiency-summary.d.ts +62 -0
- package/dist/agent-runtime/engine/efficiency-summary.js +31 -0
- package/dist/agent-runtime/engine/events.d.ts +19 -0
- package/dist/agent-runtime/engine/events.js +88 -0
- package/dist/agent-runtime/engine/execution.d.ts +29 -0
- package/dist/agent-runtime/engine/execution.js +121 -0
- package/dist/agent-runtime/engine/expressions.d.ts +11 -0
- package/dist/agent-runtime/engine/expressions.js +172 -0
- package/dist/agent-runtime/engine/fork.d.ts +28 -0
- package/dist/agent-runtime/engine/fork.js +211 -0
- package/dist/agent-runtime/engine/graph-description.d.ts +48 -0
- package/dist/agent-runtime/engine/graph-description.js +16 -0
- package/dist/agent-runtime/engine/implementation-compiler.d.ts +214 -0
- package/dist/agent-runtime/engine/implementation-compiler.js +91 -0
- package/dist/agent-runtime/engine/index.d.ts +14 -0
- package/dist/agent-runtime/engine/index.js +14 -0
- package/dist/agent-runtime/engine/invocation-context.d.ts +11 -0
- package/dist/agent-runtime/engine/invocation-context.js +128 -0
- package/dist/agent-runtime/engine/otel.d.ts +15 -0
- package/dist/agent-runtime/engine/otel.js +132 -0
- package/dist/agent-runtime/engine/output.d.ts +4 -0
- package/dist/agent-runtime/engine/output.js +73 -0
- package/dist/agent-runtime/engine/piece-dependencies.d.ts +50 -0
- package/dist/agent-runtime/engine/piece-dependencies.js +193 -0
- package/dist/agent-runtime/engine/piece-registry.d.ts +19 -0
- package/dist/agent-runtime/engine/piece-registry.js +62 -0
- package/dist/agent-runtime/engine/pieces/assign.d.ts +4 -0
- package/dist/agent-runtime/engine/pieces/assign.js +35 -0
- package/dist/agent-runtime/engine/pieces/control.d.ts +5 -0
- package/dist/agent-runtime/engine/pieces/control.js +49 -0
- package/dist/agent-runtime/engine/pieces/decider.d.ts +5 -0
- package/dist/agent-runtime/engine/pieces/decider.js +70 -0
- package/dist/agent-runtime/engine/pieces/implementation-binding.d.ts +12 -0
- package/dist/agent-runtime/engine/pieces/implementation-binding.js +40 -0
- package/dist/agent-runtime/engine/pieces/implementation-journal.d.ts +25 -0
- package/dist/agent-runtime/engine/pieces/implementation-journal.js +192 -0
- package/dist/agent-runtime/engine/pieces/implementation.d.ts +23 -0
- package/dist/agent-runtime/engine/pieces/implementation.js +176 -0
- package/dist/agent-runtime/engine/pieces/index.d.ts +7 -0
- package/dist/agent-runtime/engine/pieces/index.js +22 -0
- package/dist/agent-runtime/engine/pieces/openspec.d.ts +3 -0
- package/dist/agent-runtime/engine/pieces/openspec.js +69 -0
- package/dist/agent-runtime/engine/pieces/ports.d.ts +48 -0
- package/dist/agent-runtime/engine/pieces/ports.js +1 -0
- package/dist/agent-runtime/engine/pieces/project-memory.d.ts +3 -0
- package/dist/agent-runtime/engine/pieces/project-memory.js +13 -0
- package/dist/agent-runtime/engine/pieces/prompt.d.ts +5 -0
- package/dist/agent-runtime/engine/pieces/prompt.js +130 -0
- package/dist/agent-runtime/engine/pieces/role-turn.d.ts +7 -0
- package/dist/agent-runtime/engine/pieces/role-turn.js +73 -0
- package/dist/agent-runtime/engine/pieces/shared.d.ts +67 -0
- package/dist/agent-runtime/engine/pieces/shared.js +40 -0
- package/dist/agent-runtime/engine/pieces/shell.d.ts +6 -0
- package/dist/agent-runtime/engine/pieces/shell.js +47 -0
- package/dist/agent-runtime/engine/pieces/verify.d.ts +7 -0
- package/dist/agent-runtime/engine/pieces/verify.js +52 -0
- package/dist/agent-runtime/engine/preflight.d.ts +27 -0
- package/dist/agent-runtime/engine/preflight.js +54 -0
- package/dist/agent-runtime/engine/regex.d.ts +3 -0
- package/dist/agent-runtime/engine/regex.js +19 -0
- package/dist/agent-runtime/engine/run-status.d.ts +563 -0
- package/dist/agent-runtime/engine/run-status.js +61 -0
- package/dist/agent-runtime/engine/runs.d.ts +605 -0
- package/dist/agent-runtime/engine/runs.js +247 -0
- package/dist/agent-runtime/engine/state.d.ts +64 -0
- package/dist/agent-runtime/engine/state.js +98 -0
- package/dist/agent-runtime/engine/steering/inbox.d.ts +54 -0
- package/dist/agent-runtime/engine/steering/inbox.js +113 -0
- package/dist/agent-runtime/engine/storage/fork-allocation.d.ts +21 -0
- package/dist/agent-runtime/engine/storage/fork-allocation.js +143 -0
- package/dist/agent-runtime/engine/storage/private-path.d.ts +11 -0
- package/dist/agent-runtime/engine/storage/private-path.js +74 -0
- package/dist/agent-runtime/engine/store/sqlite-store.d.ts +26 -0
- package/dist/agent-runtime/engine/store/sqlite-store.js +227 -0
- package/dist/agent-runtime/evaluation-corpus.d.ts +125 -0
- package/dist/agent-runtime/evaluation-corpus.js +4 -0
- package/dist/agent-runtime/evaluation.d.ts +9 -3
- package/dist/agent-runtime/evaluation.js +50 -8
- package/dist/agent-runtime/executor-types.d.ts +53 -3
- package/dist/agent-runtime/executor-types.js +35 -2
- package/dist/agent-runtime/executors.js +2 -2
- package/dist/agent-runtime/graph/artifacts.d.ts +7 -0
- package/dist/agent-runtime/graph/artifacts.js +86 -10
- package/dist/agent-runtime/graph/convergence.d.ts +26 -0
- package/dist/agent-runtime/graph/convergence.js +62 -0
- package/dist/agent-runtime/graph/nodes.d.ts +2 -0
- package/dist/agent-runtime/graph/nodes.js +171 -24
- package/dist/agent-runtime/graph/roles.d.ts +6 -0
- package/dist/agent-runtime/graph/roles.js +23 -17
- package/dist/agent-runtime/graph/state.d.ts +5 -0
- package/dist/agent-runtime/graph/state.js +2 -0
- package/dist/agent-runtime/kimi-acp.js +6 -6
- package/dist/agent-runtime/openai-executor.js +17 -12
- package/dist/agent-runtime/openspec.d.ts +17 -2
- package/dist/agent-runtime/openspec.js +54 -20
- package/dist/agent-runtime/prompts.d.ts +11 -5
- package/dist/agent-runtime/prompts.js +71 -20
- package/dist/agent-runtime/recovery.d.ts +1 -1
- package/dist/agent-runtime/repository-context.js +20 -1
- package/dist/agent-runtime/role-routing.js +4 -2
- package/dist/agent-runtime/role-state.d.ts +5 -0
- package/dist/agent-runtime/verification-plan.d.ts +2 -0
- package/dist/agent-runtime/verification-plan.js +18 -3
- package/dist/agent-runtime/workflow-types.d.ts +2 -0
- package/dist/agent-runtime/workflow.d.ts +3 -1
- package/dist/agent-runtime/workflow.js +7 -3
- package/dist/agent-runtime/workspace-tools.d.ts +2 -2
- package/dist/agent-runtime/workspace-tools.js +6 -6
- package/dist/installer/cli.js +5 -0
- package/dist/installer/phases/scaffold.d.ts +2 -2
- package/dist/installer/phases/scaffold.js +3 -7
- package/dist/pipeline/pipeline-state.d.ts +49 -4
- package/dist/pipeline/pipeline-state.js +150 -23
- package/integration-contract.json +84 -12
- package/package.json +12 -5
- package/schemas/agent-runtime.schema.json +93 -1
- package/schemas/workflow-definition.schema.json +312 -0
- package/templates/kimi/specrails/run-skill.mjs +15 -1
- package/templates/commands/specrails/batch-implement.md +0 -32
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import { bindVerificationPlan, pipelineStateDirectory, validateVerificationRequest } from '../pipeline/pipeline-state.js';
|
|
5
|
+
import { assertProposalInScope, withScopeDefault } from './change-scope.js';
|
|
5
6
|
import { fingerprint } from './durable-store.js';
|
|
6
7
|
import { child, write } from './graph/artifacts.js';
|
|
7
8
|
const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
|
@@ -39,7 +40,10 @@ export function validateProposedChecks(context, raw) {
|
|
|
39
40
|
text(check.command, 'command', 4096);
|
|
40
41
|
if (!Array.isArray(check.args) || check.args.length > 128 || check.args.some(arg => typeof arg !== 'string' || arg.includes('\0') || arg.length > 4096))
|
|
41
42
|
throw new Error('Invalid verification proposal args');
|
|
42
|
-
|
|
43
|
+
if (check.cwd !== undefined && typeof check.cwd !== 'string')
|
|
44
|
+
throw new Error('Invalid verification proposal cwd');
|
|
45
|
+
assertProposalInScope(context, check);
|
|
46
|
+
validateVerificationRequest(context, { kind: 'scoped', commands: [withScopeDefault(context, check)] });
|
|
43
47
|
if (check.kind === 'command') {
|
|
44
48
|
if (check.entrypoint !== undefined || check.files !== undefined)
|
|
45
49
|
throw new Error('Invalid command proposal: harness fields are forbidden');
|
|
@@ -123,7 +127,8 @@ export function readVerificationPlan(context) {
|
|
|
123
127
|
}
|
|
124
128
|
export function initializeVerificationPlan(context, host, architect, maxConcurrency = 1) {
|
|
125
129
|
const previous = readVerificationPlan(context);
|
|
126
|
-
|
|
130
|
+
// The key hashes the command as declared, so a scope default never renames a check.
|
|
131
|
+
const baseline = [['host', host], ['architect', architect]].flatMap(([origin, commands]) => commands.map((command, i) => entry(`${origin}:${command.key ?? `${i}-${fingerprint(normalized(command)).slice(0, 12)}`}`, command.label ?? [command.command, ...command.args].join(' ').slice(0, 256), withScopeDefault(context, command), origin)));
|
|
127
132
|
if (!Number.isSafeInteger(maxConcurrency) || maxConcurrency < 1 || maxConcurrency > 4)
|
|
128
133
|
throw new Error('Invalid verification concurrency');
|
|
129
134
|
if (previous && fingerprint(previous.baseline) !== fingerprint(baseline))
|
|
@@ -156,7 +161,7 @@ export function addDeveloperChecks(context, proposals) {
|
|
|
156
161
|
const developer = new Map(previous.developer.map(item => [item.key, item]));
|
|
157
162
|
const sources = [];
|
|
158
163
|
for (const check of checked) {
|
|
159
|
-
const command = { repositoryId: check.repositoryId, command: check.command, args: check.args, ...(check.cwd === undefined ? {} : { cwd: check.cwd }), ...(check.timeoutMs === undefined ? {} : { timeoutMs: check.timeoutMs }) };
|
|
164
|
+
const command = withScopeDefault(context, { repositoryId: check.repositoryId, command: check.command, args: check.args, ...(check.cwd === undefined ? {} : { cwd: check.cwd }), ...(check.timeoutMs === undefined ? {} : { timeoutMs: check.timeoutMs }) });
|
|
160
165
|
const files = check.files?.slice().sort((a, b) => a.path.localeCompare(b.path)).map(file => ({ path: file.path, hash: bytesHash(file.content), byteCount: Buffer.byteLength(file.content) }));
|
|
161
166
|
const harness = files ? { hash: fingerprint(files), entrypoint: check.entrypoint, sources: files } : undefined;
|
|
162
167
|
const item = entry('dev:' + check.key, check.label, command, 'developer', harness);
|
|
@@ -203,3 +208,13 @@ export function bindPlan(context, plan) {
|
|
|
203
208
|
}
|
|
204
209
|
bindVerificationPlan(context, plan.planHash, files);
|
|
205
210
|
}
|
|
211
|
+
/** Rebind a checkpoint-owned plan to a new run, retaining the exact admitted checks. */
|
|
212
|
+
export function forkVerificationPlan(context, source, sourceContext) {
|
|
213
|
+
const { integrity, ...payload } = source;
|
|
214
|
+
if (integrity !== fingerprint(payload) || source.scopeHash !== fingerprint(sourceContext) || source.runId !== sourceContext.runId
|
|
215
|
+
|| source.planHash !== fingerprint({ policyVersion: 1, scopeHash: source.scopeHash, executionPolicy: source.executionPolicy, entries: source.entries }))
|
|
216
|
+
throw new Error('Fork verification plan failed its source integrity check');
|
|
217
|
+
const plan = savePlan(context, source.baseline, source.developer, null, source.executionPolicy);
|
|
218
|
+
bindPlan(context, plan);
|
|
219
|
+
return plan;
|
|
220
|
+
}
|
|
@@ -52,6 +52,8 @@ export interface WorkflowStepContext {
|
|
|
52
52
|
attemptId: string;
|
|
53
53
|
attempt: number;
|
|
54
54
|
input: JsonValue;
|
|
55
|
+
/** Engine-owned, durably claimed operator text; cannot replace the frozen scope. */
|
|
56
|
+
operatorSteering?: string;
|
|
55
57
|
signal: AbortSignal;
|
|
56
58
|
/** A detached snapshot; modifying it cannot modify the persisted run. */
|
|
57
59
|
checkpoint: WorkflowState;
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import type { RunWorkflowOptions, WorkflowState } from './workflow-types.js';
|
|
1
|
+
import type { RunWorkflowOptions, WorkflowDefinition, WorkflowState } from './workflow-types.js';
|
|
2
2
|
export * from './workflow-types.js';
|
|
3
3
|
export { readWorkflowState, readWorkflowEnvelope, writeWorkflowEnvelope, WorkflowStoreError, type WorkflowEnvelope } from './durable-store.js';
|
|
4
4
|
export declare class WorkflowError extends Error {
|
|
5
5
|
readonly code: 'INVALID_WORKFLOW' | 'ALREADY_EXISTS' | 'NOT_FOUND' | 'INCOMPATIBLE_RESUME';
|
|
6
6
|
constructor(code: 'INVALID_WORKFLOW' | 'ALREADY_EXISTS' | 'NOT_FOUND' | 'INCOMPATIBLE_RESUME', message: string);
|
|
7
7
|
}
|
|
8
|
+
/** Pure legacy resume identity; preserve node order, declared exits and default metadata. */
|
|
9
|
+
export declare function definitionFingerprint<S extends Record<string, unknown>>(workflow: WorkflowDefinition<S>): string;
|
|
8
10
|
/**
|
|
9
11
|
* Execute a LangGraph state graph with a durable host ledger at every effect
|
|
10
12
|
* boundary. LangGraph owns traversal, state reducers, checkpoints, interrupts
|
|
@@ -63,9 +63,13 @@ function validateWorkflow(workflow) {
|
|
|
63
63
|
if (!Number.isInteger(maxTransitions) || maxTransitions < 1 || maxTransitions > 10_000) {
|
|
64
64
|
throw new WorkflowError('INVALID_WORKFLOW', 'maxTransitions must be an integer from 1 to 10000');
|
|
65
65
|
}
|
|
66
|
+
return definitionFingerprint(workflow);
|
|
67
|
+
}
|
|
68
|
+
/** Pure legacy resume identity; preserve node order, declared exits and default metadata. */
|
|
69
|
+
export function definitionFingerprint(workflow) {
|
|
66
70
|
return fingerprint({
|
|
67
|
-
id: workflow.id, version: workflow.version, maxTransitions, entry: workflow.entry,
|
|
68
|
-
nodes:
|
|
71
|
+
id: workflow.id, version: workflow.version, maxTransitions: workflow.maxTransitions ?? 100, entry: workflow.entry,
|
|
72
|
+
nodes: Object.keys(workflow.nodes).map(id => ({ id, effect: workflow.nodes[id].effect ?? 'read', maxAttempts: workflow.nodes[id].maxAttempts ?? 1, retrySafe: workflow.nodes[id].retrySafe ?? false, ends: workflow.nodes[id].ends })),
|
|
69
73
|
});
|
|
70
74
|
}
|
|
71
75
|
function validateUsage(usage) {
|
|
@@ -406,7 +410,7 @@ export async function runWorkflow(options) {
|
|
|
406
410
|
},
|
|
407
411
|
reportInvocation: async (invocation) => {
|
|
408
412
|
validateUsage(invocation.usage);
|
|
409
|
-
if (!Number.isFinite(invocation.durationMs) || invocation.durationMs < 0 || !Number.isSafeInteger(invocation.toolCalls) || invocation.toolCalls < 0)
|
|
413
|
+
if (typeof invocation.durationMs !== 'number' || !Number.isFinite(invocation.durationMs) || invocation.durationMs < 0 || typeof invocation.toolCalls !== 'number' || !Number.isSafeInteger(invocation.toolCalls) || invocation.toolCalls < 0)
|
|
410
414
|
throw new TypeError('Invalid invocation measurement');
|
|
411
415
|
if (invocation.invocationId && history.invocations?.some(item => item.invocationId === invocation.invocationId))
|
|
412
416
|
return;
|
|
@@ -13,11 +13,11 @@ export declare function canonicalWorkspace(cwd: string, allowedRoots: string[]):
|
|
|
13
13
|
};
|
|
14
14
|
/** Application tool boundary, not an OS sandbox for concurrent external processes. */
|
|
15
15
|
export declare class WorkspaceTools {
|
|
16
|
-
private readonly
|
|
16
|
+
private readonly access;
|
|
17
17
|
readonly cwd: string;
|
|
18
18
|
readonly roots: string[];
|
|
19
19
|
private readonly rootAliases;
|
|
20
|
-
constructor(cwd: string, roots: string[],
|
|
20
|
+
constructor(cwd: string, roots: string[], access: 'read' | 'write' | AgentRole);
|
|
21
21
|
definitions(): WorkspaceToolDefinition[];
|
|
22
22
|
private resolve;
|
|
23
23
|
execute(name: string, input: unknown): string;
|
|
@@ -33,12 +33,12 @@ export function canonicalWorkspace(cwd, allowedRoots) {
|
|
|
33
33
|
}
|
|
34
34
|
/** Application tool boundary, not an OS sandbox for concurrent external processes. */
|
|
35
35
|
export class WorkspaceTools {
|
|
36
|
-
|
|
36
|
+
access;
|
|
37
37
|
cwd;
|
|
38
38
|
roots;
|
|
39
39
|
rootAliases;
|
|
40
|
-
constructor(cwd, roots,
|
|
41
|
-
this.
|
|
40
|
+
constructor(cwd, roots, access) {
|
|
41
|
+
this.access = access;
|
|
42
42
|
const scope = canonicalWorkspace(cwd, roots);
|
|
43
43
|
this.cwd = scope.cwd;
|
|
44
44
|
this.roots = scope.roots;
|
|
@@ -52,8 +52,8 @@ export class WorkspaceTools {
|
|
|
52
52
|
definition('read_lines', 'Read numbered lines, up to 500 lines / 48 KiB, from a UTF-8 file up to 1 MiB. Returns a full-file SHA-256 for guarded edits.', { ...file, startLine: { type: 'integer', minimum: 1 }, endLine: { type: 'integer', minimum: 1 } }, ['path']),
|
|
53
53
|
definition('search_text', 'Search literal text recursively in source files. Skips dependencies, build output, metadata, binaries and symlinks. Returns bounded numbered matches and a truncation flag.', { ...file, query: { type: 'string' }, maxResults: { type: 'integer', minimum: 1, maximum: 100 } }, ['path', 'query']),
|
|
54
54
|
definition('get_diff', 'Read the staged and unstaged Git diff against HEAD for one file, up to 48 KiB. Untracked files are identified; use read_lines for their content.', file, ['path']),
|
|
55
|
-
...(this.
|
|
56
|
-
...(this.
|
|
55
|
+
...((this.access === 'write' || this.access === 'developer') ? [definition('write_file', 'Create or replace a UTF-8 source file (max 256 KiB). Parent directories are created. Runtime metadata cannot be changed.', { ...file, content: { type: 'string' } }, ['path', 'content'])] : []),
|
|
56
|
+
...((this.access === 'write' || this.access === 'developer') ? [definition('apply_patch', 'Atomically replace one exact, unique fragment in a UTF-8 file. Rejects missing or ambiguous matches. Optionally guard against stale reads with expectedHash from read_lines.', { ...file, oldText: { type: 'string', minLength: 1 }, newText: { type: 'string' }, expectedHash: { type: 'string' } }, ['path', 'oldText', 'newText'])] : []),
|
|
57
57
|
];
|
|
58
58
|
}
|
|
59
59
|
resolve(raw, write) {
|
|
@@ -92,7 +92,7 @@ export class WorkspaceTools {
|
|
|
92
92
|
throw new Error('Tool input must be a JSON object');
|
|
93
93
|
const args = input;
|
|
94
94
|
if (!this.definitions().some(tool => tool.function.name === name))
|
|
95
|
-
throw new Error(`Tool '${name}' is unavailable for the ${this.
|
|
95
|
+
throw new Error(`Tool '${name}' is unavailable for the ${this.access} role`);
|
|
96
96
|
const allowed = name === 'write_file' ? ['path', 'content'] : name === 'apply_patch' ? ['path', 'oldText', 'newText', 'expectedHash'] : name === 'read_lines' ? ['path', 'startLine', 'endLine'] : name === 'search_text' ? ['path', 'query', 'maxResults'] : ['path'];
|
|
97
97
|
if (Object.keys(args).some(key => !allowed.includes(key)) || typeof args.path !== 'string')
|
|
98
98
|
throw new Error('Invalid tool arguments');
|
package/dist/installer/cli.js
CHANGED
|
@@ -99,6 +99,11 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
99
99
|
return await dispatch(subcommand, flags, positionals);
|
|
100
100
|
}
|
|
101
101
|
catch (err) {
|
|
102
|
+
if (subcommand === 'runtime') {
|
|
103
|
+
const { runtimeFailure } = await import('../agent-runtime/cli.js');
|
|
104
|
+
process.stdout.write(JSON.stringify(runtimeFailure(err)) + '\n');
|
|
105
|
+
return 1;
|
|
106
|
+
}
|
|
102
107
|
if (isInstallerError(err)) {
|
|
103
108
|
fatal(err.message);
|
|
104
109
|
return err.exitCode;
|
|
@@ -4,8 +4,8 @@ import type { Provider } from './provider-detect.js';
|
|
|
4
4
|
* ships. The implement pipeline depends on all three. Any additional agent
|
|
5
5
|
* comes from a user-authored profile (`custom-*`), never the installer.
|
|
6
6
|
*
|
|
7
|
-
* Mirrors the
|
|
8
|
-
*
|
|
7
|
+
* Mirrors the required built-in roles in schemas/agent-runtime.schema.json
|
|
8
|
+
* (`agents`, with the sr- prefix in installed files); keep both in sync.
|
|
9
9
|
*/
|
|
10
10
|
export declare const CORE_AGENTS: Set<string>;
|
|
11
11
|
/**
|
|
@@ -11,8 +11,8 @@ import { buildManifest, writeManifestFiles } from './manifest.js';
|
|
|
11
11
|
* ships. The implement pipeline depends on all three. Any additional agent
|
|
12
12
|
* comes from a user-authored profile (`custom-*`), never the installer.
|
|
13
13
|
*
|
|
14
|
-
* Mirrors the
|
|
15
|
-
*
|
|
14
|
+
* Mirrors the required built-in roles in schemas/agent-runtime.schema.json
|
|
15
|
+
* (`agents`, with the sr- prefix in installed files); keep both in sync.
|
|
16
16
|
*/
|
|
17
17
|
export const CORE_AGENTS = new Set([
|
|
18
18
|
'sr-architect',
|
|
@@ -151,10 +151,6 @@ const SKILL_FROM_COMMAND = {
|
|
|
151
151
|
command: 'implement',
|
|
152
152
|
description: 'sr:implement — Full OpenSpec lifecycle with specialized agents: architect designs, developer implements, reviewer validates. Use for implementing frozen specs or feature descriptions.',
|
|
153
153
|
},
|
|
154
|
-
'sr-batch-implement': {
|
|
155
|
-
command: 'batch-implement',
|
|
156
|
-
description: 'sr:batch-implement — Run multiple frozen specs as one programmatic agent workflow.',
|
|
157
|
-
},
|
|
158
154
|
};
|
|
159
155
|
/**
|
|
160
156
|
* Provider-static subtrees inside a providerDir that are SHARED via symlink from
|
|
@@ -1557,7 +1553,7 @@ function renderInitialAgentsMd(repoRoot) {
|
|
|
1557
1553
|
'',
|
|
1558
1554
|
'Implementation is coordinated by the Specrails programmatic agent runtime.',
|
|
1559
1555
|
'Use the frozen scope and official OpenSpec workflow supplied for your role.',
|
|
1560
|
-
'Do not start another implement
|
|
1556
|
+
'Do not start another implement workflow inside a role.',
|
|
1561
1557
|
'',
|
|
1562
1558
|
'## Repository context',
|
|
1563
1559
|
'',
|
|
@@ -7,11 +7,18 @@ export interface PipelineSpec {
|
|
|
7
7
|
repositoryIds?: string[];
|
|
8
8
|
acceptanceCriteria?: string[];
|
|
9
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* `scope`: checkout-relative directories the repository is confined to — a
|
|
12
|
+
* package registered inside a larger checkout (a monorepo app). Edits outside
|
|
13
|
+
* them are not part of the change, and a command without `cwd` runs in the
|
|
14
|
+
* first one. Absent: the whole checkout is the repository.
|
|
15
|
+
*/
|
|
10
16
|
export interface PipelineRepository {
|
|
11
17
|
id: string;
|
|
12
18
|
name: string;
|
|
13
19
|
path: string;
|
|
14
20
|
baseSha?: string;
|
|
21
|
+
scope?: string[];
|
|
15
22
|
}
|
|
16
23
|
export interface PipelineContext {
|
|
17
24
|
schemaVersion: 1;
|
|
@@ -180,6 +187,9 @@ export interface PipelineState {
|
|
|
180
187
|
acceptanceHash: string;
|
|
181
188
|
};
|
|
182
189
|
artifactExclusions: string[];
|
|
190
|
+
/** Explicit engine-owned metadata; absent for all legacy journals. */
|
|
191
|
+
runtimeExclusions?: readonly string[];
|
|
192
|
+
repositoryExclusions?: Readonly<Record<string, readonly string[]>>;
|
|
183
193
|
preview?: {
|
|
184
194
|
baseHash: string;
|
|
185
195
|
files: PreviewFile[];
|
|
@@ -194,8 +204,24 @@ export interface PreviewFile {
|
|
|
194
204
|
contentHash?: string;
|
|
195
205
|
}
|
|
196
206
|
export declare function verificationEnvironment(env: NodeJS.ProcessEnv, overrides?: Record<string, string>, platform?: NodeJS.Platform): NodeJS.ProcessEnv;
|
|
207
|
+
/** True when a checkout-relative POSIX path belongs to the repository's scope (always, without a scope). */
|
|
208
|
+
export declare function withinRepositoryScope(repository: Pick<PipelineRepository, 'scope'>, relative: string): boolean;
|
|
209
|
+
/** Where a command without `cwd` runs: the first scope directory, else the checkout root. */
|
|
210
|
+
export declare function repositoryWorkingDirectory(repository: Pick<PipelineRepository, 'path' | 'scope'>): string;
|
|
197
211
|
export declare function validatePipelineContext(input: unknown): PipelineContext;
|
|
198
212
|
export declare function pipelineStateDirectory(context: PipelineContext): string;
|
|
213
|
+
export type CandidateScope = Pick<PipelineState, 'context' | 'scopeHash' | 'artifactExclusions'> & {
|
|
214
|
+
/** Engine-owned child journals and artifact paths, never user-defined exclusion patterns. */
|
|
215
|
+
runtimeExclusions?: readonly string[];
|
|
216
|
+
repositoryExclusions?: Readonly<Record<string, readonly string[]>>;
|
|
217
|
+
};
|
|
218
|
+
/** Bind only engine-owned metadata paths; callers supply declared run/branch ownership. */
|
|
219
|
+
export declare function bindImplementationExclusions(contextInput: PipelineContext, exclusions: {
|
|
220
|
+
runtimeExclusions: readonly string[];
|
|
221
|
+
repositoryExclusions: Readonly<Record<string, readonly string[]>>;
|
|
222
|
+
}): void;
|
|
223
|
+
/** Read the validated implementation journal's dynamic candidate exclusions. */
|
|
224
|
+
export declare function readCandidateScope(contextInput: unknown): CandidateScope;
|
|
199
225
|
export interface CandidateManifest {
|
|
200
226
|
schemaVersion: 1;
|
|
201
227
|
scopeHash: string;
|
|
@@ -205,8 +231,8 @@ export interface CandidateManifest {
|
|
|
205
231
|
files: Array<[string, string]>;
|
|
206
232
|
}>;
|
|
207
233
|
}
|
|
208
|
-
export declare function candidateManifest(state:
|
|
209
|
-
export declare function fingerprintCandidate(state:
|
|
234
|
+
export declare function candidateManifest(state: CandidateScope): CandidateManifest;
|
|
235
|
+
export declare function fingerprintCandidate(state: CandidateScope): string;
|
|
210
236
|
export declare function initializePipeline(contextInput: unknown, change: string): PipelineState;
|
|
211
237
|
/** Host-owned binding, including immutable harness bytes outside the candidate. */
|
|
212
238
|
export declare function bindVerificationPlan(contextInput: PipelineContext, hash: string, files: Array<{
|
|
@@ -259,6 +285,11 @@ export declare function verificationInvocation(command: string, args: string[],
|
|
|
259
285
|
};
|
|
260
286
|
/** Shared persisted-output/provider diagnostic redaction; preserves line structure. */
|
|
261
287
|
export declare function redactRuntimeText(text: string, env?: NodeJS.ProcessEnv): string;
|
|
288
|
+
/** Structured shell execution with the same scope, environment and tree-kill rules, without a receipt or journal. */
|
|
289
|
+
export declare function executeVerificationCommand(contextInput: unknown, raw: unknown, log?: (text: string) => void, signal?: AbortSignal, options?: {
|
|
290
|
+
deadline?: number;
|
|
291
|
+
idleTimeoutMs?: number;
|
|
292
|
+
}): Promise<CommandReceipt>;
|
|
262
293
|
export interface VerificationSnapshot {
|
|
263
294
|
eligible: boolean;
|
|
264
295
|
reason: string;
|
|
@@ -269,12 +300,25 @@ export interface VerificationSnapshot {
|
|
|
269
300
|
export declare function verificationSnapshot(context: PipelineContext, command: VerificationCommand): VerificationSnapshot;
|
|
270
301
|
/** Contiguous waves preserve baseline order; an uncertain check is a barrier. */
|
|
271
302
|
export declare function verificationWaves<T extends VerificationCommand>(commands: T[], maxConcurrency?: number): T[][];
|
|
272
|
-
export
|
|
303
|
+
export interface VerificationRunOptions {
|
|
273
304
|
deadline?: number;
|
|
274
305
|
maxConcurrency?: number;
|
|
275
306
|
idleTimeoutMs?: number;
|
|
276
307
|
onEvidence?: (kind: 'check-started' | 'check-finished' | 'check-reused' | 'check-invalidated', payload: Record<string, string | number | null>) => Promise<void>;
|
|
277
|
-
}
|
|
308
|
+
}
|
|
309
|
+
/** Persistence and candidate ownership are bound by the host, never inferred from a journal path. */
|
|
310
|
+
export interface VerificationEvidencePort {
|
|
311
|
+
candidateHash: string;
|
|
312
|
+
scopeHash: string;
|
|
313
|
+
planHash?: string;
|
|
314
|
+
previous?: VerificationReceipt;
|
|
315
|
+
isCurrent(): boolean;
|
|
316
|
+
persistCheck(result: CommandReceipt, planHash: string, candidateHash: string): void | Promise<void>;
|
|
317
|
+
commitReceipt(receipt: VerificationReceipt): VerificationReceipt | Promise<VerificationReceipt>;
|
|
318
|
+
}
|
|
319
|
+
export declare function verifyPipeline(contextInput: unknown, raw: unknown, log?: (text: string) => void, signal?: AbortSignal, options?: VerificationRunOptions): Promise<VerificationReceipt>;
|
|
320
|
+
/** Same deterministic command/evidence runner for legacy journals and ledger-only engine runs. */
|
|
321
|
+
export declare function executeVerification(contextInput: unknown, raw: unknown, evidence: VerificationEvidencePort, log?: (text: string) => void, signal?: AbortSignal, options?: VerificationRunOptions): Promise<VerificationReceipt>;
|
|
278
322
|
/** Validate a check plan without creating state or executing commands. */
|
|
279
323
|
export declare function validateVerificationRequest(contextInput: unknown, raw: unknown): void;
|
|
280
324
|
export declare function preparePreview(contextInput: unknown, raw: unknown): PipelineState;
|
|
@@ -288,6 +332,7 @@ export interface VerificationEvidenceQuery {
|
|
|
288
332
|
cursor?: string;
|
|
289
333
|
limit?: number;
|
|
290
334
|
}
|
|
335
|
+
export declare function persistCheckEvidence(context: PipelineContext, result: CommandReceipt, planHash: string, candidateHash: string): void;
|
|
291
336
|
/** Historical read: no live repository validation, source execution or lifecycle mutation. */
|
|
292
337
|
export declare function readVerificationEvidence(context: Pick<PipelineContext, 'backlogRoot' | 'runId'>, query?: VerificationEvidenceQuery): {
|
|
293
338
|
nextCursor?: string | undefined;
|
|
@@ -83,6 +83,58 @@ function safeChild(root, relative) {
|
|
|
83
83
|
}
|
|
84
84
|
return target;
|
|
85
85
|
}
|
|
86
|
+
/** Case-insensitive filesystems: compare checkout paths the way the OS resolves them. */
|
|
87
|
+
const foldedPaths = process.platform === 'win32' || process.platform === 'darwin';
|
|
88
|
+
function samePathPrefix(file, directory) {
|
|
89
|
+
const [a, b] = foldedPaths ? [file.toLowerCase(), directory.toLowerCase()] : [file, directory];
|
|
90
|
+
return a === b || a.startsWith(b + '/');
|
|
91
|
+
}
|
|
92
|
+
/** Normalized scope: existing, symlink-free directories spelled as on disk. Undefined = whole checkout. */
|
|
93
|
+
function repositoryScope(root, raw) {
|
|
94
|
+
if (!Array.isArray(raw) || raw.length === 0 || raw.length > 32)
|
|
95
|
+
fail('Invalid repository scope: expected 1–32 directories');
|
|
96
|
+
const scope = [];
|
|
97
|
+
for (const value of raw) {
|
|
98
|
+
if (typeof value !== 'string' || !value.trim() || value.includes('\0') || value.length > 1024)
|
|
99
|
+
fail('Invalid repository scope directory');
|
|
100
|
+
const relative = value.replace(/\\/g, '/').replace(/^(?:\.\/)+/, '').replace(/\/+$/, '');
|
|
101
|
+
// The checkout root is the whole repository: no narrower scope applies.
|
|
102
|
+
if (!relative || relative === '.')
|
|
103
|
+
return undefined;
|
|
104
|
+
if (/^[a-z]:/i.test(relative) || relative.startsWith('/') || relative.split('/').some(part => !part || part === '.' || part === '..'))
|
|
105
|
+
fail('Invalid repository scope directory: ' + value);
|
|
106
|
+
// Spell every component as the directory listing does, so git paths match it.
|
|
107
|
+
let current = root;
|
|
108
|
+
const parts = [];
|
|
109
|
+
for (const part of relative.split('/')) {
|
|
110
|
+
let name = part;
|
|
111
|
+
try {
|
|
112
|
+
const names = readdirSync(current);
|
|
113
|
+
if (!names.includes(part))
|
|
114
|
+
name = names.find(entry => entry.toLowerCase() === part.toLowerCase()) ?? part;
|
|
115
|
+
}
|
|
116
|
+
catch { /* the lstat below reports the missing directory */ }
|
|
117
|
+
current = path.join(current, name);
|
|
118
|
+
const stat = lstatSync(current, { throwIfNoEntry: false });
|
|
119
|
+
if (!stat)
|
|
120
|
+
fail('Repository scope directory does not exist: ' + value);
|
|
121
|
+
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
122
|
+
fail('Repository scope must be a real directory: ' + value);
|
|
123
|
+
parts.push(name);
|
|
124
|
+
}
|
|
125
|
+
scope.push(parts.join('/'));
|
|
126
|
+
}
|
|
127
|
+
const unique = [...new Set(scope)];
|
|
128
|
+
return unique.filter(item => !unique.some(other => other !== item && samePathPrefix(item, other)));
|
|
129
|
+
}
|
|
130
|
+
/** True when a checkout-relative POSIX path belongs to the repository's scope (always, without a scope). */
|
|
131
|
+
export function withinRepositoryScope(repository, relative) {
|
|
132
|
+
return !repository.scope?.length || repository.scope.some(directory => samePathPrefix(relative, directory));
|
|
133
|
+
}
|
|
134
|
+
/** Where a command without `cwd` runs: the first scope directory, else the checkout root. */
|
|
135
|
+
export function repositoryWorkingDirectory(repository) {
|
|
136
|
+
return repository.scope?.length ? path.join(repository.path, ...repository.scope[0].split('/')) : repository.path;
|
|
137
|
+
}
|
|
86
138
|
function atomicJson(file, value) {
|
|
87
139
|
mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
88
140
|
const temp = file + '.' + randomUUID() + '.tmp';
|
|
@@ -106,7 +158,9 @@ export function validatePipelineContext(input) {
|
|
|
106
158
|
fail('Invalid repository identity');
|
|
107
159
|
if (repo.baseSha !== undefined && (typeof repo.baseSha !== 'string' || !/^[a-f0-9]{40,64}$/.test(repo.baseSha)))
|
|
108
160
|
fail('Invalid repository base SHA');
|
|
109
|
-
|
|
161
|
+
const root = directory(repo.path);
|
|
162
|
+
const scope = repo.scope === undefined ? undefined : repositoryScope(root, repo.scope);
|
|
163
|
+
return { id: repo.id, name: repo.name, path: root, ...(repo.baseSha ? { baseSha: repo.baseSha } : {}), ...(scope ? { scope } : {}) };
|
|
110
164
|
});
|
|
111
165
|
if (new Set(repositories.map((repo) => repo.id)).size !== repositories.length || new Set(repositories.map((repo) => repo.path)).size !== repositories.length)
|
|
112
166
|
fail('Duplicate repository identity or path');
|
|
@@ -219,10 +273,56 @@ function saveState(state) {
|
|
|
219
273
|
atomicJson(stateFile(state.context), state);
|
|
220
274
|
}
|
|
221
275
|
function relativeUnix(value) { return value.split(path.sep).join('/'); }
|
|
276
|
+
/** Bind only engine-owned metadata paths; callers supply declared run/branch ownership. */
|
|
277
|
+
export function bindImplementationExclusions(contextInput, exclusions) {
|
|
278
|
+
const context = validatePipelineContext(contextInput);
|
|
279
|
+
const root = path.join(context.backlogRoot, '.specrails/pipeline');
|
|
280
|
+
const runtimeExclusions = [...new Set(exclusions.runtimeExclusions)].sort();
|
|
281
|
+
for (const item of runtimeExclusions) {
|
|
282
|
+
const relative = path.relative(root, item);
|
|
283
|
+
const memory = ['', '-wal', '-shm'].some(suffix => item === path.join(context.backlogRoot, '.specrails/engine-store.sqlite' + suffix));
|
|
284
|
+
if (memory) {
|
|
285
|
+
safeChild(context.backlogRoot, path.relative(context.backlogRoot, item));
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (!path.isAbsolute(item) || path.resolve(item) !== item || !ID.test(relative) || relative.includes(path.sep))
|
|
289
|
+
fail('Implementation runtime exclusion is not an admitted pipeline journal');
|
|
290
|
+
safeChild(root, relative);
|
|
291
|
+
}
|
|
292
|
+
const repositoryExclusions = {};
|
|
293
|
+
for (const [id, paths] of Object.entries(exclusions.repositoryExclusions)) {
|
|
294
|
+
const repository = context.repositories.find(repository => repository.id === id);
|
|
295
|
+
if (!repository)
|
|
296
|
+
continue; // A scoped child receives the parent union, filtered to its frozen repositories.
|
|
297
|
+
repositoryExclusions[id] = [...new Set(paths)].sort();
|
|
298
|
+
for (const item of repositoryExclusions[id]) {
|
|
299
|
+
if (!/^(?:[a-zA-Z0-9][a-zA-Z0-9._-]*\/)*openspec\/(?:changes|specs)\/[a-z0-9][a-z0-9./-]*$/.test(item))
|
|
300
|
+
fail('Implementation artifact exclusion is outside declared OpenSpec artifacts');
|
|
301
|
+
safeChild(repository.path, item);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
locked(context, () => {
|
|
305
|
+
const state = readState(context);
|
|
306
|
+
if (canonical(state.runtimeExclusions ?? []) === canonical(runtimeExclusions) && canonical(state.repositoryExclusions ?? {}) === canonical(repositoryExclusions))
|
|
307
|
+
return;
|
|
308
|
+
state.runtimeExclusions = runtimeExclusions;
|
|
309
|
+
state.repositoryExclusions = repositoryExclusions;
|
|
310
|
+
saveState(state);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
/** Read the validated implementation journal's dynamic candidate exclusions. */
|
|
314
|
+
export function readCandidateScope(contextInput) {
|
|
315
|
+
const state = readState(validatePipelineContext(contextInput));
|
|
316
|
+
return { context: state.context, scopeHash: state.scopeHash, artifactExclusions: [...state.artifactExclusions], ...(state.runtimeExclusions ? { runtimeExclusions: [...state.runtimeExclusions] } : {}), ...(state.repositoryExclusions ? { repositoryExclusions: structuredClone(state.repositoryExclusions) } : {}) };
|
|
317
|
+
}
|
|
222
318
|
function excluded(state, repo, relative) {
|
|
223
319
|
const absolute = path.join(repo.path, relative);
|
|
224
320
|
if (within(pipelineStateDirectory(state.context), absolute))
|
|
225
321
|
return true;
|
|
322
|
+
if (state.runtimeExclusions?.some(directory => within(directory, absolute)))
|
|
323
|
+
return true;
|
|
324
|
+
if (state.repositoryExclusions?.[repo.id]?.some(item => relative === item || relative.startsWith(item + '/')))
|
|
325
|
+
return true;
|
|
226
326
|
if (relative === '.specrails/runtime' || relative.startsWith('.specrails/runtime/'))
|
|
227
327
|
return true;
|
|
228
328
|
if (repo.id !== state.context.artifactRepositoryId)
|
|
@@ -711,7 +811,7 @@ function validateCommand(context, raw) {
|
|
|
711
811
|
const repo = context.repositories.find((item) => item.id === command.repositoryId);
|
|
712
812
|
if (!repo || typeof command.command !== 'string' || !command.command || command.command.includes('\0') || !Array.isArray(command.args) || !command.args.every((arg) => typeof arg === 'string' && !arg.includes('\0')))
|
|
713
813
|
fail('Invalid verification command');
|
|
714
|
-
const cwd = command.cwd === undefined ? repo
|
|
814
|
+
const cwd = command.cwd === undefined ? directory(repositoryWorkingDirectory(repo)) : directory(path.resolve(repo.path, String(command.cwd)));
|
|
715
815
|
if (!within(repo.path, cwd))
|
|
716
816
|
fail('Verification cwd escapes selected repository');
|
|
717
817
|
const env = command.env === undefined ? undefined : object(command.env);
|
|
@@ -751,6 +851,12 @@ function validateCommand(context, raw) {
|
|
|
751
851
|
export function verificationInvocation(command, args, cwd, platform = process.platform, env = process.env) {
|
|
752
852
|
if (platform !== 'win32')
|
|
753
853
|
return { command, args };
|
|
854
|
+
// An explicit shell line (`cmd /d /s /c <line>`) is already cmd syntax. Node's
|
|
855
|
+
// default quoting would escape its inner quotes with backslashes, which cmd does
|
|
856
|
+
// not understand. Pass it verbatim: /s strips exactly the outer quotes we add.
|
|
857
|
+
if (/(^|[\\/])cmd(\.exe)?$/i.test(command) && args.length === 4 && args[0].toLowerCase() === '/d' && args[1].toLowerCase() === '/s' && args[2].toLowerCase() === '/c') {
|
|
858
|
+
return { command, args: ['/d', '/s', '/c', '"' + args[3] + '"'], windowsVerbatimArguments: true };
|
|
859
|
+
}
|
|
754
860
|
let resolved = command;
|
|
755
861
|
if (!/\.(cmd|bat|exe|com)$/i.test(command)) {
|
|
756
862
|
const pathValue = Object.entries(env).find(([key]) => key.toLowerCase() === 'path')?.[1] ?? '';
|
|
@@ -791,6 +897,10 @@ export function redactRuntimeText(text, env = process.env) {
|
|
|
791
897
|
}
|
|
792
898
|
/** Silence a verification command may keep before it counts as hung (env `SPECRAILS_VERIFY_IDLE_TIMEOUT_MS`, default 5 min). */
|
|
793
899
|
function verificationIdleTimeoutMs() { const raw = Number(process.env.SPECRAILS_VERIFY_IDLE_TIMEOUT_MS); return Number.isFinite(raw) && raw >= 100 ? raw : 5 * 60_000; }
|
|
900
|
+
/** Structured shell execution with the same scope, environment and tree-kill rules, without a receipt or journal. */
|
|
901
|
+
export async function executeVerificationCommand(contextInput, raw, log = () => { }, signal, options = {}) {
|
|
902
|
+
return executeCheck(validateCommand(validatePipelineContext(contextInput), raw), log, signal, options.deadline, undefined, options.idleTimeoutMs);
|
|
903
|
+
}
|
|
794
904
|
async function executeCheck(command, log, signal, deadline, evidenceId = digest(randomUUID()), idleTimeoutOverrideMs) {
|
|
795
905
|
const started = Date.now();
|
|
796
906
|
const overrides = normalizeVerificationEnvironment(command.env ?? {});
|
|
@@ -1024,7 +1134,7 @@ export function verificationWaves(commands, maxConcurrency = 1) {
|
|
|
1024
1134
|
}
|
|
1025
1135
|
export async function verifyPipeline(contextInput, raw, log = () => { }, signal, options = {}) {
|
|
1026
1136
|
const context = validatePipelineContext(contextInput);
|
|
1027
|
-
const { request
|
|
1137
|
+
const { request } = verificationPlan(context, raw);
|
|
1028
1138
|
let previous;
|
|
1029
1139
|
const state = locked(context, () => {
|
|
1030
1140
|
const current = readState(context);
|
|
@@ -1043,6 +1153,33 @@ export async function verifyPipeline(contextInput, raw, log = () => { }, signal,
|
|
|
1043
1153
|
if (identityProblems.length)
|
|
1044
1154
|
fail(identityProblems.join('; '));
|
|
1045
1155
|
const candidateHash = fingerprintCandidate(state);
|
|
1156
|
+
const isCurrent = () => {
|
|
1157
|
+
const current = readState(context);
|
|
1158
|
+
return fingerprintCandidate(current) === candidateHash && current.revision === state.revision && planReasons(current, request.planHash).length === 0;
|
|
1159
|
+
};
|
|
1160
|
+
return executeVerification(context, raw, {
|
|
1161
|
+
candidateHash, scopeHash: state.scopeHash, planHash: state.verificationPlan?.hash, previous,
|
|
1162
|
+
isCurrent: () => locked(context, isCurrent),
|
|
1163
|
+
persistCheck: (result, planHash, candidate) => persistCheckEvidence(context, result, planHash, candidate),
|
|
1164
|
+
commitReceipt: receipt => locked(context, () => {
|
|
1165
|
+
const current = readState(context);
|
|
1166
|
+
if (!isCurrent())
|
|
1167
|
+
receipt = { ...receipt, valid: false, reason: 'Candidate changed during verification' };
|
|
1168
|
+
atomicJson(safeChild(pipelineStateDirectory(context), 'receipts/' + receipt.id + '.json'), receipt);
|
|
1169
|
+
if (receipt.kind === 'full' || !receipt.valid || !current.verification)
|
|
1170
|
+
current.verification = receipt;
|
|
1171
|
+
else if (previous?.valid && previous.candidateHash === candidateHash)
|
|
1172
|
+
current.verification = previous;
|
|
1173
|
+
saveState(current);
|
|
1174
|
+
return receipt;
|
|
1175
|
+
}),
|
|
1176
|
+
}, log, signal, options);
|
|
1177
|
+
}
|
|
1178
|
+
/** Same deterministic command/evidence runner for legacy journals and ledger-only engine runs. */
|
|
1179
|
+
export async function executeVerification(contextInput, raw, evidence, log = () => { }, signal, options = {}) {
|
|
1180
|
+
const context = validatePipelineContext(contextInput);
|
|
1181
|
+
const { request, commands, unverifiedRepositories } = verificationPlan(context, raw);
|
|
1182
|
+
const { candidateHash, previous } = evidence;
|
|
1046
1183
|
const results = [];
|
|
1047
1184
|
const controller = new AbortController();
|
|
1048
1185
|
const abort = () => controller.abort();
|
|
@@ -1062,7 +1199,7 @@ export async function verifyPipeline(contextInput, raw, log = () => { }, signal,
|
|
|
1062
1199
|
const evidenceId = digest(randomUUID());
|
|
1063
1200
|
if (!reuse) {
|
|
1064
1201
|
const pending = { repositoryId: command.repositoryId, key: command.key, label: command.label, command: command.command, args: command.args, cwd: command.cwd, environmentHash: '', environmentKeys: [], environmentOverrideKeys: [], environmentOverridesHash: '', evidenceId, pending: true, disposition: 'executed', outcome: 'interrupted', exitCode: -1, durationMs: 0, output: '' };
|
|
1065
|
-
|
|
1202
|
+
await evidence.persistCheck(pending, request.planHash ?? digest(canonical(commands)), candidateHash);
|
|
1066
1203
|
await options.onEvidence?.('check-started', { executionId: evidenceId, repositoryId: command.repositoryId, checkId: command.key ?? '', label: command.label ?? command.command });
|
|
1067
1204
|
}
|
|
1068
1205
|
const result = reuse ? { ...old, evidenceId, disposition: 'reused', reusedFrom: old.evidenceId, durationMs: 0, snapshot, reuseReason: 'snapshot-local-identities-match' } : { ...await executeCheck(command, text => log(prefix + text), controller.signal, options.deadline, evidenceId, options.idleTimeoutMs), disposition: 'executed', snapshot, reuseReason: snapshot.eligible ? 'snapshot-local-no-current-match' : snapshot.reason };
|
|
@@ -1077,7 +1214,7 @@ export async function verifyPipeline(contextInput, raw, log = () => { }, signal,
|
|
|
1077
1214
|
}
|
|
1078
1215
|
if (result.exitCode !== 0)
|
|
1079
1216
|
controller.abort();
|
|
1080
|
-
|
|
1217
|
+
await evidence.persistCheck(result, request.planHash ?? digest(canonical(commands)), candidateHash);
|
|
1081
1218
|
await options.onEvidence?.(reuse ? 'check-reused' : 'check-finished', { executionId: evidenceId, repositoryId: command.repositoryId, checkId: command.key ?? '', label: command.label ?? command.command, exitCode: result.exitCode, durationMs: result.durationMs, reason: result.reuseReason ?? null });
|
|
1082
1219
|
return result;
|
|
1083
1220
|
}
|
|
@@ -1106,25 +1243,15 @@ export async function verifyPipeline(contextInput, raw, log = () => { }, signal,
|
|
|
1106
1243
|
for (const command of commands.slice(results.length)) {
|
|
1107
1244
|
const id = digest(randomUUID());
|
|
1108
1245
|
const skipped = { repositoryId: command.repositoryId, key: command.key, label: command.label, command: command.command, args: command.args, cwd: command.cwd, environmentHash: '', environmentKeys: [], environmentOverrideKeys: [], environmentOverridesHash: '', evidenceId: id, disposition: 'not-run', outcome: 'cancelled', exitCode: -1, durationMs: 0, output: 'Not run after cancellation or an earlier failed check' };
|
|
1109
|
-
|
|
1246
|
+
await evidence.persistCheck(skipped, request.planHash ?? digest(canonical(commands)), candidateHash);
|
|
1110
1247
|
notRunEvidenceIds.push(id);
|
|
1111
1248
|
}
|
|
1112
|
-
const
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
...(changed ? { reason: 'Candidate changed during verification' } : results.some((result) => result.exitCode !== 0) ? { reason: 'A verification command failed' } : {}),
|
|
1119
|
-
...(unverifiedRepositories.length ? { unverifiedRepositories } : {}),
|
|
1120
|
-
};
|
|
1121
|
-
atomicJson(safeChild(pipelineStateDirectory(context), 'receipts/' + receipt.id + '.json'), receipt);
|
|
1122
|
-
if (receipt.kind === 'full' || !receipt.valid || !current.verification)
|
|
1123
|
-
current.verification = receipt;
|
|
1124
|
-
else if (previous?.valid && previous.candidateHash === candidateHash)
|
|
1125
|
-
current.verification = previous;
|
|
1126
|
-
saveState(current);
|
|
1127
|
-
return receipt;
|
|
1249
|
+
const changed = !evidence.isCurrent();
|
|
1250
|
+
const receipt = await evidence.commitReceipt({
|
|
1251
|
+
id: randomUUID(), ...(evidence.planHash ? { planHash: evidence.planHash } : {}), kind: request.kind, scopeHash: evidence.scopeHash, candidateHash, commands: results, ...(notRunEvidenceIds.length ? { notRunEvidenceIds } : {}),
|
|
1252
|
+
completedAt: new Date().toISOString(), valid: !changed && !signal?.aborted && (options.deadline === undefined || Date.now() <= options.deadline) && results.length === commands.length && results.every((result) => result.exitCode === 0),
|
|
1253
|
+
...(changed ? { reason: 'Candidate changed during verification' } : results.some((result) => result.exitCode !== 0) ? { reason: 'A verification command failed' } : {}),
|
|
1254
|
+
...(unverifiedRepositories.length ? { unverifiedRepositories } : {}),
|
|
1128
1255
|
});
|
|
1129
1256
|
if (!receipt.valid)
|
|
1130
1257
|
for (const result of results) {
|
|
@@ -1352,7 +1479,7 @@ function evidenceSummary(document) {
|
|
|
1352
1479
|
const { stdout: _out, stderr: _err, sources, ...summary } = document;
|
|
1353
1480
|
return { ...summary, sources: sources.map(({ text: _text, ...source }) => source) };
|
|
1354
1481
|
}
|
|
1355
|
-
function persistCheckEvidence(context, result, planHash, candidateHash) {
|
|
1482
|
+
export function persistCheckEvidence(context, result, planHash, candidateHash) {
|
|
1356
1483
|
const id = result.evidenceId;
|
|
1357
1484
|
const planPath = safeChild(pipelineStateDirectory(context), 'verification/plan.json');
|
|
1358
1485
|
const plan = existsSync(planPath) ? object(readJson(planPath)) : undefined;
|