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.
Files changed (173) hide show
  1. package/README.md +1 -1
  2. package/dist/agent-runtime/capabilities.js +3 -1
  3. package/dist/agent-runtime/change-scope.d.ts +63 -0
  4. package/dist/agent-runtime/change-scope.js +297 -0
  5. package/dist/agent-runtime/cli-executor.d.ts +3 -0
  6. package/dist/agent-runtime/cli-executor.js +50 -13
  7. package/dist/agent-runtime/cli-process.js +6 -2
  8. package/dist/agent-runtime/cli.d.ts +15 -0
  9. package/dist/agent-runtime/cli.js +43 -9
  10. package/dist/agent-runtime/compact/chat-client.js +2 -1
  11. package/dist/agent-runtime/compact/environment.d.ts +12 -0
  12. package/dist/agent-runtime/compact/environment.js +31 -1
  13. package/dist/agent-runtime/compact/test-reachability.js +6 -2
  14. package/dist/agent-runtime/config.d.ts +4 -1
  15. package/dist/agent-runtime/config.js +41 -3
  16. package/dist/agent-runtime/core-host.d.ts +10 -4
  17. package/dist/agent-runtime/core-host.js +22 -19
  18. package/dist/agent-runtime/efficiency-summary.d.ts +4 -1
  19. package/dist/agent-runtime/efficiency-summary.js +30 -27
  20. package/dist/agent-runtime/efficiency-types.d.ts +4 -4
  21. package/dist/agent-runtime/efficiency.d.ts +1 -1
  22. package/dist/agent-runtime/efficiency.js +7 -6
  23. package/dist/agent-runtime/engine/__fixtures__/implementation-component.json +90 -0
  24. package/dist/agent-runtime/engine/__fixtures__/implementation-fixture.d.ts +11 -0
  25. package/dist/agent-runtime/engine/__fixtures__/implementation-fixture.js +60 -0
  26. package/dist/agent-runtime/engine/__fixtures__/implementation.json +43 -0
  27. package/dist/agent-runtime/engine/budget.d.ts +9 -0
  28. package/dist/agent-runtime/engine/budget.js +33 -0
  29. package/dist/agent-runtime/engine/canonical-json.d.ts +9 -0
  30. package/dist/agent-runtime/engine/canonical-json.js +162 -0
  31. package/dist/agent-runtime/engine/checkpoint/database.d.ts +67 -0
  32. package/dist/agent-runtime/engine/checkpoint/database.js +304 -0
  33. package/dist/agent-runtime/engine/checkpoint/lease.d.ts +17 -0
  34. package/dist/agent-runtime/engine/checkpoint/lease.js +82 -0
  35. package/dist/agent-runtime/engine/checkpoint/ledger.d.ts +133 -0
  36. package/dist/agent-runtime/engine/checkpoint/ledger.js +510 -0
  37. package/dist/agent-runtime/engine/checkpoint/saver.d.ts +25 -0
  38. package/dist/agent-runtime/engine/checkpoint/saver.js +146 -0
  39. package/dist/agent-runtime/engine/cli.d.ts +10 -0
  40. package/dist/agent-runtime/engine/cli.js +172 -0
  41. package/dist/agent-runtime/engine/compiler.d.ts +23 -0
  42. package/dist/agent-runtime/engine/compiler.js +261 -0
  43. package/dist/agent-runtime/engine/composition.d.ts +16 -0
  44. package/dist/agent-runtime/engine/composition.js +28 -0
  45. package/dist/agent-runtime/engine/concurrency.d.ts +14 -0
  46. package/dist/agent-runtime/engine/concurrency.js +55 -0
  47. package/dist/agent-runtime/engine/contracts.d.ts +273 -0
  48. package/dist/agent-runtime/engine/contracts.js +11 -0
  49. package/dist/agent-runtime/engine/definition-schema.d.ts +250 -0
  50. package/dist/agent-runtime/engine/definition-schema.js +313 -0
  51. package/dist/agent-runtime/engine/definition-types.d.ts +84 -0
  52. package/dist/agent-runtime/engine/definition-types.js +3 -0
  53. package/dist/agent-runtime/engine/definition-validator.d.ts +7 -0
  54. package/dist/agent-runtime/engine/definition-validator.js +187 -0
  55. package/dist/agent-runtime/engine/efficiency-summary.d.ts +62 -0
  56. package/dist/agent-runtime/engine/efficiency-summary.js +31 -0
  57. package/dist/agent-runtime/engine/events.d.ts +19 -0
  58. package/dist/agent-runtime/engine/events.js +88 -0
  59. package/dist/agent-runtime/engine/execution.d.ts +29 -0
  60. package/dist/agent-runtime/engine/execution.js +121 -0
  61. package/dist/agent-runtime/engine/expressions.d.ts +11 -0
  62. package/dist/agent-runtime/engine/expressions.js +172 -0
  63. package/dist/agent-runtime/engine/fork.d.ts +28 -0
  64. package/dist/agent-runtime/engine/fork.js +211 -0
  65. package/dist/agent-runtime/engine/graph-description.d.ts +48 -0
  66. package/dist/agent-runtime/engine/graph-description.js +16 -0
  67. package/dist/agent-runtime/engine/implementation-compiler.d.ts +214 -0
  68. package/dist/agent-runtime/engine/implementation-compiler.js +91 -0
  69. package/dist/agent-runtime/engine/index.d.ts +14 -0
  70. package/dist/agent-runtime/engine/index.js +14 -0
  71. package/dist/agent-runtime/engine/invocation-context.d.ts +11 -0
  72. package/dist/agent-runtime/engine/invocation-context.js +128 -0
  73. package/dist/agent-runtime/engine/otel.d.ts +15 -0
  74. package/dist/agent-runtime/engine/otel.js +132 -0
  75. package/dist/agent-runtime/engine/output.d.ts +4 -0
  76. package/dist/agent-runtime/engine/output.js +73 -0
  77. package/dist/agent-runtime/engine/piece-dependencies.d.ts +50 -0
  78. package/dist/agent-runtime/engine/piece-dependencies.js +193 -0
  79. package/dist/agent-runtime/engine/piece-registry.d.ts +19 -0
  80. package/dist/agent-runtime/engine/piece-registry.js +62 -0
  81. package/dist/agent-runtime/engine/pieces/assign.d.ts +4 -0
  82. package/dist/agent-runtime/engine/pieces/assign.js +35 -0
  83. package/dist/agent-runtime/engine/pieces/control.d.ts +5 -0
  84. package/dist/agent-runtime/engine/pieces/control.js +49 -0
  85. package/dist/agent-runtime/engine/pieces/decider.d.ts +5 -0
  86. package/dist/agent-runtime/engine/pieces/decider.js +70 -0
  87. package/dist/agent-runtime/engine/pieces/implementation-binding.d.ts +12 -0
  88. package/dist/agent-runtime/engine/pieces/implementation-binding.js +40 -0
  89. package/dist/agent-runtime/engine/pieces/implementation-journal.d.ts +25 -0
  90. package/dist/agent-runtime/engine/pieces/implementation-journal.js +192 -0
  91. package/dist/agent-runtime/engine/pieces/implementation.d.ts +23 -0
  92. package/dist/agent-runtime/engine/pieces/implementation.js +176 -0
  93. package/dist/agent-runtime/engine/pieces/index.d.ts +7 -0
  94. package/dist/agent-runtime/engine/pieces/index.js +22 -0
  95. package/dist/agent-runtime/engine/pieces/openspec.d.ts +3 -0
  96. package/dist/agent-runtime/engine/pieces/openspec.js +69 -0
  97. package/dist/agent-runtime/engine/pieces/ports.d.ts +48 -0
  98. package/dist/agent-runtime/engine/pieces/ports.js +1 -0
  99. package/dist/agent-runtime/engine/pieces/project-memory.d.ts +3 -0
  100. package/dist/agent-runtime/engine/pieces/project-memory.js +13 -0
  101. package/dist/agent-runtime/engine/pieces/prompt.d.ts +5 -0
  102. package/dist/agent-runtime/engine/pieces/prompt.js +130 -0
  103. package/dist/agent-runtime/engine/pieces/role-turn.d.ts +7 -0
  104. package/dist/agent-runtime/engine/pieces/role-turn.js +73 -0
  105. package/dist/agent-runtime/engine/pieces/shared.d.ts +67 -0
  106. package/dist/agent-runtime/engine/pieces/shared.js +40 -0
  107. package/dist/agent-runtime/engine/pieces/shell.d.ts +6 -0
  108. package/dist/agent-runtime/engine/pieces/shell.js +47 -0
  109. package/dist/agent-runtime/engine/pieces/verify.d.ts +7 -0
  110. package/dist/agent-runtime/engine/pieces/verify.js +52 -0
  111. package/dist/agent-runtime/engine/preflight.d.ts +27 -0
  112. package/dist/agent-runtime/engine/preflight.js +54 -0
  113. package/dist/agent-runtime/engine/regex.d.ts +3 -0
  114. package/dist/agent-runtime/engine/regex.js +19 -0
  115. package/dist/agent-runtime/engine/run-status.d.ts +563 -0
  116. package/dist/agent-runtime/engine/run-status.js +61 -0
  117. package/dist/agent-runtime/engine/runs.d.ts +605 -0
  118. package/dist/agent-runtime/engine/runs.js +247 -0
  119. package/dist/agent-runtime/engine/state.d.ts +64 -0
  120. package/dist/agent-runtime/engine/state.js +98 -0
  121. package/dist/agent-runtime/engine/steering/inbox.d.ts +54 -0
  122. package/dist/agent-runtime/engine/steering/inbox.js +113 -0
  123. package/dist/agent-runtime/engine/storage/fork-allocation.d.ts +21 -0
  124. package/dist/agent-runtime/engine/storage/fork-allocation.js +143 -0
  125. package/dist/agent-runtime/engine/storage/private-path.d.ts +11 -0
  126. package/dist/agent-runtime/engine/storage/private-path.js +74 -0
  127. package/dist/agent-runtime/engine/store/sqlite-store.d.ts +26 -0
  128. package/dist/agent-runtime/engine/store/sqlite-store.js +227 -0
  129. package/dist/agent-runtime/evaluation-corpus.d.ts +125 -0
  130. package/dist/agent-runtime/evaluation-corpus.js +4 -0
  131. package/dist/agent-runtime/evaluation.d.ts +9 -3
  132. package/dist/agent-runtime/evaluation.js +50 -8
  133. package/dist/agent-runtime/executor-types.d.ts +53 -3
  134. package/dist/agent-runtime/executor-types.js +35 -2
  135. package/dist/agent-runtime/executors.js +2 -2
  136. package/dist/agent-runtime/graph/artifacts.d.ts +7 -0
  137. package/dist/agent-runtime/graph/artifacts.js +86 -10
  138. package/dist/agent-runtime/graph/convergence.d.ts +26 -0
  139. package/dist/agent-runtime/graph/convergence.js +62 -0
  140. package/dist/agent-runtime/graph/nodes.d.ts +2 -0
  141. package/dist/agent-runtime/graph/nodes.js +171 -24
  142. package/dist/agent-runtime/graph/roles.d.ts +6 -0
  143. package/dist/agent-runtime/graph/roles.js +23 -17
  144. package/dist/agent-runtime/graph/state.d.ts +5 -0
  145. package/dist/agent-runtime/graph/state.js +2 -0
  146. package/dist/agent-runtime/kimi-acp.js +6 -6
  147. package/dist/agent-runtime/openai-executor.js +17 -12
  148. package/dist/agent-runtime/openspec.d.ts +17 -2
  149. package/dist/agent-runtime/openspec.js +54 -20
  150. package/dist/agent-runtime/prompts.d.ts +11 -5
  151. package/dist/agent-runtime/prompts.js +71 -20
  152. package/dist/agent-runtime/recovery.d.ts +1 -1
  153. package/dist/agent-runtime/repository-context.js +20 -1
  154. package/dist/agent-runtime/role-routing.js +4 -2
  155. package/dist/agent-runtime/role-state.d.ts +5 -0
  156. package/dist/agent-runtime/verification-plan.d.ts +2 -0
  157. package/dist/agent-runtime/verification-plan.js +18 -3
  158. package/dist/agent-runtime/workflow-types.d.ts +2 -0
  159. package/dist/agent-runtime/workflow.d.ts +3 -1
  160. package/dist/agent-runtime/workflow.js +7 -3
  161. package/dist/agent-runtime/workspace-tools.d.ts +2 -2
  162. package/dist/agent-runtime/workspace-tools.js +6 -6
  163. package/dist/installer/cli.js +5 -0
  164. package/dist/installer/phases/scaffold.d.ts +2 -2
  165. package/dist/installer/phases/scaffold.js +3 -7
  166. package/dist/pipeline/pipeline-state.d.ts +49 -4
  167. package/dist/pipeline/pipeline-state.js +150 -23
  168. package/integration-contract.json +84 -12
  169. package/package.json +12 -5
  170. package/schemas/agent-runtime.schema.json +93 -1
  171. package/schemas/workflow-definition.schema.json +312 -0
  172. package/templates/kimi/specrails/run-skill.mjs +15 -1
  173. 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
