specrails-core 6.0.1 → 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 (164) hide show
  1. package/README.md +1 -1
  2. package/dist/agent-runtime/capabilities.js +3 -1
  3. package/dist/agent-runtime/cli-executor.d.ts +3 -0
  4. package/dist/agent-runtime/cli-executor.js +50 -13
  5. package/dist/agent-runtime/cli-process.js +6 -2
  6. package/dist/agent-runtime/cli.d.ts +15 -0
  7. package/dist/agent-runtime/cli.js +43 -9
  8. package/dist/agent-runtime/compact/chat-client.js +2 -1
  9. package/dist/agent-runtime/config.d.ts +4 -1
  10. package/dist/agent-runtime/config.js +41 -3
  11. package/dist/agent-runtime/core-host.d.ts +9 -3
  12. package/dist/agent-runtime/core-host.js +21 -18
  13. package/dist/agent-runtime/efficiency-summary.d.ts +4 -1
  14. package/dist/agent-runtime/efficiency-summary.js +30 -27
  15. package/dist/agent-runtime/efficiency-types.d.ts +4 -4
  16. package/dist/agent-runtime/efficiency.d.ts +1 -1
  17. package/dist/agent-runtime/efficiency.js +7 -6
  18. package/dist/agent-runtime/engine/__fixtures__/implementation-component.json +90 -0
  19. package/dist/agent-runtime/engine/__fixtures__/implementation-fixture.d.ts +11 -0
  20. package/dist/agent-runtime/engine/__fixtures__/implementation-fixture.js +60 -0
  21. package/dist/agent-runtime/engine/__fixtures__/implementation.json +43 -0
  22. package/dist/agent-runtime/engine/budget.d.ts +9 -0
  23. package/dist/agent-runtime/engine/budget.js +33 -0
  24. package/dist/agent-runtime/engine/canonical-json.d.ts +9 -0
  25. package/dist/agent-runtime/engine/canonical-json.js +162 -0
  26. package/dist/agent-runtime/engine/checkpoint/database.d.ts +67 -0
  27. package/dist/agent-runtime/engine/checkpoint/database.js +304 -0
  28. package/dist/agent-runtime/engine/checkpoint/lease.d.ts +17 -0
  29. package/dist/agent-runtime/engine/checkpoint/lease.js +82 -0
  30. package/dist/agent-runtime/engine/checkpoint/ledger.d.ts +133 -0
  31. package/dist/agent-runtime/engine/checkpoint/ledger.js +510 -0
  32. package/dist/agent-runtime/engine/checkpoint/saver.d.ts +25 -0
  33. package/dist/agent-runtime/engine/checkpoint/saver.js +146 -0
  34. package/dist/agent-runtime/engine/cli.d.ts +10 -0
  35. package/dist/agent-runtime/engine/cli.js +172 -0
  36. package/dist/agent-runtime/engine/compiler.d.ts +23 -0
  37. package/dist/agent-runtime/engine/compiler.js +261 -0
  38. package/dist/agent-runtime/engine/composition.d.ts +16 -0
  39. package/dist/agent-runtime/engine/composition.js +28 -0
  40. package/dist/agent-runtime/engine/concurrency.d.ts +14 -0
  41. package/dist/agent-runtime/engine/concurrency.js +55 -0
  42. package/dist/agent-runtime/engine/contracts.d.ts +273 -0
  43. package/dist/agent-runtime/engine/contracts.js +11 -0
  44. package/dist/agent-runtime/engine/definition-schema.d.ts +250 -0
  45. package/dist/agent-runtime/engine/definition-schema.js +313 -0
  46. package/dist/agent-runtime/engine/definition-types.d.ts +84 -0
  47. package/dist/agent-runtime/engine/definition-types.js +3 -0
  48. package/dist/agent-runtime/engine/definition-validator.d.ts +7 -0
  49. package/dist/agent-runtime/engine/definition-validator.js +187 -0
  50. package/dist/agent-runtime/engine/efficiency-summary.d.ts +62 -0
  51. package/dist/agent-runtime/engine/efficiency-summary.js +31 -0
  52. package/dist/agent-runtime/engine/events.d.ts +19 -0
  53. package/dist/agent-runtime/engine/events.js +88 -0
  54. package/dist/agent-runtime/engine/execution.d.ts +29 -0
  55. package/dist/agent-runtime/engine/execution.js +121 -0
  56. package/dist/agent-runtime/engine/expressions.d.ts +11 -0
  57. package/dist/agent-runtime/engine/expressions.js +172 -0
  58. package/dist/agent-runtime/engine/fork.d.ts +28 -0
  59. package/dist/agent-runtime/engine/fork.js +211 -0
  60. package/dist/agent-runtime/engine/graph-description.d.ts +48 -0
  61. package/dist/agent-runtime/engine/graph-description.js +16 -0
  62. package/dist/agent-runtime/engine/implementation-compiler.d.ts +214 -0
  63. package/dist/agent-runtime/engine/implementation-compiler.js +91 -0
  64. package/dist/agent-runtime/engine/index.d.ts +14 -0
  65. package/dist/agent-runtime/engine/index.js +14 -0
  66. package/dist/agent-runtime/engine/invocation-context.d.ts +11 -0
  67. package/dist/agent-runtime/engine/invocation-context.js +128 -0
  68. package/dist/agent-runtime/engine/otel.d.ts +15 -0
  69. package/dist/agent-runtime/engine/otel.js +132 -0
  70. package/dist/agent-runtime/engine/output.d.ts +4 -0
  71. package/dist/agent-runtime/engine/output.js +73 -0
  72. package/dist/agent-runtime/engine/piece-dependencies.d.ts +50 -0
  73. package/dist/agent-runtime/engine/piece-dependencies.js +193 -0
  74. package/dist/agent-runtime/engine/piece-registry.d.ts +19 -0
  75. package/dist/agent-runtime/engine/piece-registry.js +62 -0
  76. package/dist/agent-runtime/engine/pieces/assign.d.ts +4 -0
  77. package/dist/agent-runtime/engine/pieces/assign.js +35 -0
  78. package/dist/agent-runtime/engine/pieces/control.d.ts +5 -0
  79. package/dist/agent-runtime/engine/pieces/control.js +49 -0
  80. package/dist/agent-runtime/engine/pieces/decider.d.ts +5 -0
  81. package/dist/agent-runtime/engine/pieces/decider.js +70 -0
  82. package/dist/agent-runtime/engine/pieces/implementation-binding.d.ts +12 -0
  83. package/dist/agent-runtime/engine/pieces/implementation-binding.js +40 -0
  84. package/dist/agent-runtime/engine/pieces/implementation-journal.d.ts +25 -0
  85. package/dist/agent-runtime/engine/pieces/implementation-journal.js +192 -0
  86. package/dist/agent-runtime/engine/pieces/implementation.d.ts +23 -0
  87. package/dist/agent-runtime/engine/pieces/implementation.js +176 -0
  88. package/dist/agent-runtime/engine/pieces/index.d.ts +7 -0
  89. package/dist/agent-runtime/engine/pieces/index.js +22 -0
  90. package/dist/agent-runtime/engine/pieces/openspec.d.ts +3 -0
  91. package/dist/agent-runtime/engine/pieces/openspec.js +69 -0
  92. package/dist/agent-runtime/engine/pieces/ports.d.ts +48 -0
  93. package/dist/agent-runtime/engine/pieces/ports.js +1 -0
  94. package/dist/agent-runtime/engine/pieces/project-memory.d.ts +3 -0
  95. package/dist/agent-runtime/engine/pieces/project-memory.js +13 -0
  96. package/dist/agent-runtime/engine/pieces/prompt.d.ts +5 -0
  97. package/dist/agent-runtime/engine/pieces/prompt.js +130 -0
  98. package/dist/agent-runtime/engine/pieces/role-turn.d.ts +7 -0
  99. package/dist/agent-runtime/engine/pieces/role-turn.js +73 -0
  100. package/dist/agent-runtime/engine/pieces/shared.d.ts +67 -0
  101. package/dist/agent-runtime/engine/pieces/shared.js +40 -0
  102. package/dist/agent-runtime/engine/pieces/shell.d.ts +6 -0
  103. package/dist/agent-runtime/engine/pieces/shell.js +47 -0
  104. package/dist/agent-runtime/engine/pieces/verify.d.ts +7 -0
  105. package/dist/agent-runtime/engine/pieces/verify.js +52 -0
  106. package/dist/agent-runtime/engine/preflight.d.ts +27 -0
  107. package/dist/agent-runtime/engine/preflight.js +54 -0
  108. package/dist/agent-runtime/engine/regex.d.ts +3 -0
  109. package/dist/agent-runtime/engine/regex.js +19 -0
  110. package/dist/agent-runtime/engine/run-status.d.ts +563 -0
  111. package/dist/agent-runtime/engine/run-status.js +61 -0
  112. package/dist/agent-runtime/engine/runs.d.ts +605 -0
  113. package/dist/agent-runtime/engine/runs.js +247 -0
  114. package/dist/agent-runtime/engine/state.d.ts +64 -0
  115. package/dist/agent-runtime/engine/state.js +98 -0
  116. package/dist/agent-runtime/engine/steering/inbox.d.ts +54 -0
  117. package/dist/agent-runtime/engine/steering/inbox.js +113 -0
  118. package/dist/agent-runtime/engine/storage/fork-allocation.d.ts +21 -0
  119. package/dist/agent-runtime/engine/storage/fork-allocation.js +143 -0
  120. package/dist/agent-runtime/engine/storage/private-path.d.ts +11 -0
  121. package/dist/agent-runtime/engine/storage/private-path.js +74 -0
  122. package/dist/agent-runtime/engine/store/sqlite-store.d.ts +26 -0
  123. package/dist/agent-runtime/engine/store/sqlite-store.js +227 -0
  124. package/dist/agent-runtime/evaluation-corpus.d.ts +125 -0
  125. package/dist/agent-runtime/evaluation-corpus.js +4 -0
  126. package/dist/agent-runtime/evaluation.d.ts +9 -3
  127. package/dist/agent-runtime/evaluation.js +50 -8
  128. package/dist/agent-runtime/executor-types.d.ts +53 -3
  129. package/dist/agent-runtime/executor-types.js +35 -2
  130. package/dist/agent-runtime/executors.js +2 -2
  131. package/dist/agent-runtime/graph/artifacts.d.ts +7 -0
  132. package/dist/agent-runtime/graph/artifacts.js +82 -9
  133. package/dist/agent-runtime/graph/nodes.d.ts +2 -0
  134. package/dist/agent-runtime/graph/nodes.js +2 -2
  135. package/dist/agent-runtime/graph/roles.d.ts +6 -0
  136. package/dist/agent-runtime/graph/roles.js +23 -17
  137. package/dist/agent-runtime/kimi-acp.js +6 -6
  138. package/dist/agent-runtime/openai-executor.js +17 -12
  139. package/dist/agent-runtime/openspec.d.ts +17 -2
  140. package/dist/agent-runtime/openspec.js +54 -20
  141. package/dist/agent-runtime/prompts.d.ts +4 -3
  142. package/dist/agent-runtime/prompts.js +26 -5
  143. package/dist/agent-runtime/recovery.d.ts +1 -1
  144. package/dist/agent-runtime/repository-context.js +1 -1
  145. package/dist/agent-runtime/role-routing.js +4 -2
  146. package/dist/agent-runtime/role-state.d.ts +5 -0
  147. package/dist/agent-runtime/verification-plan.d.ts +2 -0
  148. package/dist/agent-runtime/verification-plan.js +10 -0
  149. package/dist/agent-runtime/workflow-types.d.ts +2 -0
  150. package/dist/agent-runtime/workflow.d.ts +3 -1
  151. package/dist/agent-runtime/workflow.js +7 -3
  152. package/dist/agent-runtime/workspace-tools.d.ts +2 -2
  153. package/dist/agent-runtime/workspace-tools.js +6 -6
  154. package/dist/installer/cli.js +5 -0
  155. package/dist/installer/phases/scaffold.d.ts +2 -2
  156. package/dist/installer/phases/scaffold.js +3 -7
  157. package/dist/pipeline/pipeline-state.d.ts +38 -4
  158. package/dist/pipeline/pipeline-state.js +94 -21
  159. package/integration-contract.json +83 -11
  160. package/package.json +12 -5
  161. package/schemas/agent-runtime.schema.json +93 -1
  162. package/schemas/workflow-definition.schema.json +312 -0
  163. package/templates/kimi/specrails/run-skill.mjs +15 -1
  164. package/templates/commands/specrails/batch-implement.md +0 -32
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  The agent workflow engine behind [Specrails Desktop](https://github.com/fjpulidop/specrails-desktop). Core does two things:
8
8
 
9
- 1. **Installs** the specrails workflow into a project for Claude Code, Codex CLI, Gemini CLI or Kimi Code: three roles (architect, developer, reviewer), the `implement` / `batch-implement` / `retry` entry points and the official OpenSpec skills.
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
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.
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.
@@ -1,3 +1,4 @@
1
+ import { resolveRoleDescriptor, roleIds } from './config.js';
1
2
  import { readFileSync, statSync } from 'node:fs';
2
3
  import { homedir, tmpdir } from 'node:os';
3
4
  import path from 'node:path';
@@ -71,7 +72,8 @@ export async function configuredCapabilities(config, registry) {
71
72
  return probes.get(key);
72
73
  };
73
74
  const roles = [];
74
- for (const [role, selected] of Object.entries(config.agents)) {
75
+ for (const role of roleIds(config)) {
76
+ const selected = resolveRoleDescriptor(config, role);
75
77
  for (const [tier, choice] of [['base', selected], ...(selected.escalation ? [['escalation', selected.escalation]] : [])]) {
76
78
  roles.push({ role, tier, provider: selected.provider, model: choice.model ?? null, requestedEffort: choice.effort ?? null, ...await probe(selected.provider, choice.model) });
77
79
  }
@@ -21,6 +21,9 @@ interface CliInvocationOptions {
21
21
  * developer that cannot run tests iterates blind and fails verification.
22
22
  */
23
23
  export declare function buildCliInvocation(provider: CliProvider, request: AgentRequest, options?: CliInvocationOptions): CliInvocation;
24
+ /** Native commands are data rendered for the selected provider; never shell text. */
25
+ export declare function renderNativeCommand(provider: Exclude<CliProvider, 'kimi'>, command: NonNullable<AgentRequest['nativeCommand']>): string;
26
+ export declare function kimiNativeSkill(id: string): string;
24
27
  interface ParsedCliResult extends AgentResult {
25
28
  terminal: boolean;
26
29
  failed: boolean;
@@ -2,13 +2,13 @@ import { codexOutputSchema, restoreOptionalFields } from './codex-schema.js';
2
2
  import { assertEffortSupported, cliCapabilities } from './capabilities.js';
3
3
  import { providerDiagnostic } from './provider-diagnostic.js';
4
4
  import { toolEvent } from './tool-event.js';
5
- import { openSpecPrompt, writeOpenSpecBridge } from './openspec.js';
5
+ import { openSpecPrompt, openSpecPolicy, writeOpenSpecBridge } from './openspec.js';
6
6
  import { existsSync, readFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
8
8
  import path from 'node:path';
9
9
  import { sumCacheUsage } from './efficiency-types.js';
10
10
  import { normalizeKimiCliModel } from './kimi-model.js';
11
- import { AgentExecutionError, unknownUsage, validateAgentRequest } from './executor-types.js';
11
+ import { AgentExecutionError, unknownUsage, normalizeAgentRequest, requestPolicy, isBuiltinRole } from './executor-types.js';
12
12
  import { runCliProcess } from './cli-process.js';
13
13
  import { canonicalWorkspace } from './workspace-tools.js';
14
14
  import { parseStructuredText } from './openai-executor.js';
@@ -23,22 +23,29 @@ const CLAUDE_DEVELOPER_DISALLOWED = 'Agent,Task,Skill';
23
23
  * developer that cannot run tests iterates blind and fails verification.
24
24
  */
25
25
  export function buildCliInvocation(provider, request, options = {}) {
26
- const readOnly = request.role !== 'developer';
26
+ if (!isBuiltinRole(request.role) || request.access !== undefined || request.nativeCommand)
27
+ request = normalizeAgentRequest(request);
28
+ if (request.nativeCommand) {
29
+ if (provider === 'kimi')
30
+ throw new AgentExecutionError('Kimi commands require skill rendering before invocation', 'native_command_unsupported');
31
+ request = { ...request, prompt: renderNativeCommand(provider, request.nativeCommand) };
32
+ }
33
+ const readOnly = requestPolicy(request).access === 'read';
27
34
  // Pin the product alias; explicit IDs remain reproducible across releases.
28
35
  const modelId = provider === 'claude' && request.model === 'opus' ? 'claude-opus-5-5' : request.model;
29
36
  const model = modelId ? ['--model', modelId] : [];
30
37
  const extraRoots = request.allowedRoots.filter(root => root !== request.cwd);
31
38
  const resume = request.resumeSessionId;
32
39
  switch (provider) {
33
- case 'claude': return { command: 'claude', stdin: request.prompt, args: [
34
- '-p', '--output-format', 'stream-json', '--verbose', '--max-turns', String(request.maxTurns ?? 100), ...model,
40
+ case 'claude': return { command: 'claude', ...(request.nativeCommand ? {} : { stdin: request.prompt }), args: [
41
+ '-p', ...(request.nativeCommand ? [request.prompt] : []), '--output-format', 'stream-json', '--verbose', '--max-turns', String(request.maxTurns ?? 100), ...model,
35
42
  ...(request.effort === undefined ? [] : ['--effort', request.effort]),
36
43
  // Project instructions and rules stay visible; the user's global config,
37
44
  // memory and plugins never leak into an autonomous role.
38
45
  '--setting-sources', 'project,local',
39
46
  ...(readOnly
40
- ? ['--tools', options.mcpConfigFile ? 'Read,Grep,Glob,ToolSearch' : 'Read,Grep,Glob', '--permission-mode', options.mcpConfigFile ? 'dontAsk' : 'plan', '--strict-mcp-config', ...(options.mcpConfigFile ? ['--allowedTools', 'Read,Grep,Glob,ToolSearch,mcp__specrails_openspec__workflow,mcp__specrails_openspec__read_verification_evidence'] : [])]
41
- : ['--tools', 'default', '--disallowedTools', CLAUDE_DEVELOPER_DISALLOWED, '--dangerously-skip-permissions']),
47
+ ? ['--tools', request.nativeCommand ? 'Read,Grep,Glob,Skill' : options.mcpConfigFile ? 'Read,Grep,Glob,ToolSearch' : 'Read,Grep,Glob', '--permission-mode', options.mcpConfigFile ? 'dontAsk' : 'plan', '--strict-mcp-config', ...(options.mcpConfigFile ? ['--allowedTools', 'Read,Grep,Glob,ToolSearch,mcp__specrails_openspec__workflow,mcp__specrails_openspec__read_verification_evidence'] : [])]
48
+ : ['--tools', 'default', '--disallowedTools', request.nativeCommand ? 'Agent,Task' : CLAUDE_DEVELOPER_DISALLOWED, '--dangerously-skip-permissions']),
42
49
  ...(options.mcpConfigFile ? ['--mcp-config', options.mcpConfigFile] : []),
43
50
  ...(request.outputSchema ? ['--json-schema', JSON.stringify(request.outputSchema)] : []),
44
51
  ...(request.maxCostUsd === undefined ? [] : ['--max-budget-usd', String(request.maxCostUsd)]),
@@ -63,8 +70,8 @@ export function buildCliInvocation(provider, request, options = {}) {
63
70
  case 'gemini': {
64
71
  if (readOnly && !options.geminiPolicyFile)
65
72
  throw new AgentExecutionError('Gemini architect/reviewer roles require --admin-policy support for an enforced read-only tool allowlist. Upgrade Gemini CLI or select another provider for this role.', 'provider_capability_unsupported');
66
- return { command: 'gemini', stdin: request.prompt, args: [
67
- '-p', 'Execute the task supplied on stdin.', '--output-format', 'stream-json', ...model,
73
+ return { command: 'gemini', ...(request.nativeCommand ? {} : { stdin: request.prompt }), args: [
74
+ '-p', request.nativeCommand ? request.prompt : 'Execute the task supplied on stdin.', '--output-format', 'stream-json', ...model,
68
75
  // Headless Gemini cannot answer shell approvals; the developer runs with
69
76
  // auto-approval exactly as the legacy Implement step did.
70
77
  ...(readOnly ? ['--approval-mode', 'plan'] : ['--yolo']),
@@ -86,6 +93,34 @@ export function buildCliInvocation(provider, request, options = {}) {
86
93
  }
87
94
  }
88
95
  }
96
+ /** Native commands are data rendered for the selected provider; never shell text. */
97
+ export function renderNativeCommand(provider, command) {
98
+ return (provider === 'codex' ? '$' : '/') + command.id + (command.args ? ' ' + command.args : '');
99
+ }
100
+ export function kimiNativeSkill(id) {
101
+ const openspec = { 'opsx:ff': 'openspec-ff-change', 'opsx:apply': 'openspec-apply-change', 'opsx:verify': 'openspec-verify-change' };
102
+ const skill = openspec[id] ?? (id.startsWith('specrails:') ? id.replace('specrails:', 'specrails-') : id);
103
+ if (!/^[a-z][a-z0-9-]{0,63}$/.test(skill))
104
+ throw new AgentExecutionError('Kimi native command is unsupported', 'native_command_unsupported');
105
+ return skill;
106
+ }
107
+ async function renderKimiRequest(request, options) {
108
+ const skill = kimiNativeSkill(request.nativeCommand.id);
109
+ const runnerPath = path.join(request.cwd, '.kimi-code', 'specrails', 'run-skill.mjs');
110
+ if (!existsSync(runnerPath))
111
+ throw new AgentExecutionError('Kimi managed skill runner is not installed', 'native_command_unsupported');
112
+ const result = await (options.runProcess ?? runCliProcess)({ command: process.execPath, args: [runnerPath, '--skill', skill, '--model', request.model ?? 'kimi-for-coding', '--args', request.nativeCommand.args ?? '', ...(request.resumeSessionId ? ['--session', request.resumeSessionId] : []), '--render-only'] }, { cwd: request.cwd, signal: request.signal, timeoutMs: 10000, env: options.env });
113
+ if (result.exitCode !== 0)
114
+ throw new AgentExecutionError('Kimi native skill cannot be rendered', 'native_command_unsupported');
115
+ let prompt;
116
+ try {
117
+ prompt = JSON.parse(result.stdout).prompt;
118
+ }
119
+ catch { /* Invalid runner output cannot become a provider prompt. */ }
120
+ if (typeof prompt !== 'string' || !prompt.trim() || prompt.includes('\0') || Buffer.byteLength(prompt) > 1_048_576)
121
+ throw new AgentExecutionError('Invalid Kimi native skill prompt', 'native_command_unsupported');
122
+ return { ...request, prompt, nativeCommand: undefined };
123
+ }
89
124
  function object(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; }
90
125
  function number(value) { return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null; }
91
126
  function textBlocks(value) {
@@ -244,7 +279,7 @@ export class CliExecutor {
244
279
  capabilities(model) { return cliCapabilities(this.provider, model, this.options); }
245
280
  capabilityChecks = new Map();
246
281
  async validateOpenSpec(context) {
247
- const key = context.role === 'developer' ? 'write' : 'read';
282
+ const key = openSpecPolicy(context).access;
248
283
  let checked = this.capabilityChecks.get(key);
249
284
  if (!checked) {
250
285
  checked = (async () => {
@@ -266,11 +301,13 @@ export class CliExecutor {
266
301
  throw new AgentExecutionError('Kimi does not report authoritative token usage. Remove the token cap or select another provider for this role.', 'usage_unavailable');
267
302
  }
268
303
  async execute(request) {
269
- validateAgentRequest(request);
304
+ request = normalizeAgentRequest(request);
270
305
  if (request.effort !== undefined)
271
306
  assertEffortSupported(request, await this.capabilities(request.model));
272
307
  this.validateLimits(request);
273
308
  const scope = canonicalWorkspace(request.cwd, request.allowedRoots);
309
+ if (this.provider === 'kimi' && request.nativeCommand)
310
+ request = await renderKimiRequest(request, this.options);
274
311
  const normalized = { ...request, prompt: (request.openspec ? openSpecPrompt(request.openspec) : '') + request.prompt, cwd: scope.cwd, allowedRoots: scope.roots };
275
312
  const runner = this.options.runProcess ?? runCliProcess;
276
313
  let temporary, kimiAgentFile, geminiPolicyFile, codexSchemaFile, stream = '', turns = 0, observedSession;
@@ -295,7 +332,7 @@ export class CliExecutor {
295
332
  if (this.provider === 'kimi')
296
333
  return await executeKimiReadonlyAcp(normalized, { ...this.options, openspecBridge });
297
334
  }
298
- if (this.provider === 'gemini' && request.role !== 'developer') {
335
+ if (this.provider === 'gemini' && requestPolicy(request).access === 'read') {
299
336
  assertGeminiAdminPolicyAvailable();
300
337
  const help = await runner({ command: 'gemini', args: ['--help'] }, { cwd: scope.cwd, signal: request.signal, timeoutMs: 10_000, env: this.options.env });
301
338
  if (help.exitCode !== 0 || !help.stdout.includes('--admin-policy'))
@@ -303,7 +340,7 @@ export class CliExecutor {
303
340
  geminiPolicyFile = path.join(scratch(), 'readonly.toml');
304
341
  writeFileSync(geminiPolicyFile, (openspecBridge ? '[[rule]]\nmcpName = "specrails_openspec"\ntoolName = "workflow"\ndecision = "allow"\npriority = 1000\n\n[[rule]]\nmcpName = "specrails_openspec"\ntoolName = "read_verification_evidence"\ndecision = "allow"\npriority = 1000\n\n' : '') + GEMINI_READONLY_POLICY, { mode: 0o600 });
305
342
  }
306
- if (this.provider === 'kimi' && request.role !== 'developer') {
343
+ if (this.provider === 'kimi' && requestPolicy(request).access === 'read') {
307
344
  const help = await runner({ command: 'kimi', args: ['--help'] }, { cwd: scope.cwd, signal: request.signal, timeoutMs: 10_000, env: this.options.env });
308
345
  if (help.exitCode !== 0)
309
346
  throw new AgentExecutionError('Cannot detect Kimi CLI capabilities', 'provider_capability_unsupported');
@@ -62,6 +62,10 @@ export function cliProcessEnvironment(base, platform = process.platform) {
62
62
  export const runCliProcess = async (raw, options) => {
63
63
  if (options.signal?.aborted)
64
64
  throw new AgentExecutionError('Agent cancelled', 'aborted');
65
+ for (const value of [options.timeoutMs, options.idleTimeoutMs]) {
66
+ if (value !== undefined && (!Number.isSafeInteger(value) || value < 0 || value > 2_147_483_647))
67
+ throw new AgentExecutionError('Invalid process timeout', 'invalid_limit');
68
+ }
65
69
  const env = cliProcessEnvironment(options.env ?? process.env);
66
70
  const invocation = process.platform === 'win32' && raw.command === 'kimi' ? windowsKimiInvocation(raw, { env }) : raw;
67
71
  return new Promise((resolve, reject) => {
@@ -93,12 +97,12 @@ export const runCliProcess = async (raw, options) => {
93
97
  };
94
98
  const fail = (error) => { failure ??= error; terminate(); };
95
99
  const abort = () => fail(new AgentExecutionError('Agent cancelled', 'aborted'));
96
- const timer = setTimeout(() => fail(new AgentExecutionError(`Agent reached the total execution limit (${options.timeoutMs} ms)`, 'timeout')), options.timeoutMs);
100
+ const timer = options.timeoutMs > 0 ? setTimeout(() => fail(new AgentExecutionError(`Agent reached the total execution limit (${options.timeoutMs} ms)`, 'timeout')), options.timeoutMs) : undefined;
97
101
  let idleTimer;
98
102
  const activity = () => {
99
103
  if (idleTimer)
100
104
  clearTimeout(idleTimer);
101
- if (options.idleTimeoutMs !== undefined && !failure)
105
+ if (options.idleTimeoutMs !== undefined && options.idleTimeoutMs > 0 && !failure)
102
106
  idleTimer = setTimeout(() => fail(new AgentExecutionError(`Agent produced no output for ${options.idleTimeoutMs} ms (idle timeout)`, 'idle_timeout')), options.idleTimeoutMs);
103
107
  };
104
108
  activity();
@@ -1,4 +1,16 @@
1
1
  import type { WorkflowState } from './workflow-types.js';
2
+ /** Both runtime entry points emit the same machine-readable fatal result. */
3
+ export declare function runtimeFailure(error: unknown): {
4
+ type: string;
5
+ status: string;
6
+ error: string | {
7
+ details?: import("./workflow-types.js").JsonValue | undefined;
8
+ code: string;
9
+ message: string;
10
+ };
11
+ };
12
+ /** Machine operations are mirrored in integration-contract.json; help is presentation-only. */
13
+ export declare const RUNTIME_CLI_OPERATIONS: readonly ["api", "validate", "run", "status", "resume", "prompts", "capabilities", "evidence", "recovery", "evaluate", "workflows", "fork", "signal", "cancel", "help"];
2
14
  /** The host-facing summary: phase status, pending interrupts and usage without accumulated outputs. */
3
15
  export declare function compactState(state: WorkflowState | null): {
4
16
  runId: string;
@@ -44,4 +56,7 @@ export declare function compactState(state: WorkflowState | null): {
44
56
  }[];
45
57
  metrics: import("./efficiency-types.js").RuntimeEfficiency;
46
58
  } | null;
59
+ /** Engines this package can launch: the legacy built-in pipeline (1) and workflow definitions (2).
60
+ * Core 7 advertises [2] so hosts refuse unconverted legacy loops before admission. */
61
+ export declare const RUNTIME_ENGINES: readonly number[];
47
62
  export declare function runRuntimeCommand(flags: Record<string, string | boolean>, positionals: string[], emit?: (value: unknown) => void): Promise<number>;
@@ -1,3 +1,5 @@
1
+ import { isDefinitionCommand, NODE_KINDS_VERSION, runDefinitionCommand } from './engine/cli.js';
2
+ import { validationPieceRegistry } from './engine/pieces/index.js';
1
3
  import { GUARDRAIL_CATALOG } from './guardrails.js';
2
4
  import { runEvaluation } from './evaluation.js';
3
5
  import { efficiencySummary } from './efficiency-summary.js';
@@ -15,6 +17,15 @@ import { runtimeEfficiency } from './efficiency.js';
15
17
  import { configuredCapabilities } from './capabilities.js';
16
18
  import { createExecutorRegistry } from './executors.js';
17
19
  import { runRecovery } from './recovery.js';
20
+ import { EngineError } from './engine/contracts.js';
21
+ /** Both runtime entry points emit the same machine-readable fatal result. */
22
+ export function runtimeFailure(error) {
23
+ return { type: 'runtime-result', status: 'failed', error: error instanceof EngineError
24
+ ? { code: error.code, message: error.message, ...(error.details === undefined ? {} : { details: error.details }) }
25
+ : error instanceof Error ? error.message : String(error) };
26
+ }
27
+ /** Machine operations are mirrored in integration-contract.json; help is presentation-only. */
28
+ export const RUNTIME_CLI_OPERATIONS = ['api', 'validate', 'run', 'status', 'resume', 'prompts', 'capabilities', 'evidence', 'recovery', 'evaluate', 'workflows', 'fork', 'signal', 'cancel', 'help'];
18
29
  function read(file) { return JSON.parse(readFileSync(file, 'utf8')); }
19
30
  async function readStdin() {
20
31
  const chunks = [];
@@ -87,8 +98,15 @@ function invocationUsage(state, priorCount) {
87
98
  ? null : attempts.reduce((total, a) => total + (a.usage?.[key] ?? 0), 0);
88
99
  return { costUsd: sum('costUsd'), inputTokens: sum('inputTokens'), outputTokens: sum('outputTokens') };
89
100
  }
101
+ /** Engines this package can launch: the legacy built-in pipeline (1) and workflow definitions (2).
102
+ * Core 7 advertises [2] so hosts refuse unconverted legacy loops before admission. */
103
+ export const RUNTIME_ENGINES = [1, 2];
90
104
  export async function runRuntimeCommand(flags, positionals, emit = value => process.stdout.write(JSON.stringify(value) + '\n')) {
91
105
  const command = flags.help === true ? 'help' : positionals[0] ?? 'help';
106
+ if (isDefinitionCommand(flags, command))
107
+ return runDefinitionCommand(flags, positionals, emit);
108
+ if (!RUNTIME_CLI_OPERATIONS.some(operation => operation === command))
109
+ throw new Error('Unknown runtime operation: ' + command);
92
110
  if (command === 'prompts') {
93
111
  emit({ type: 'runtime-role-prompts', defaults: rolePromptDefaults() });
94
112
  return 0;
@@ -105,28 +123,42 @@ export async function runRuntimeCommand(flags, positionals, emit = value => proc
105
123
  if (command === 'evaluate') {
106
124
  if (flags.real !== undefined && flags.real !== true)
107
125
  throw new Error('--real is a boolean flag');
108
- const report = await runEvaluation({ output: stringFlag(flags, 'output'), real: flags.real === true, ...(flags.config === undefined ? {} : { config: validateRuntimeConfig(read(stringFlag(flags, 'config'))) }), ...(flags['max-cost-usd'] === undefined ? {} : { maxCostUsd: Number(stringFlag(flags, 'max-cost-usd')) }), ...(flags.repetitions === undefined ? {} : { repetitions: Number(stringFlag(flags, 'repetitions')) }) });
109
- emit({ type: 'runtime-evaluation', mode: report.mode, observations: report.observations.length, output: stringFlag(flags, 'output'), monetaryConclusion: report.monetaryConclusion, correctionPromptTargetMet: report.correctionPromptTargetMet, noObservedQualityDrop: report.noObservedQualityDrop, stopReason: report.stopReason });
110
- return report.stopReason || (!flags.real && (!report.noObservedQualityDrop || !report.noExtraInvocations || !report.correctionPromptTargetMet)) ? 1 : 0;
126
+ const report = await runEvaluation({ output: stringFlag(flags, 'output'), real: flags.real === true,
127
+ ...(flags.definition === undefined ? {} : { definition: readFileSync(stringFlag(flags, 'definition')) }),
128
+ ...(flags.config === undefined ? {} : { config: validateRuntimeConfig(read(stringFlag(flags, 'config'))) }), ...(flags['max-cost-usd'] === undefined ? {} : { maxCostUsd: Number(stringFlag(flags, 'max-cost-usd')) }), ...(flags.repetitions === undefined ? {} : { repetitions: Number(stringFlag(flags, 'repetitions')) }) });
129
+ emit({ type: 'runtime-evaluation', mode: report.mode, observations: report.observations.length, output: stringFlag(flags, 'output'),
130
+ isDefinitionEvaluation: report.isDefinitionEvaluation, allCasesAccepted: report.allCasesAccepted, acceptedCases: report.acceptedCases,
131
+ monetaryConclusion: report.monetaryConclusion, correctionPromptTargetMet: report.correctionPromptTargetMet, noObservedQualityDrop: report.noObservedQualityDrop, noExtraInvocations: report.noExtraInvocations, stopReason: report.stopReason });
132
+ return report.stopReason || !report.allCasesAccepted || (!flags.real && (!report.noObservedQualityDrop || !report.noExtraInvocations || !report.correctionPromptTargetMet)) ? 1 : 0;
111
133
  }
112
134
  if (command === 'help') {
113
135
  emit({ usage: [
114
136
  'specrails-core runtime api',
115
- 'specrails-core runtime evaluate --output <directory> [--repetitions <1..20>] [--real --config <json> --max-cost-usd <limit>]',
137
+ 'specrails-core runtime evaluate --output <directory> [--definition <json>] [--repetitions <1..20>] [--real --config <json> --max-cost-usd <limit>]',
116
138
  'specrails-core runtime capabilities --config <json>',
117
139
  'specrails-core runtime evidence --context <json> [--id <opaqueId>] [--section summary|stdout|stderr|source] [--source-id <opaqueId>] [--cursor <cursor>] [--limit <1..100>]',
118
140
  'specrails-core runtime prompts',
119
141
  'specrails-core runtime validate --config <json>',
120
142
  'specrails-core runtime validate --stdin',
121
143
  'specrails-core runtime run --context <json> --config <json> --change <kebab-case>',
144
+ 'specrails-core runtime run --context <json> --config <json> --definition <json> [--change <kebab-case>]',
145
+ 'specrails-core runtime workflows list',
146
+ 'specrails-core runtime workflows validate --stdin [--config <json> | --structural]',
122
147
  'specrails-core runtime status --context <json> [--compact]',
123
148
  'specrails-core runtime resume --context <json> [--approve archive] [--answer <text>] [--recover developer] [--invalidate verify]',
124
149
  'specrails-core runtime recovery --context <json> --stdin',
150
+ 'specrails-core runtime fork --context <json> --from <nodePath> --run-id <newRunId> [--scope-id <scope>] [--visit <number>] [--state <json>]',
151
+ 'specrails-core runtime signal --context <json> --stdin [--request-id <id>]',
152
+ 'specrails-core runtime cancel --context <json> [--request-id <id>]',
125
153
  ] });
126
154
  return 0;
127
155
  }
128
156
  if (command === 'api') {
129
- emit({ type: 'runtime-api', apiVersion: RUNTIME_API_VERSION, coreVersion: CORE_PACKAGE_VERSION, runtimeIdentity: coreRuntimeIdentity(), workflowVersions: [CORE_WORKFLOW_VERSION], capabilities: { scopedRecovery: 1, efficientRoleExecution: 1, reproducibleVerification: 1, implementationEfficiencyMetrics: 1, compactAgentLoop: 1, configurableGuardrails: 1, compactOutputBudget: 1, roleThinkingControl: 1, repositoryScope: 1 }, guardrails: GUARDRAIL_CATALOG });
157
+ // Engine v2 fields come from the same validation registry that admits definitions; test-only pieces are never registered there.
158
+ emit({ type: 'runtime-api', apiVersion: RUNTIME_API_VERSION, coreVersion: CORE_PACKAGE_VERSION, runtimeIdentity: coreRuntimeIdentity(), workflowVersions: [CORE_WORKFLOW_VERSION],
159
+ engineVersion: 2, engines: RUNTIME_ENGINES, nodeKindsVersion: NODE_KINDS_VERSION, nodeKinds: validationPieceRegistry().kinds(),
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 });
130
162
  return 0;
131
163
  }
132
164
  if (command === 'validate') {
@@ -163,8 +195,6 @@ export async function runRuntimeCommand(flags, positionals, emit = value => proc
163
195
  process.off('SIGTERM', abort);
164
196
  }
165
197
  }
166
- if (!['run', 'status', 'resume'].includes(command))
167
- throw new Error('Unknown runtime operation: ' + command);
168
198
  const context = validatePipelineContext(read(stringFlag(flags, 'context')));
169
199
  const directory = path.join(pipelineStateDirectory(context), 'agent-workflow');
170
200
  const previous = await readWorkflowState(directory, context.runId);
@@ -178,6 +208,8 @@ export async function runRuntimeCommand(flags, positionals, emit = value => proc
178
208
  const requestFile = path.join(pipelineStateDirectory(context), 'agent-runtime-request.json');
179
209
  let request;
180
210
  if (command === 'resume') {
211
+ if (flags.definition !== undefined)
212
+ throw new EngineError('invalid_arguments', 'Resume uses the frozen definition; start a new run to change it');
181
213
  if (!previous)
182
214
  throw new Error('No programmatic run exists for this context');
183
215
  request = read(requestFile);
@@ -189,7 +221,9 @@ export async function runRuntimeCommand(flags, positionals, emit = value => proc
189
221
  else {
190
222
  if (flags.answer !== undefined || flags.approve !== undefined)
191
223
  throw new Error('Answers and approvals apply to runtime resume');
192
- request = { change: stringFlag(flags, 'change'), config: normalizeRuntimeConfig(read(stringFlag(flags, 'config'))), runtimeIdentity: coreRuntimeIdentity() };
224
+ // The built-in workflow has no published definition hash; its resume identity is the checkpoint's workflowFingerprint.
225
+ request = { change: stringFlag(flags, 'change'), config: normalizeRuntimeConfig(read(stringFlag(flags, 'config'))), runtimeIdentity: coreRuntimeIdentity(),
226
+ workflow: { id: 'specrails-implementation', version: CORE_WORKFLOW_VERSION, source: 'builtin', definitionHash: null, engine: 1 } };
193
227
  await preflightCoreWorkflow({ context, change: request.change, config: request.config });
194
228
  const serialized = JSON.stringify(request, null, 2) + '\n';
195
229
  mkdirSync(path.dirname(requestFile), { recursive: true, mode: 0o700 });
@@ -237,7 +271,7 @@ if (process.argv[1] && existsSync(process.argv[1]) && import.meta.url === pathTo
237
271
  process.exitCode = await runRuntimeCommand(flags, [subcommand ?? 'help', ...positionals]);
238
272
  }
239
273
  catch (error) {
240
- process.stdout.write(JSON.stringify({ type: 'runtime-result', status: 'failed', error: error instanceof Error ? error.message : String(error) }) + '\n');
274
+ process.stdout.write(JSON.stringify(runtimeFailure(error)) + '\n');
241
275
  process.exitCode = 1;
242
276
  }
243
277
  }
@@ -1,3 +1,4 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
1
2
  import { AgentExecutionError } from '../executor-types.js';
2
3
  import { sumCacheUsage } from '../efficiency-types.js';
3
4
  /**
@@ -49,7 +50,7 @@ export function patientFetchInit() {
49
50
  }
50
51
  const RETRY_DELAY_MS = 3000;
51
52
  function sleep(ms, signal) {
52
- return new Promise((resolve, reject) => { const timer = setTimeout(resolve, ms); signal.addEventListener('abort', () => { clearTimeout(timer); reject(signal.reason); }, { once: true }); });
53
+ return delay(ms, undefined, { signal });
53
54
  }
54
55
  export function record(value) {
55
56
  return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
@@ -1,4 +1,4 @@
1
- import type { RuntimeConfig } from './executor-types.js';
1
+ import { type RoleDescriptor, type RuntimeConfig } from './executor-types.js';
2
2
  export declare function validateRuntimeConfig(input: unknown, options?: {
3
3
  registeredProviderIds?: string[];
4
4
  }): RuntimeConfig;
@@ -9,3 +9,6 @@ export declare function loadRuntimeConfig(file: string, options?: {
9
9
  export declare function normalizeRuntimeConfig(input: unknown, options?: {
10
10
  registeredProviderIds?: string[];
11
11
  }): RuntimeConfig;
12
+ /** Built-ins stay ordered and custom roles are declared data, never inferred from a prompt. */
13
+ export declare function roleIds(config: RuntimeConfig): string[];
14
+ export declare function resolveRoleDescriptor(config: RuntimeConfig, id: string): RoleDescriptor;
@@ -1,7 +1,9 @@
1
+ import { isDeepStrictEqual } from 'node:util';
1
2
  import { validateGuardrailSettings } from './guardrails.js';
2
3
  import { readFileSync } from 'node:fs';
4
+ import { BUILTIN_ROLES, BUILTIN_ROLE_POLICY, ROLE_ID, isBuiltinRole } from './executor-types.js';
3
5
  import { DEFAULT_REVIEW_POLICY, REVIEW_ASPECTS } from './graph/review-policy.js';
4
- const ROLES = ['architect', 'developer', 'reviewer'];
6
+ const ROLES = [...BUILTIN_ROLES];
5
7
  const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
6
8
  function fail(field, why) { throw new Error(`Invalid runtime config ${field}: ${why}`); }
7
9
  function object(value, field) {
@@ -60,7 +62,7 @@ function score(value, field, floor) {
60
62
  }
61
63
  export function validateRuntimeConfig(input, options = {}) {
62
64
  const config = object(input, '$');
63
- keys(config, ['schemaVersion', 'enabled', 'providers', 'agents', 'fixer', 'limits', 'verification', 'approvalBeforeArchive', 'review', 'architect', 'rolePrompts', 'efficiency', 'guardrails'], '$');
65
+ keys(config, ['schemaVersion', 'enabled', 'providers', 'agents', 'roles', 'fixer', 'limits', 'verification', 'approvalBeforeArchive', 'review', 'architect', 'rolePrompts', 'efficiency', 'guardrails'], '$');
64
66
  if (config.schemaVersion !== 1)
65
67
  fail('schemaVersion', 'expected 1');
66
68
  if (config.enabled !== undefined && typeof config.enabled !== 'boolean')
@@ -141,6 +143,30 @@ export function validateRuntimeConfig(input, options = {}) {
141
143
  return structuredClone(agent);
142
144
  };
143
145
  const agents = Object.fromEntries(ROLES.map(role => [role, validateAgent(rawAgents[role], `agents.${role}`)]));
146
+ let roles;
147
+ if (config.roles !== undefined) {
148
+ const raw = object(config.roles, 'roles');
149
+ roles = {};
150
+ for (const [id, value] of Object.entries(raw)) {
151
+ if (!ROLE_ID.test(id) || id === 'fixer')
152
+ fail(`roles.${id}`, 'expected a role id; fixer is reserved');
153
+ const descriptor = object(value, `roles.${id}`);
154
+ const { access, artifacts, prompt, openspecSkill, ...assignment } = descriptor;
155
+ const selected = validateAgent(assignment, `roles.${id}`);
156
+ choices(access, ['read', 'write'], `roles.${id}.access`);
157
+ choices(artifacts, ['none', 'tasks-checkboxes', 'all'], `roles.${id}.artifacts`);
158
+ if (prompt !== undefined && string(prompt, `roles.${id}.prompt`).length > 20000)
159
+ fail(`roles.${id}.prompt`, 'maximum 20000 characters');
160
+ if (openspecSkill !== undefined)
161
+ choices(openspecSkill, ['openspec-ff-change', 'openspec-apply-change', 'openspec-verify-change'], `roles.${id}.openspecSkill`);
162
+ if (isBuiltinRole(id)) {
163
+ const policy = BUILTIN_ROLE_POLICY[id];
164
+ if (access !== policy.access || artifacts !== policy.artifacts || openspecSkill !== policy.openspecSkill || prompt !== undefined || !isDeepStrictEqual(selected, agents[id]))
165
+ fail(`roles.${id}`, 'built-in descriptors must match their implicit assignment and policy');
166
+ }
167
+ roles[id] = { ...selected, access, artifacts, ...(prompt === undefined ? {} : { prompt }), ...(openspecSkill === undefined ? {} : { openspecSkill }) };
168
+ }
169
+ }
144
170
  // The optional fixer: the engine correction rounds run on (verify failure or
145
171
  // review rejection); absent ⇒ the developer corrects its own work.
146
172
  const fixer = config.fixer === undefined ? undefined : validateAgent(config.fixer, 'fixer');
@@ -259,7 +285,7 @@ export function validateRuntimeConfig(input, options = {}) {
259
285
  let rolePrompts;
260
286
  if (config.rolePrompts !== undefined) {
261
287
  const raw = object(config.rolePrompts, 'rolePrompts');
262
- keys(raw, [...ROLES, 'fixer'], 'rolePrompts');
288
+ keys(raw, [...ROLES, 'fixer', ...Object.keys(roles ?? {})], 'rolePrompts');
263
289
  rolePrompts = {};
264
290
  for (const [role, value] of Object.entries(raw)) {
265
291
  const text = string(value, `rolePrompts.${role}`);
@@ -269,6 +295,7 @@ export function validateRuntimeConfig(input, options = {}) {
269
295
  }
270
296
  }
271
297
  return { schemaVersion: 1, enabled: true, providers, agents, verification,
298
+ ...(roles === undefined ? {} : { roles }),
272
299
  ...(config.efficiency === undefined ? {} : { efficiency: structuredClone(config.efficiency) }),
273
300
  ...(rolePrompts === undefined ? {} : { rolePrompts }),
274
301
  ...(config.limits === undefined ? {} : { limits: { ...config.limits } }),
@@ -284,9 +311,20 @@ export function loadRuntimeConfig(file, options) {
284
311
  /** New admissions normalize once; validation of a saved document is lossless. */
285
312
  export function normalizeRuntimeConfig(input, options) {
286
313
  const config = JSON.parse(JSON.stringify(validateRuntimeConfig(input, options)));
314
+ config.roles = Object.fromEntries(roleIds(config).map(id => { const { id: _id, ...descriptor } = resolveRoleDescriptor(config, id); return [id, descriptor]; }));
287
315
  config.efficiency = {
288
316
  schemaVersion: 1, contextMode: 'incremental', reviewMode: 'incremental', planning: 'proportional', acceptDeveloperChecks: true,
289
317
  ...config.efficiency, verification: { maxConcurrency: 1, ...config.efficiency?.verification },
290
318
  };
291
319
  return config;
292
320
  }
321
+ /** Built-ins stay ordered and custom roles are declared data, never inferred from a prompt. */
322
+ export function roleIds(config) { return [...new Set([...BUILTIN_ROLES, ...Object.keys(config.roles ?? {})])]; }
323
+ export function resolveRoleDescriptor(config, id) {
324
+ if (isBuiltinRole(id))
325
+ return { ...config.agents[id], ...BUILTIN_ROLE_POLICY[id], id };
326
+ const role = Object.hasOwn(config.roles ?? {}, id) ? config.roles[id] : undefined;
327
+ if (!role)
328
+ throw new Error(`Unknown runtime role '${id}'`);
329
+ return { ...role, ...(config.rolePrompts?.[id] === undefined ? {} : { prompt: config.rolePrompts[id] }), id };
330
+ }
@@ -2,8 +2,9 @@ import { type PipelineContext } from '../pipeline/pipeline-state.js';
2
2
  import { type ExecutorRegistry } from './executors.js';
3
3
  import type { AgentEvent, RuntimeConfig, AgentEventRole } from './executor-types.js';
4
4
  import { parseAgentObject } from './graph/artifacts.js';
5
+ import { type CoreNodeId, type CoreStateType } from './graph/state.js';
5
6
  import { type RuntimeIdentity } from './runtime-identity.js';
6
- import type { WorkflowEvent, WorkflowSpan, WorkflowState } from './workflow-types.js';
7
+ import type { WorkflowDefinition, WorkflowEvent, WorkflowNode, WorkflowSpan, WorkflowState } from './workflow-types.js';
7
8
  export declare const RUNTIME_API_VERSION = 1;
8
9
  export declare const CORE_WORKFLOW_VERSION = "7";
9
10
  export declare const CORE_PACKAGE_VERSION: string;
@@ -32,9 +33,14 @@ export declare function preflightCoreWorkflow(options: Pick<CoreWorkflowOptions,
32
33
  config: RuntimeConfig;
33
34
  registry: ExecutorRegistry;
34
35
  }>;
36
+ /** The legacy graph's resume identity; extracting it must never change its fingerprint. */
37
+ export declare function implementationWorkflowDefinition(nodes: Record<CoreNodeId, WorkflowNode<CoreStateType>>, { attempts, compactDeveloper }: {
38
+ attempts: number;
39
+ compactDeveloper: boolean;
40
+ }): WorkflowDefinition<CoreStateType>;
35
41
  /**
36
- * Runs the Core implementation graph: architect → developer → verify → review (→ fixer → verify → review on corrections)er → archive,
37
- * with bounded corrections routed back to the developer, an autonomous investigation pass
42
+ * Runs the Core implementation graph: architect → developer → verify → reviewer → archive,
43
+ * with bounded corrections through fixer → verify → reviewer, an autonomous investigation pass
38
44
  * before a low-confidence design asks the requester, real verification receipts, acceptance
39
45
  * evidence bound to the exact candidate, and an optional approval before archive.
40
46
  */
@@ -3,7 +3,7 @@ import { prepareOpenSpec, roleOpenSpecContext } from './openspec.js';
3
3
  import { existsSync, readFileSync } from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { fingerprintCandidate, initializePipeline, inspectPipeline, pipelineStateDirectory, validatePipelineContext, validateVerificationRequest, } from '../pipeline/pipeline-state.js';
6
- import { normalizeRuntimeConfig } from './config.js';
6
+ import { normalizeRuntimeConfig, resolveRoleDescriptor, roleIds } from './config.js';
7
7
  import { createExecutorRegistry } from './executors.js';
8
8
  import { archive, child, journal, parseAgentObject, SLUG } from './graph/artifacts.js';
9
9
  import { coreNodes } from './graph/nodes.js';
@@ -18,7 +18,7 @@ export const RUNTIME_API_VERSION = 1;
18
18
  export const CORE_WORKFLOW_VERSION = '7';
19
19
  export const CORE_PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
20
20
  export function coreRuntimeIdentity() {
21
- return { packageVersion: CORE_PACKAGE_VERSION, workflowVersion: CORE_WORKFLOW_VERSION, instructionsVersion: String(ROLE_INSTRUCTIONS_VERSION), packageIntegrity: runtimePackageIntegrity(), apiVersion: 1 };
21
+ return { packageVersion: CORE_PACKAGE_VERSION, workflowVersion: CORE_WORKFLOW_VERSION, instructionsVersion: String(ROLE_INSTRUCTIONS_VERSION), packageIntegrity: runtimePackageIntegrity(), apiVersion: RUNTIME_API_VERSION };
22
22
  }
23
23
  export { parseAgentObject };
24
24
  /** Read-only admission: validate scope, limits and actual effort support before freezing a request. */
@@ -36,7 +36,7 @@ export async function preflightCoreWorkflow(options) {
36
36
  if (config.verification.length)
37
37
  validateVerificationRequest(context, { kind: 'scoped', commands: config.verification });
38
38
  const registry = options.registry ?? createExecutorRegistry(config);
39
- for (const selected of Object.values(config.agents)) {
39
+ for (const selected of roleIds(config).map(id => resolveRoleDescriptor(config, id))) {
40
40
  registry.validateLimits(selected.provider, config.limits ?? {});
41
41
  for (const tier of [selected, ...(selected.escalation ? [selected.escalation] : [])]) {
42
42
  if (tier.effort !== undefined)
@@ -45,9 +45,19 @@ export async function preflightCoreWorkflow(options) {
45
45
  }
46
46
  return { context, config, registry };
47
47
  }
48
+ /** The legacy graph's resume identity; extracting it must never change its fingerprint. */
49
+ export function implementationWorkflowDefinition(nodes, { attempts, compactDeveloper }) {
50
+ return {
51
+ id: 'specrails-implementation', version: CORE_WORKFLOW_VERSION, schema: CoreState, entry: 'architect',
52
+ // The compact developer finishes task groups over developer→verify passes
53
+ // that do not count as corrections (bounded: +2 passes each).
54
+ maxTransitions: attempts * 3 + 3 + (compactDeveloper ? attempts * 2 * 2 : 0),
55
+ nodes: Object.fromEntries(CORE_NODE_ORDER.map(id => [id, nodes[id]])),
56
+ };
57
+ }
48
58
  /**
49
- * Runs the Core implementation graph: architect → developer → verify → review (→ fixer → verify → review on corrections)er → archive,
50
- * with bounded corrections routed back to the developer, an autonomous investigation pass
59
+ * Runs the Core implementation graph: architect → developer → verify → reviewer → archive,
60
+ * with bounded corrections through fixer → verify → reviewer, an autonomous investigation pass
51
61
  * before a low-confidence design asks the requester, real verification receipts, acceptance
52
62
  * evidence bound to the exact candidate, and an optional approval before archive.
53
63
  */
@@ -68,12 +78,12 @@ export async function runCoreWorkflow(options) {
68
78
  }
69
79
  }
70
80
  const prepared = prepareOpenSpec(context.artifactRoot, options.change, directory);
71
- const openspec = Object.fromEntries(['architect', 'developer', 'reviewer'].map(role => {
72
- const provider = config.providers.find(item => item.id === config.agents[role].provider);
73
- return [role, roleOpenSpecContext(prepared, context.artifactRoot, options.change, directory, role, provider?.kind === 'cli' ? provider.cli : 'claude')];
81
+ const openspec = Object.fromEntries(roleIds(config).filter(role => resolveRoleDescriptor(config, role).openspecSkill).map(role => {
82
+ const provider = config.providers.find(item => item.id === resolveRoleDescriptor(config, role).provider);
83
+ return [role, roleOpenSpecContext(prepared, context.artifactRoot, options.change, directory, resolveRoleDescriptor(config, role), provider?.kind === 'cli' ? provider.cli : 'claude')];
74
84
  }));
75
- for (const role of ['architect', 'developer', 'reviewer'])
76
- await registry.get(config.agents[role].provider).validateOpenSpec?.(openspec[role]);
85
+ for (const role of Object.keys(openspec))
86
+ await registry.get(resolveRoleDescriptor(config, role).provider).validateOpenSpec?.(openspec[role]);
77
87
  const attempts = config.limits?.maxAttempts ?? 3;
78
88
  const developerProvider = config.providers.find(item => item.id === config.agents.developer.provider);
79
89
  const compactDeveloper = developerProvider?.kind === 'openai-compatible' && (developerProvider.agentLoop ?? 'compact') === 'compact';
@@ -127,13 +137,6 @@ export async function runCoreWorkflow(options) {
127
137
  return inspection.phases.archive.status === 'done';
128
138
  return true;
129
139
  },
130
- workflow: {
131
- id: 'specrails-implementation', version: CORE_WORKFLOW_VERSION, schema: CoreState, entry: 'architect',
132
- // The compact (small-model) developer finishes its task groups over
133
- // several developer→verify passes that do not count as corrections, so
134
- // the transition budget must cover them too (bounded: +2 passes each).
135
- maxTransitions: attempts * 3 + 3 + (compactDeveloper ? attempts * 2 * 2 : 0),
136
- nodes: Object.fromEntries(CORE_NODE_ORDER.map(id => [id, nodes[id]])),
137
- },
140
+ workflow: implementationWorkflowDefinition(nodes, { attempts, compactDeveloper }),
138
141
  });
139
142
  }
@@ -13,6 +13,9 @@ export declare function efficiencySummary(state: WorkflowState, context: Pipelin
13
13
  notRunEvidenceIds?: string[];
14
14
  };
15
15
  };
16
+ }, durableEvidence?: {
17
+ items: Array<Record<string, unknown>>;
18
+ available: boolean;
16
19
  }): {
17
20
  schemaVersion: 1;
18
21
  currentEvidenceIds: string[];
@@ -24,7 +27,7 @@ export declare function efficiencySummary(state: WorkflowState, context: Pipelin
24
27
  archive: import("../pipeline/pipeline-state.js").PhaseStatus;
25
28
  delivery: "pending" | "complete" | "pending-host";
26
29
  roles: {
27
- role: "architect" | "developer" | "reviewer";
30
+ role: string;
28
31
  provider: string | null;
29
32
  model: string | null;
30
33
  effort: string | null;