specrails-core 6.1.0 → 6.2.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 (42) hide show
  1. package/README.md +3 -3
  2. package/dist/agent-runtime/change-scope.js +2 -0
  3. package/dist/agent-runtime/cli-executor.js +2 -1
  4. package/dist/agent-runtime/cli.js +1 -1
  5. package/dist/agent-runtime/engine/checkpoint/ledger.js +2 -2
  6. package/dist/agent-runtime/engine/cli.js +1 -1
  7. package/dist/agent-runtime/engine/compiler.js +9 -8
  8. package/dist/agent-runtime/engine/definition-schema.d.ts +1 -1
  9. package/dist/agent-runtime/engine/definition-schema.js +2 -0
  10. package/dist/agent-runtime/engine/definition-types.d.ts +1 -1
  11. package/dist/agent-runtime/engine/definition-types.js +2 -2
  12. package/dist/agent-runtime/engine/definition-validator.js +6 -2
  13. package/dist/agent-runtime/engine/expressions.d.ts +1 -1
  14. package/dist/agent-runtime/engine/expressions.js +16 -3
  15. package/dist/agent-runtime/engine/fork.js +51 -9
  16. package/dist/agent-runtime/engine/graph-description.d.ts +3 -3
  17. package/dist/agent-runtime/engine/invocation-context.js +2 -0
  18. package/dist/agent-runtime/engine/piece-dependencies.d.ts +6 -2
  19. package/dist/agent-runtime/engine/piece-dependencies.js +54 -7
  20. package/dist/agent-runtime/engine/piece-registry.d.ts +1 -1
  21. package/dist/agent-runtime/engine/piece-registry.js +1 -1
  22. package/dist/agent-runtime/engine/pieces/artifact-contract.d.ts +4 -0
  23. package/dist/agent-runtime/engine/pieces/artifact-contract.js +70 -0
  24. package/dist/agent-runtime/engine/pieces/control.js +7 -3
  25. package/dist/agent-runtime/engine/pieces/implementation-binding.d.ts +1 -0
  26. package/dist/agent-runtime/engine/pieces/implementation-step.d.ts +5 -0
  27. package/dist/agent-runtime/engine/pieces/implementation-step.js +47 -0
  28. package/dist/agent-runtime/engine/pieces/implementation.d.ts +5 -1
  29. package/dist/agent-runtime/engine/pieces/implementation.js +8 -7
  30. package/dist/agent-runtime/engine/pieces/index.js +3 -1
  31. package/dist/agent-runtime/engine/pieces/openspec.js +8 -2
  32. package/dist/agent-runtime/engine/pieces/ports.d.ts +6 -1
  33. package/dist/agent-runtime/engine/pieces/prompt.js +2 -2
  34. package/dist/agent-runtime/engine/pieces/role-turn.js +1 -1
  35. package/dist/agent-runtime/engine/pieces/verify.js +53 -3
  36. package/dist/agent-runtime/graph/roles.d.ts +2 -0
  37. package/dist/agent-runtime/graph/roles.js +2 -0
  38. package/dist/agent-runtime/prompts.js +1 -1
  39. package/dist/pipeline/pipeline-state.js +2 -0
  40. package/integration-contract.json +32 -12
  41. package/package.json +2 -2
  42. package/schemas/workflow-definition.schema.json +2 -0
package/README.md CHANGED
@@ -6,8 +6,8 @@
6
6
 