- validateVerificationRequest(context, { kind: 'scoped', commands: [check] });
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
- 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), command, origin)));
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: ids.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 })),
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 role;
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[], role: AgentRole);
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
- role;
36
+ access;
37
37
  cwd;
38
38
  roots;
39
39
  rootAliases;
40
- constructor(cwd, roots, role) {
41
- this.role = role;
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.role === '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.role === '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'])] : []),
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.role} role`);
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');
@@ -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 `allOf` baseline in schemas/profile.v1.json — update
8
- * both files together if this set ever changes.
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 `allOf` baseline in schemas/profile.v1.json — update
15
- * both files together if this set ever changes.
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 or batch-implement workflow inside a role.',
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: PipelineState): CandidateManifest;
209
- export declare function fingerprintCandidate(state: PipelineState): string;
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 declare function verifyPipeline(contextInput: unknown, raw: unknown, log?: (text: string) => void, signal?: AbortSignal, options?: {
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
- }): Promise<VerificationReceipt>;
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
- return { id: repo.id, name: repo.name, path: directory(repo.path), ...(repo.baseSha ? { baseSha: repo.baseSha } : {}) };
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.path : directory(path.resolve(repo.path, String(command.cwd)));
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, commands, unverifiedRepositories } = verificationPlan(context, raw);
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
- persistCheckEvidence(context, pending, request.planHash ?? digest(canonical(commands)), candidateHash);
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
- persistCheckEvidence(context, result, request.planHash ?? digest(canonical(commands)), candidateHash);
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
- persistCheckEvidence(context, skipped, request.planHash ?? digest(canonical(commands)), candidateHash);
1246
+ await evidence.persistCheck(skipped, request.planHash ?? digest(canonical(commands)), candidateHash);
1110
1247
  notRunEvidenceIds.push(id);
1111
1248
  }
1112
- const receipt = locked(context, () => {
1113
- const current = readState(context);
1114
- const changed = fingerprintCandidate(current) !== candidateHash || current.revision !== state.revision || planReasons(current, request.planHash).length > 0;
1115
- const receipt = {
1116
- id: randomUUID(), ...(state.verificationPlan ? { planHash: state.verificationPlan.hash } : {}), kind: request.kind, scopeHash: state.scopeHash, candidateHash, commands: results, ...(notRunEvidenceIds.length ? { notRunEvidenceIds } : {}),
1117
- 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),
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;