7
7
  The agent workflow engine behind [Specrails Desktop](https://github.com/fjpulidop/specrails-desktop). Core does two things:
8
8
 
9
- 1. **Installs** the specrails workflow into a project for Claude Code, Codex CLI, Gemini CLI or Kimi Code: three roles (architect, developer, reviewer), the `implement` and `retry` entry points (`implement` runs one ticket or several tickets as one aggregate run) and the official OpenSpec skills.
10
- 2. **Runs** implementations through the [programmatic agent runtime](docs/agent-runtime.md): a LangGraph workflow (architect → developer → verify → reviewer → archive) with per-role providers, deterministic verification, durable recovery and OpenAI-compatible local models.
9
+ 1. **Installs** the specrails workflow into a project for Claude Code or Codex CLI: three roles (architect, developer, reviewer), the `implement` and `retry` entry points (`implement` runs one ticket or several tickets as one aggregate run) and the official OpenSpec skills.
10
+ 2. **Executes** Desktop-owned workflow definitions through the [programmatic agent runtime](docs/agent-runtime.md): independent agent and implementation operations, per-role providers, deterministic verification, durable recovery and OpenAI-compatible local models. Desktop owns workflow composition and editable loop agents; Core retains the old builtin for saved runs.
11
11
 
12
12
  Desktop bundles a pinned Core package and owns everything around it: worktrees, commits, pull requests, backlog and the user interface. Core never ships code on its own; it prepares a reviewed candidate for the host.
13
13
 
@@ -31,7 +31,7 @@ bin/specrails-core.mjs npm bin shim → dist/installer/cli.js
31
31
  src/installer/ CLI, framework lifecycle, provider rendering
32
32
  src/pipeline/ pipeline journal, gates and verification receipts
33
33
  src/agent-runtime/ programmatic runtime: graph, executors, compact loop, recovery
34
- templates/ role definitions, workflow commands, provider settings, Kimi runner
34
+ templates/ role definitions, workflow commands, provider settings
35
35
  schemas/ runtime configuration schema and fixtures
36
36
  integration-contract.json Desktop ⇄ Core contract
37
37
  ```
@@ -45,6 +45,8 @@ export function withScopeDefault(context, command) {
45
45
  if (command.cwd !== undefined)
46
46
  return command;
47
47
  const scope = context.repositories.find(repo => repo.id === command.repositoryId)?.scope;
48
+ if (scope && scope.length > 1)
49
+ throw new Error('Verification must name an explicit cwd for each selected code workspace');
48
50
  return scope?.length ? { ...command, cwd: scope[0] } : command;
49
51
  }
50
52
  /** Model-proposed checks stay inside the repository scope: they prove the change, not the whole checkout. */
@@ -32,7 +32,8 @@ export function buildCliInvocation(provider, request, options = {}) {
32
32
  }
33
33
  const readOnly = requestPolicy(request).access === 'read';
34
34
  // Pin the product alias; explicit IDs remain reproducible across releases.
35
- const modelId = provider === 'claude' && request.model === 'opus' ? 'claude-opus-5-5' : request.model;
35
+ const claudeModelIds = { opus: 'claude-opus-5-5', sonnet: 'claude-sonnet-5-5' };
36
+ const modelId = provider === 'claude' && request.model ? claudeModelIds[request.model] ?? request.model : request.model;
36
37
  const model = modelId ? ['--model', modelId] : [];
37
38
  const extraRoots = request.allowedRoots.filter(root => root !== request.cwd);
38
39
  const resume = request.resumeSessionId;
@@ -158,7 +158,7 @@ export async function runRuntimeCommand(flags, positionals, emit = value => proc
158
158
  emit({ type: 'runtime-api', apiVersion: RUNTIME_API_VERSION, coreVersion: CORE_PACKAGE_VERSION, runtimeIdentity: coreRuntimeIdentity(), workflowVersions: [CORE_WORKFLOW_VERSION],
159
159
  engineVersion: 2, engines: RUNTIME_ENGINES, nodeKindsVersion: NODE_KINDS_VERSION, nodeKinds: validationPieceRegistry().kinds(),
160
160
  capabilities: { openRoles: 1, scopedRecovery: 1, efficientRoleExecution: 1, reproducibleVerification: 1, implementationEfficiencyMetrics: 1, compactAgentLoop: 1, configurableGuardrails: 1, compactOutputBudget: 1, roleThinkingControl: 1, repositoryScope: 1,
161
- engineV2: 1, workflowDefinitions: 1, fanOut: 1, fork: 1, forkIdempotency: 1, steeringInbox: 1 }, guardrails: GUARDRAIL_CATALOG });
161
+ engineV2: 1, workflowAgentSteps: 1, implementationSteps: 1, workflowDefinitions: 1, fanOut: 1, fork: 1, forkIdempotency: 1, steeringInbox: 1 }, guardrails: GUARDRAIL_CATALOG });
162
162
  return 0;
163
163
  }
164
164
  if (command === 'validate') {
@@ -198,7 +198,7 @@ export class RunLedger {
198
198
  else if (result.verified) {
199
199
  const receipt = result.receipt, verified = result.verified;
200
200
  const evidence = receipt?.evidence && typeof receipt.evidence === 'object' && !Array.isArray(receipt.evidence) ? receipt.evidence : undefined;
201
- if (!['verify', 'implementation'].includes(String(visit.kind)) || !receipt?.valid || receipt.scope !== 'full' || !candidate ||
201
+ if (!['verify', 'implementation', 'implementation-step'].includes(String(visit.kind)) || !receipt?.valid || receipt.scope !== 'full' || !candidate ||
202
202
  !Array.isArray(evidence?.commands) || evidence.commands.length === 0 ||
203
203
  (evidence.unverifiedRepositories !== undefined && (!Array.isArray(evidence.unverifiedRepositories) || evidence.unverifiedRepositories.length !== 0)) ||
204
204
  verified.receiptId !== receipt.id || verified.candidateHash !== candidate.hash || verified.revision !== candidate.revision || verified.atTransition !== frame.transition) {
@@ -460,7 +460,7 @@ export class RunLedger {
460
460
  }
461
461
  sessionOwner(frame, ownerNodePath, key) {
462
462
  const owner = ownerNodePath.replace(/\/$/, '');
463
- if (!key.startsWith('session:') || (owner !== '' && owner !== frame.nodePath && !frame.nodePath.startsWith(owner + '/'))) {
463
+ if ((!key.startsWith('session:') && !key.startsWith('contract:artifacts:') && key !== 'binding:implementation') || (owner !== '' && owner !== frame.nodePath && !frame.nodePath.startsWith(owner + '/'))) {
464
464
  throw new EngineError('piece_state_scope', 'Shared sessions require an ancestor node owner in this execution scope');
465
465
  }
466
466
  return owner;
@@ -68,7 +68,7 @@ export async function runDefinitionCommand(flags, positionals, emit) {
68
68
  if (command === 'workflows') {
69
69
  if (!positionals[1] || positionals[1] === 'list') {
70
70
  emit({ type: 'runtime-workflows', nodeKindsVersion: NODE_KINDS_VERSION, nodeKinds: validationPieceRegistry().catalog(), definitionSchema: workflowDefinitionSchema,
71
- builtins: [{ id: 'specrails-implementation', version: CORE_WORKFLOW_VERSION, deprecated: false }] });
71
+ builtins: [{ id: 'specrails-implementation', version: CORE_WORKFLOW_VERSION, deprecated: true }] });
72
72
  return 0;
73
73
  }
74
74
  if (positionals[1] !== 'validate' || flags.stdin !== true)
@@ -35,19 +35,21 @@ export function compileWorkflowDefinition(definition, registry, execution, optio
35
35
  const outcomes = node.kind === 'component' ? componentBody?.outputs ?? ['next', 'failed'] : registry.outcomes(node.kind, node.params);
36
36
  const branchName = '_core_map_' + id;
37
37
  const mapPredecessor = node.kind === 'join' ? Object.entries(body.nodes).find(([, value]) => value.kind === 'map' && value.ends.next === id)?.[0] : undefined;
38
- const retry = { maxAttempts: node.retry?.maxAttempts ?? (piece.descriptor.requiresAI ? 2 : 1), retrySafe: effect === 'read', initialIntervalMs: node.retry?.backoffMs ?? 5000 };
38
+ const requiresAI = piece.descriptor.requiresAI && !(node.kind === 'implementation-step' && ['verify', 'archive'].includes(String(node.params.phase)));
39
+ const retry = { maxAttempts: node.retry?.maxAttempts ?? (requiresAI ? 2 : 1), retrySafe: effect === 'read', initialIntervalMs: node.retry?.backoffMs ?? 5000 };
39
40
  graph.addNode(id, async (state, config) => {
40
41
  const info = config.executionInfo;
41
42
  if (!info?.threadId)
42
43
  throw new EngineError('execution_identity_missing', 'Durable definitions require public task identity and a checkpoint thread');
43
44
  const nodePath = state.$scope.nodePathPrefix ? state.$scope.nodePathPrefix + '/' + id : id;
44
- const frame = await execution.enter({ nodePath, kind: node.kind, effect, requiresAI: piece.descriptor.requiresAI,
45
- acceptsSteering: piece.descriptor.requiresAI && ['prompt', 'role-turn'].includes(node.kind) && node.params.appendSteering !== false, scope: state.$scope,
45
+ const frame = await execution.enter({ nodePath, kind: node.kind, effect, requiresAI,
46
+ acceptsSteering: requiresAI && (['prompt', 'role-turn'].includes(node.kind) || node.kind === 'implementation-step' && !['verify', 'archive'].includes(String(node.params.phase))) && node.params.appendSteering !== false, scope: state.$scope,
46
47
  task: { checkpointThreadId: info.threadId, checkpointId: info.checkpointId, taskCheckpointNs: info.checkpointNs, taskId: info.taskId }, retry });
47
48
  let result;
49
+ let childEvidence;
48
50
  let mapPlan;
49
51
  try {
50
- const interpolated = interpolateParams(node.params, state.$vars);
52
+ const interpolated = interpolateParams(node.params, state.$vars, state);
51
53
  const params = node.kind === 'end' && root ? { ...interpolated, requiresVerified: interpolated.requiresVerified === true || frozen.delivery?.requiresVerified === true } : interpolated;
52
54
  if (node.kind === 'map') {
53
55
  const items = mapItems(params.over, state, options.collections);
@@ -82,11 +84,10 @@ export function compileWorkflowDefinition(definition, registry, execution, optio
82
84
  const exit = completed.$exit;
83
85
  if (!exit)
84
86
  throw new EngineError('component_exit_missing', `Component ${nodePath} did not produce an exit`);
87
+ childEvidence = { $candidate: completed.$candidate, $verified: completed.$verified };
85
88
  result = { outcome: exit.outcome, status: exit.status, output: { outputs: completed.$outputs, completion: { ...exit.completion } },
86
- candidate: completed.$candidate ?? undefined, verified: completed.$verified, usage: completed.$usage, completion: exit.completion,
89
+ usage: completed.$usage, completion: exit.completion,
87
90
  ...(exit.error ? { error: exit.error } : {}) };
88
- if (!result.candidate)
89
- delete result.candidate;
90
91
  }
91
92
  else
92
93
  result = await execution.execute(frame, effect, signal => piece.execute(params, {
@@ -129,7 +130,7 @@ export function compileWorkflowDefinition(definition, registry, execution, optio
129
130
  result.completion = { ...result.completion, ok: false, reasons: [...new Set([...result.completion.reasons, 'unverified'])] };
130
131
  }
131
132
  const marker = execution.terminal(frame, result);
132
- const patch = resultPatch(id, frame, marker.result, marker);
133
+ const patch = { ...resultPatch(id, frame, marker.result, marker), ...childEvidence };
133
134
  if (mapPlan)
134
135
  patch.$maps = { [id]: mapPlan };
135
136
  if (node.kind === 'join' && mapPredecessor && state.$maps[mapPredecessor])
@@ -192,7 +192,7 @@ export declare const workflowDefinitionSchema: {
192
192
  readonly required: readonly ["kind", "params", "ends"];
193
193
  readonly properties: {
194
194
  readonly kind: {
195
- readonly enum: readonly ["prompt", "role-turn", "decider", "condition", "assign", "verify", "shell", "openspec-validate", "openspec-archive", "approval", "question", "gate", "map", "join", "component", "implementation", "end"];
195
+ readonly enum: readonly ["prompt", "role-turn", "decider", "condition", "assign", "verify", "shell", "openspec-validate", "openspec-archive", "approval", "question", "gate", "map", "join", "component", "implementation", "implementation-step", "artifact-contract", "end"];
196
196
  };
197
197
  readonly params: {
198
198
  readonly type: "object";
@@ -251,6 +251,8 @@ export const workflowDefinitionSchema = {
251
251
  "join",
252
252
  "component",
253
253
  "implementation",
254
+ "implementation-step",
255
+ "artifact-contract",
254
256
  "end"
255
257
  ]
256
258
  },
@@ -1,5 +1,5 @@
1
1
  import type { EngineEffect, JsonObject, WorkflowBudget } from './contracts.js';
2
- export declare const PIECE_KINDS: readonly ["prompt", "role-turn", "decider", "condition", "assign", "verify", "shell", "openspec-validate", "openspec-archive", "approval", "question", "gate", "map", "join", "component", "implementation", "end"];
2
+ export declare const PIECE_KINDS: readonly ["prompt", "role-turn", "decider", "condition", "assign", "verify", "shell", "artifact-contract", "openspec-validate", "openspec-archive", "approval", "question", "gate", "map", "join", "component", "implementation", "implementation-step", "end"];
3
3
  export type PieceKind = typeof PIECE_KINDS[number];
4
4
  export interface DefinitionNode {
5
5
  kind: PieceKind;
@@ -1,3 +1,3 @@
1
1
  export const PIECE_KINDS = ['prompt', 'role-turn', 'decider', 'condition', 'assign', 'verify', 'shell',
2
- 'openspec-validate', 'openspec-archive', 'approval', 'question', 'gate', 'map', 'join',
3
- 'component', 'implementation', 'end'];
2
+ 'artifact-contract', 'openspec-validate', 'openspec-archive', 'approval', 'question', 'gate', 'map', 'join',
3
+ 'component', 'implementation', 'implementation-step', 'end'];
@@ -34,6 +34,7 @@ export function validateWorkflowDefinition(input, registry, roles = {}, options
34
34
  if (!Object.hasOwn(roles, role))
35
35
  add('role_not_found', '/roles', `Role ${role} is not configured`);
36
36
  let implementations = 0;
37
+ let operationCount = 0;
37
38
  let writes = false;
38
39
  const successfulEnds = [];
39
40
  const bodies = definition.components ?? {};
@@ -133,6 +134,9 @@ export function validateWorkflowDefinition(input, registry, roles = {}, options
133
134
  }
134
135
  if (node.kind === 'implementation')
135
136
  implementations += 1;
137
+ if (node.kind === 'implementation-step') {
138
+ operationCount += 1;
139
+ }
136
140
  if (node.kind === 'end' && node.params.outcome === 'success')
137
141
  successfulEnds.push({ path, verified: node.params.requiresVerified === true });
138
142
  if (node.kind === 'end' && node.params.exit !== undefined) {
@@ -173,8 +177,8 @@ export function validateWorkflowDefinition(input, registry, roles = {}, options
173
177
  inspectReferences(definition, [], '');
174
178
  for (const [name, body] of Object.entries(bodies))
175
179
  inspectReferences(body, [name], '/components/' + name);
176
- if ((definition.journal === 'ledger-only' && implementations !== 0) || (definition.journal === 'implementation' && (implementations !== 1 || definition.change === 'none')))
177
- add('journal_mismatch', '/journal', 'Implementation journals require exactly one implementation piece and an existing or new change');
180
+ if ((definition.journal === 'ledger-only' && (implementations !== 0 || operationCount > 0)) || (definition.journal === 'implementation' && ((implementations !== 1 && operationCount === 0) || (implementations > 0 && operationCount > 0) || definition.change === 'none')))
181
+ add('journal_mismatch', '/journal', 'Implementation journals require independent implementation steps or one legacy implementation wrapper, and an existing or new change');
178
182
  if (writes && definition.delivery?.requiresVerified !== false && definition.delivery?.requiresVerified !== true)
179
183
  for (const end of successfulEnds)
180
184
  if (!end.verified)
@@ -7,5 +7,5 @@ export declare function boundedPattern(pattern: string): RegExp;
7
7
  /** Recursive descent over a closed grammar; no eval or executable expression callbacks. */
8
8
  export declare function parseExpression(source: string): Expression;
9
9
  export declare function validateInterpolations(value: JsonValue): void;
10
- export declare function interpolateParams(params: JsonObject, vars: Record<string, JsonValue>): JsonObject;
10
+ export declare function interpolateParams(params: JsonObject, vars: Record<string, JsonValue>, state?: CoreDefinitionState): JsonObject;
11
11
  export {};
@@ -140,21 +140,34 @@ export function parseExpression(source) {
140
140
  export function validateInterpolations(value) {
141
141
  if (typeof value === 'string') {
142
142
  const tokens = value.replaceAll('{{{{', '').match(/\{\{[^}]*\}\}/g) ?? [];
143
- for (const token of tokens)
143
+ for (const token of tokens) {
144
+ if (token.startsWith('{{outputs.') && (!/^\{\{outputs\.[A-Za-z][A-Za-z0-9_.-]*\}\}$/.test(token) || token.slice(10, -2).split('.').some(part => forbiddenKeys.has(part))))
145
+ throw new EngineError('invalid_interpolation', 'Invalid workflow output reference');
144
146
  if (token.startsWith('{{run.') && !/^\{\{run\.[A-Za-z][A-Za-z0-9_-]*\}\}$/.test(token))
145
147
  throw new EngineError('invalid_interpolation', `Invalid runtime variable ${token}`);
148
+ }
146
149
  }
147
150
  else if (Array.isArray(value))
148
151
  value.forEach(validateInterpolations);
149
152
  else if (value && typeof value === 'object')
150
153
  Object.values(value).forEach(validateInterpolations);
151
154
  }
152
- export function interpolateParams(params, vars) {
155
+ export function interpolateParams(params, vars, state) {
153
156
  function resolve(value) {
154
157
  if (typeof value === 'string')
155
- return value.replace(/\{\{\{\{|\{\{run\.([A-Za-z][A-Za-z0-9_-]*)\}\}/g, (token, name) => {
158
+ return value.replace(/\{\{\{\{|\{\{run\.([A-Za-z][A-Za-z0-9_-]*)\}\}|\{\{outputs\.([A-Za-z][A-Za-z0-9_.-]*)\}\}|\{\{history\.text\}\}/g, (token, name, output) => {
156
159
  if (token === '{{{{')
157
160
  return '{{';
161
+ if (token === '{{history.text}}')
162
+ return (state?.$history ?? []).map(entry => `[${entry.nodePath}] ${entry.text}`).join('\n').slice(-32_000);
163
+ if (output) {
164
+ if (output.split('.').some(part => forbiddenKeys.has(part)))
165
+ throw new EngineError('invalid_interpolation', 'Prototype output paths are forbidden');
166
+ const resolved = state ? lookup(state, '$outputs.' + output) : undefined;
167
+ if (resolved === undefined)
168
+ throw new EngineError('output_missing', `Workflow output ${output} is missing`);
169
+ return typeof resolved === 'string' ? resolved : JSON.stringify(resolved);
170
+ }
158
171
  if (!name || !Object.hasOwn(vars, name))
159
172
  throw new EngineError('run_var_missing', `Runtime variable ${name ?? ''} is missing`);
160
173
  const replacement = vars[name];
@@ -1,8 +1,9 @@
1
- import { existsSync, renameSync, rmSync } from 'node:fs';
1
+ import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, renameSync, rmSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { pipelineStateDirectory, validatePipelineContext } from '../../pipeline/pipeline-state.js';
5
5
  import { createExecutorRegistry } from '../executors.js';
6
+ import { artifactPath } from '../openspec.js';
6
7
  import { coreRuntimeIdentity } from '../core-host.js';
7
8
  import { sameRuntimeIdentity } from '../runtime-identity.js';
8
9
  import { canonicalJson, contentDigest } from './canonical-json.js';
@@ -33,6 +34,38 @@ function rebind(source, parent, change) {
33
34
  const context = validatePipelineContext({ ...source.context, runId: standalone ? parent.runId : parent.runId.slice(0, 80) + '-impl-' + digest });
34
35
  return { ...source, context, parentRunId: parent.runId, change: standalone ? change : change.slice(0, 38).replace(/-+$/, '') + '-' + digest, directory: pipelineStateDirectory(context) };
35
36
  }
37
+ function copyActiveChange(context, source, target, allocation) {
38
+ const from = artifactPath(context.artifactRoot, 'openspec/changes/' + source);
39
+ if (!existsSync(from))
40
+ return; // A cut before planning has no authored artifacts yet.
41
+ const to = artifactPath(context.artifactRoot, 'openspec/changes/' + target);
42
+ if (existsSync(to))
43
+ throw new EngineError('run_exists', 'Fork refuses to replace an existing change');
44
+ allocation.reserve(to);
45
+ let bytes = 0, files = 0;
46
+ const copy = (relative) => {
47
+ const directory = relative ? artifactPath(from, relative) : from;
48
+ mkdirSync(relative ? artifactPath(to, relative) : to, { recursive: true, mode: 0o700 });
49
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
50
+ const child = relative ? relative + '/' + entry.name : entry.name;
51
+ if (entry.isSymbolicLink())
52
+ throw new EngineError('artifact_scope_mismatch', 'Fork rejects symlinked change artifacts');
53
+ if (entry.isDirectory())
54
+ copy(child);
55
+ else if (entry.isFile()) {
56
+ const content = readFileSync(artifactPath(from, child));
57
+ bytes += content.length;
58
+ files++;
59
+ if (bytes > 2 * 1024 * 1024 || files > 200)
60
+ throw new EngineError('output_limit', 'Fork artifacts exceed their copy limit');
61
+ writeFileSync(artifactPath(to, child), content, { flag: 'wx', mode: 0o600 });
62
+ }
63
+ else
64
+ throw new EngineError('artifact_scope_mismatch', 'Fork requires ordinary change artifacts');
65
+ }
66
+ };
67
+ copy('');
68
+ }
36
69
  /** A fork publishes an independently leased historical cut; the source is opened read-only. */
37
70
  export async function forkRun(directory, options) {
38
71
  const state = patchState(options.state);
@@ -93,20 +126,27 @@ export async function forkRun(directory, options) {
93
126
  const change = request.change ? request.change.slice(0, 42).replace(/-+$/, '') + '-fork-' + contentDigest(context.runId).slice(0, 12) : undefined;
94
127
  const forkRequest = { ...request, context, ...(change ? { change } : {}) };
95
128
  const sourceBindings = destination.sqlite.prepare("SELECT * FROM piece_state WHERE key='binding:implementation'").all();
129
+ const genericChange = !!change && sourceBindings.length === 0 && definition.journal === 'ledger-only';
130
+ if (genericChange)
131
+ copyActiveChange(context, request.change, change, allocation);
96
132
  const restored = [];
97
133
  for (const row of sourceBindings) {
98
134
  // A completed implementation is inherited evidence. Rebinding its journal would
99
135
  // create a new candidate and repeat verified work even when the workspace is unchanged.
100
136
  const completed = destination.sqlite.prepare('SELECT 1 FROM attempts WHERE visit_id=? AND terminal_digest IS NOT NULL').get(row.visit_id);
101
- if (completed)
137
+ const binding = JSON.parse(String(row.value_json));
138
+ if (completed && !binding.shared)
139
+ continue;
140
+ const childScope = binding.shared ? binding.scopeId : binding.scopeId + '/' + String(row.visit_id);
141
+ const results = destination.sqlite.prepare('SELECT a.output_json FROM attempts a JOIN visits v ON a.visit_id=v.visit_id WHERE a.scope_id=? AND a.output_json IS NOT NULL ORDER BY v.global_transition DESC,a.attempt DESC').all(childScope);
142
+ const snapshot = results.map(result => JSON.parse(String(result.output_json)).childUpdate?.journal).find(value => value !== undefined);
143
+ const lastOperation = results.map(result => JSON.parse(String(result.output_json))).find(result => result.childUpdate?.operationState !== undefined);
144
+ if (binding.shared && lastOperation?.completion?.ok)
102
145
  continue;
103
- const binding = JSON.parse(String(row.value_json)), target = rebind(binding, context, change);
146
+ const target = rebind(binding, context, change);
104
147
  const changeDirectory = path.join(target.context.artifactRoot, 'openspec', 'changes', target.change);
105
148
  if ((target.directory !== targetRoot && existsSync(target.directory)) || existsSync(changeDirectory))
106
149
  throw new EngineError('run_exists', 'Fork refuses to replace an existing implementation journal or change');
107
- const childScope = binding.scopeId + '/' + String(row.visit_id);
108
- const results = destination.sqlite.prepare('SELECT a.output_json FROM attempts a JOIN visits v ON a.visit_id=v.visit_id WHERE a.scope_id=? AND a.output_json IS NOT NULL ORDER BY v.global_transition DESC,a.attempt DESC').all(childScope);
109
- const snapshot = results.map(result => JSON.parse(String(result.output_json)).childUpdate?.journal).find(value => value !== undefined);
110
150
  if (snapshot) {
111
151
  if (target.directory !== targetRoot)
112
152
  allocation.reserve(target.directory);
@@ -120,7 +160,7 @@ export async function forkRun(directory, options) {
120
160
  destination.transaction('fork-rebound', () => {
121
161
  lease.assert(token);
122
162
  destination.put('runs', { ...ledger.run(), request_json: canonicalJson(forkRequest), status: 'running', completion_json: null,
123
- ...(Object.keys(state).length || restored.length ? { verified_json: null } : {}) });
163
+ ...(Object.keys(state).length || restored.length || genericChange ? { verified_json: null } : {}) });
124
164
  for (const binding of restored)
125
165
  destination.put('piece_state', { ...binding.row, value_json: canonicalJson(binding.target) });
126
166
  destination.put('piece_state', { run_id: context.runId, scope_id: 'root', node_path: '', key: 'lineage:fork', value_json: canonicalJson({
@@ -141,7 +181,7 @@ export async function forkRun(directory, options) {
141
181
  heads.set(namespace, tuple);
142
182
  }
143
183
  for (const binding of restored) {
144
- if (!existsSync(path.join(binding.target.directory, 'state.json')))
184
+ if (!existsSync(path.join(binding.target.directory, 'state.json')) || binding.source.shared)
145
185
  continue;
146
186
  const scope = binding.source.scopeId + '/' + String(binding.row.visit_id);
147
187
  const matching = [...heads.values()].filter(tuple => tuple.checkpoint.channel_values.$scope?.id === scope && '$next' in tuple.checkpoint.channel_values);
@@ -154,11 +194,13 @@ export async function forkRun(directory, options) {
154
194
  throw new EngineError('fork_checkpoint_missing', 'Implementation checkpoint has no unambiguous public previous node');
155
195
  await runtime.graph.updateState(tuple.config, projection.update, writer);
156
196
  }
157
- if (Object.keys(state).length) {
197
+ if (Object.keys(state).length || genericChange) {
158
198
  const tuple = await runtime.saver.getTuple(targetConfig);
159
199
  if (!tuple)
160
200
  throw new EngineError('fork_checkpoint_missing', 'Target checkpoint is not present in the historical cut');
161
201
  const values = tuple.checkpoint.channel_values;
202
+ if (genericChange)
203
+ state.$vars = { ...(values.$outer?.$vars ?? values.$vars), ...state.$vars, changeId: change };
162
204
  const outer = values.$outer;
163
205
  const update = outer ? { $outer: { ...outer,
164
206
  ...(state.$vars ? { $vars: { ...outer.$vars, ...state.$vars } } : {}),
@@ -8,7 +8,7 @@ export declare function describeDefinition(runId: string, definition: WorkflowDe
8
8
  workflowId: string;
9
9
  nodes: {
10
10
  path: string;
11
- kind: "join" | "map" | "verify" | "shell" | "end" | "approval" | "question" | "prompt" | "gate" | "implementation" | "role-turn" | "decider" | "condition" | "assign" | "openspec-validate" | "openspec-archive" | "component";
11
+ kind: "join" | "map" | "verify" | "shell" | "end" | "approval" | "question" | "prompt" | "gate" | "implementation" | "role-turn" | "decider" | "condition" | "assign" | "openspec-validate" | "openspec-archive" | "component" | "implementation-step" | "artifact-contract";
12
12
  label: string;
13
13
  }[];
14
14
  edges: {
@@ -24,7 +24,7 @@ export declare function describeDefinition(runId: string, definition: WorkflowDe
24
24
  nodes: {
25
25
  component?: string | undefined;
26
26
  id: string;
27
- kind: "join" | "map" | "verify" | "shell" | "end" | "approval" | "question" | "prompt" | "gate" | "implementation" | "role-turn" | "decider" | "condition" | "assign" | "openspec-validate" | "openspec-archive" | "component";
27
+ kind: "join" | "map" | "verify" | "shell" | "end" | "approval" | "question" | "prompt" | "gate" | "implementation" | "role-turn" | "decider" | "condition" | "assign" | "openspec-validate" | "openspec-archive" | "component" | "implementation-step" | "artifact-contract";
28
28
  label: string;
29
29
  ends: {
30
30
  [x: string]: string | null;
@@ -36,7 +36,7 @@ export declare function describeDefinition(runId: string, definition: WorkflowDe
36
36
  nodes: {
37
37
  component?: string | undefined;
38
38
  id: string;
39
- kind: "join" | "map" | "verify" | "shell" | "end" | "approval" | "question" | "prompt" | "gate" | "implementation" | "role-turn" | "decider" | "condition" | "assign" | "openspec-validate" | "openspec-archive" | "component";
39
+ kind: "join" | "map" | "verify" | "shell" | "end" | "approval" | "question" | "prompt" | "gate" | "implementation" | "role-turn" | "decider" | "condition" | "assign" | "openspec-validate" | "openspec-archive" | "component" | "implementation-step" | "artifact-contract";
40
40
  label: string;
41
41
  ends: {
42
42
  [x: string]: string | null;
@@ -18,6 +18,8 @@ export function definitionNodeAt(definition, nodePath) {
18
18
  }
19
19
  export function roleAt(definition, nodePath) {
20
20
  const node = definitionNodeAt(definition, nodePath);
21
+ if (node?.kind === 'implementation-step')
22
+ return String(node.params.phase);
21
23
  if (node?.kind === 'role-turn' || node?.kind === 'decider')
22
24
  return String(node.params.roleId);
23
25
  return node?.kind === 'prompt' ? 'prompt' : nodePath.split('/').at(-1);
@@ -18,13 +18,13 @@ export declare class RunPieceDependencies implements PieceDependencies {
18
18
  private readonly definition;
19
19
  private readonly ledger;
20
20
  private readonly directory;
21
- private readonly change?;
21
+ readonly change?: string | undefined;
22
22
  private readonly projectMemory?;
23
23
  readonly policies: PieceDependencies['policies'];
24
24
  constructor(context: PipelineContext, config: RuntimeConfig, registry: ExecutorRegistry, definition: WorkflowDefinition, ledger: RunLedger, directory: string, change?: string | undefined, projectMemory?: SqliteProjectStore | undefined);
25
25
  memory(context: PieceExecutionContext): ProjectMemory;
26
26
  private implementationBindings;
27
- bindImplementation(context: PieceExecutionContext, change: string): ImplementationBinding;
27
+ bindImplementation(context: PieceExecutionContext, change: string, shared?: boolean): ImplementationBinding;
28
28
  private candidateScope;
29
29
  private inheritedCandidateScope;
30
30
  private candidate;
@@ -38,8 +38,12 @@ export declare class RunPieceDependencies implements PieceDependencies {
38
38
  candidate: CandidateState;
39
39
  verified: import("./contracts.js").VerifiedState | null;
40
40
  };
41
+ /** Write admission revokes certification; the archive may check its immutable pre-write checkpoint against a real receipt. */
42
+ verifiedBeforeWrite(context: PieceExecutionContext): import("./contracts.js").VerifiedState | null;
41
43
  /** Only the execution owner proposes protected candidate/receipt channels. */
42
44
  finalize(frame: AttemptFrame, result: PieceResult, effect: EngineEffect): PieceResult;
45
+ operationState(context: PieceExecutionContext): JsonValue | undefined;
46
+ artifactContracts(context: PieceExecutionContext): PieceStatePort;
43
47
  memo(context: PieceExecutionContext): PieceStatePort;
44
48
  roleState(context: PieceExecutionContext): RoleStatePort;
45
49
  stepContext(context: PieceExecutionContext): import("../workflow-types.js").WorkflowStepContext;
@@ -42,13 +42,20 @@ export class RunPieceDependencies {
42
42
  .map(row => JSON.parse(String(row.value_json)))
43
43
  .filter(binding => binding.parentRunId === this.context.runId);
44
44
  }
45
- bindImplementation(context, change) {
46
- const binding = deriveImplementationBinding(this.context, context, change);
47
- const previous = this.ledger.readPieceState(context.frame, 'binding:implementation');
45
+ bindImplementation(context, change, shared = false) {
46
+ const scoped = shared ? { ...context, frame: { ...context.frame, nodePath: context.frame.scope.nodePathPrefix } } : context;
47
+ const binding = deriveImplementationBinding(this.context, scoped, change);
48
+ if (shared)
49
+ binding.shared = true;
50
+ const previous = shared ? this.ledger.readScopedPieceState(context.frame, binding.nodePath, 'binding:implementation') : this.ledger.readPieceState(context.frame, 'binding:implementation');
48
51
  if (previous !== undefined && contentDigest(previous) !== contentDigest(binding))
49
52
  throw new EngineError('scope_mismatch', 'Implementation binding changed within a frozen run');
50
- if (previous === undefined)
51
- this.ledger.writePieceState(context.frame, 'binding:implementation', json(binding));
53
+ if (previous === undefined) {
54
+ if (shared)
55
+ this.ledger.writeScopedPieceState(context.frame, binding.nodePath, 'binding:implementation', json(binding));
56
+ else
57
+ this.ledger.writePieceState(context.frame, 'binding:implementation', json(binding));
58
+ }
52
59
  return binding;
53
60
  }
54
61
  candidateScope() {
@@ -58,6 +65,8 @@ export class RunPieceDependencies {
58
65
  ? readCandidateScope(this.context)
59
66
  : inherited ? this.inheritedCandidateScope(inherited)
60
67
  : { context: this.context, scopeHash: contentDigest(this.context), artifactExclusions: this.change ? ['openspec/changes/' + this.change] : [] };
68
+ if (inherited && this.change)
69
+ base.artifactExclusions = [...new Set([...base.artifactExclusions, 'openspec/changes/' + this.change])];
61
70
  const bindings = this.implementationBindings(), repositoryExclusions = Object.fromEntries(Object.entries(base.repositoryExclusions ?? {}).map(([id, paths]) => [id, [...paths]]));
62
71
  for (const binding of bindings) {
63
72
  const exclusions = existsSync(path.join(binding.directory, 'state.json')) ? readCandidateScope(binding.context).artifactExclusions : ['openspec/changes/' + binding.change];
@@ -103,6 +112,17 @@ export class RunPieceDependencies {
103
112
  const candidate = snapshot.candidate?.hash === hash ? snapshot.candidate : this.candidate(context.frame.transition, Number(this.ledger.run().candidate_revision) + 1);
104
113
  return { candidate, verified: snapshot.verified?.candidateHash === hash ? snapshot.verified : null };
105
114
  }
115
+ /** Write admission revokes certification; the archive may check its immutable pre-write checkpoint against a real receipt. */
116
+ verifiedBeforeWrite(context) {
117
+ const verified = context.state.$verified;
118
+ if (!verified || verified.candidateHash !== this.fingerprint())
119
+ return null;
120
+ const receipt = this.ledger.db.get('receipts', { receipt_id: verified.receiptId });
121
+ if (!receipt || receipt.run_id !== this.ledger.runId || receipt.valid !== 1 || receipt.candidate_hash !== verified.candidateHash)
122
+ return null;
123
+ const evidence = JSON.parse(String(receipt.receipt_json));
124
+ return evidence.scope === 'full' ? verified : null;
125
+ }
106
126
  /** Only the execution owner proposes protected candidate/receipt channels. */
107
127
  finalize(frame, result, effect) {
108
128
  const hash = this.fingerprint(), snapshot = this.ledger.scopeSnapshot(frame.scope.id);
@@ -116,7 +136,7 @@ export class RunPieceDependencies {
116
136
  const node = definitionNodeAt(this.definition, frame.nodePath);
117
137
  const kind = node?.kind ?? this.ledger.db.get('visits', { visit_id: frame.visitId })?.kind;
118
138
  const evidence = receipt?.evidence;
119
- const certifies = result.verified !== null && ['verify', 'implementation'].includes(String(kind)) && receipt?.valid && receipt.scope === 'full' && !!evidence?.commands?.length && !evidence.unverifiedRepositories?.length;
139
+ const certifies = result.verified !== null && ['verify', 'implementation', 'implementation-step'].includes(String(kind)) && receipt?.valid && receipt.scope === 'full' && !!evidence?.commands?.length && !evidence.unverifiedRepositories?.length;
120
140
  if (receipt?.valid && receipt.candidateHash !== hash)
121
141
  return { ...result, outcome: 'fail',
122
142
  ...(effect === 'write' ? { candidate } : {}), verified: null,
@@ -127,6 +147,16 @@ export class RunPieceDependencies {
127
147
  : effect === 'write' || snapshot.verified?.candidateHash !== hash ? { verified: null } : {}),
128
148
  };
129
149
  }
150
+ operationState(context) {
151
+ return [...this.ledger.scopeSnapshot(context.frame.scope.id).attempts].reverse()
152
+ .find(attempt => attempt.result?.childUpdate?.operationState !== undefined)?.result?.childUpdate;
153
+ }
154
+ artifactContracts(context) {
155
+ const owner = context.frame.scope.nodePathPrefix;
156
+ const keyOf = (key) => 'contract:artifacts:' + key;
157
+ return { get: key => this.ledger.readScopedPieceState(context.frame, owner, keyOf(key)),
158
+ set: (key, value) => this.ledger.writeScopedPieceState(context.frame, owner, keyOf(key), value) };
159
+ }
130
160
  memo(context) {
131
161
  const keyOf = (key) => 'memo:' + context.frame.visitId + ':' + key;
132
162
  return { get: key => this.ledger.readPieceState(context.frame, keyOf(key)), set: (key, value) => this.ledger.writePieceState(context.frame, keyOf(key), value) };
@@ -144,7 +174,24 @@ export class RunPieceDependencies {
144
174
  write: value => this.ledger.writeScopedPieceState(context.frame, context.frame.scope.nodePathPrefix, 'session:roles', json(value)),
145
175
  };
146
176
  }
147
- stepContext(context) { return invocationStepContext(this.ledger, this.definition, context); }
177
+ stepContext(context) {
178
+ const step = invocationStepContext(this.ledger, this.definition, context);
179
+ if (definitionNodeAt(this.definition, context.frame.nodePath)?.kind !== 'implementation-step')
180
+ return step;
181
+ const phase = (path) => {
182
+ const node = definitionNodeAt(this.definition, path);
183
+ return node?.kind === 'implementation-step' ? String(node.params.phase) : path;
184
+ };
185
+ const checkpoint = step.checkpoint;
186
+ return { ...step, checkpoint: { ...checkpoint,
187
+ nextStep: checkpoint.nextStep ? phase(checkpoint.nextStep) : null,
188
+ steps: Object.fromEntries(Object.entries(checkpoint.steps).map(([key, value]) => [phase(key), { ...value, id: phase(key) }])),
189
+ history: checkpoint.history.map(value => ({ ...value, stepId: phase(value.stepId) })),
190
+ events: checkpoint.events.map(value => ({ ...value, ...(value.stepId ? { stepId: phase(value.stepId) } : {}) })),
191
+ ...(checkpoint.pendingApproval ? { pendingApproval: { ...checkpoint.pendingApproval, stepId: phase(checkpoint.pendingApproval.stepId) } } : {}),
192
+ ...(checkpoint.pendingQuestion ? { pendingQuestion: { ...checkpoint.pendingQuestion, stepId: phase(checkpoint.pendingQuestion.stepId) } } : {}),
193
+ } };
194
+ }
148
195
  settleResult(context, key, value, invocation) {
149
196
  if (!invocation.invocationId)
150
197
  throw new EngineError('invocation_mismatch', 'A response memo requires its durable invocation ID');
@@ -4,7 +4,7 @@ import type { DefinitionIssue, RoleCatalog } from './definition-types.js';
4
4
  * Version of the published piece catalog and observable piece behavior. Bump
5
5
  * when either changes; retained runtime packages preserve earlier semantics.
6
6
  */
7
- export declare const NODE_KINDS_VERSION = 5;
7
+ export declare const NODE_KINDS_VERSION = 7;
8
8
  /** Immutable bindings supplied by Core's composition root; user definitions cannot register code. */
9
9
  export declare class PieceRegistry {
10
10
  private readonly entries;
@@ -11,7 +11,7 @@ function freezeData(value) {
11
11
  * Version of the published piece catalog and observable piece behavior. Bump
12
12
  * when either changes; retained runtime packages preserve earlier semantics.
13
13
  */
14
- export const NODE_KINDS_VERSION = 5;
14
+ export const NODE_KINDS_VERSION = 7;
15
15
  /** Immutable bindings supplied by Core's composition root; user definitions cannot register code. */
16
16
  export class PieceRegistry {
17
17
  entries = new Map();
@@ -0,0 +1,4 @@
1
+ import { type Piece } from '../contracts.js';
2
+ import type { PieceDependencyProvider } from './ports.js';
3
+ /** A reusable artifact invariant, with no agent roles, phase order or successor policy. */
4
+ export declare function artifactContractPiece(bindings: PieceDependencyProvider): Piece;