specrails-core 6.0.0 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/README.md +1 -1
  2. package/dist/agent-runtime/capabilities.js +3 -1
  3. package/dist/agent-runtime/change-scope.d.ts +63 -0
  4. package/dist/agent-runtime/change-scope.js +297 -0
  5. package/dist/agent-runtime/cli-executor.d.ts +3 -0
  6. package/dist/agent-runtime/cli-executor.js +50 -13
  7. package/dist/agent-runtime/cli-process.js +6 -2
  8. package/dist/agent-runtime/cli.d.ts +15 -0
  9. package/dist/agent-runtime/cli.js +43 -9
  10. package/dist/agent-runtime/compact/chat-client.js +2 -1
  11. package/dist/agent-runtime/compact/environment.d.ts +12 -0
  12. package/dist/agent-runtime/compact/environment.js +31 -1
  13. package/dist/agent-runtime/compact/test-reachability.js +6 -2
  14. package/dist/agent-runtime/config.d.ts +4 -1
  15. package/dist/agent-runtime/config.js +41 -3
  16. package/dist/agent-runtime/core-host.d.ts +10 -4
  17. package/dist/agent-runtime/core-host.js +22 -19
  18. package/dist/agent-runtime/efficiency-summary.d.ts +4 -1
  19. package/dist/agent-runtime/efficiency-summary.js +30 -27
  20. package/dist/agent-runtime/efficiency-types.d.ts +4 -4
  21. package/dist/agent-runtime/efficiency.d.ts +1 -1
  22. package/dist/agent-runtime/efficiency.js +7 -6
  23. package/dist/agent-runtime/engine/__fixtures__/implementation-component.json +90 -0
  24. package/dist/agent-runtime/engine/__fixtures__/implementation-fixture.d.ts +11 -0
  25. package/dist/agent-runtime/engine/__fixtures__/implementation-fixture.js +60 -0
  26. package/dist/agent-runtime/engine/__fixtures__/implementation.json +43 -0
  27. package/dist/agent-runtime/engine/budget.d.ts +9 -0
  28. package/dist/agent-runtime/engine/budget.js +33 -0
  29. package/dist/agent-runtime/engine/canonical-json.d.ts +9 -0
  30. package/dist/agent-runtime/engine/canonical-json.js +162 -0
  31. package/dist/agent-runtime/engine/checkpoint/database.d.ts +67 -0
  32. package/dist/agent-runtime/engine/checkpoint/database.js +304 -0
  33. package/dist/agent-runtime/engine/checkpoint/lease.d.ts +17 -0
  34. package/dist/agent-runtime/engine/checkpoint/lease.js +82 -0
  35. package/dist/agent-runtime/engine/checkpoint/ledger.d.ts +133 -0
  36. package/dist/agent-runtime/engine/checkpoint/ledger.js +510 -0
  37. package/dist/agent-runtime/engine/checkpoint/saver.d.ts +25 -0
  38. package/dist/agent-runtime/engine/checkpoint/saver.js +146 -0
  39. package/dist/agent-runtime/engine/cli.d.ts +10 -0
  40. package/dist/agent-runtime/engine/cli.js +172 -0
  41. package/dist/agent-runtime/engine/compiler.d.ts +23 -0
  42. package/dist/agent-runtime/engine/compiler.js +261 -0
  43. package/dist/agent-runtime/engine/composition.d.ts +16 -0
  44. package/dist/agent-runtime/engine/composition.js +28 -0
  45. package/dist/agent-runtime/engine/concurrency.d.ts +14 -0
  46. package/dist/agent-runtime/engine/concurrency.js +55 -0
  47. package/dist/agent-runtime/engine/contracts.d.ts +273 -0
  48. package/dist/agent-runtime/engine/contracts.js +11 -0
  49. package/dist/agent-runtime/engine/definition-schema.d.ts +250 -0
  50. package/dist/agent-runtime/engine/definition-schema.js +313 -0
  51. package/dist/agent-runtime/engine/definition-types.d.ts +84 -0
  52. package/dist/agent-runtime/engine/definition-types.js +3 -0
  53. package/dist/agent-runtime/engine/definition-validator.d.ts +7 -0
  54. package/dist/agent-runtime/engine/definition-validator.js +187 -0
  55. package/dist/agent-runtime/engine/efficiency-summary.d.ts +62 -0
  56. package/dist/agent-runtime/engine/efficiency-summary.js +31 -0
  57. package/dist/agent-runtime/engine/events.d.ts +19 -0
  58. package/dist/agent-runtime/engine/events.js +88 -0
  59. package/dist/agent-runtime/engine/execution.d.ts +29 -0
  60. package/dist/agent-runtime/engine/execution.js +121 -0
  61. package/dist/agent-runtime/engine/expressions.d.ts +11 -0
  62. package/dist/agent-runtime/engine/expressions.js +172 -0
  63. package/dist/agent-runtime/engine/fork.d.ts +28 -0
  64. package/dist/agent-runtime/engine/fork.js +211 -0
  65. package/dist/agent-runtime/engine/graph-description.d.ts +48 -0
  66. package/dist/agent-runtime/engine/graph-description.js +16 -0
  67. package/dist/agent-runtime/engine/implementation-compiler.d.ts +214 -0
  68. package/dist/agent-runtime/engine/implementation-compiler.js +91 -0
  69. package/dist/agent-runtime/engine/index.d.ts +14 -0
  70. package/dist/agent-runtime/engine/index.js +14 -0
  71. package/dist/agent-runtime/engine/invocation-context.d.ts +11 -0
  72. package/dist/agent-runtime/engine/invocation-context.js +128 -0
  73. package/dist/agent-runtime/engine/otel.d.ts +15 -0
  74. package/dist/agent-runtime/engine/otel.js +132 -0
  75. package/dist/agent-runtime/engine/output.d.ts +4 -0
  76. package/dist/agent-runtime/engine/output.js +73 -0
  77. package/dist/agent-runtime/engine/piece-dependencies.d.ts +50 -0
  78. package/dist/agent-runtime/engine/piece-dependencies.js +193 -0
  79. package/dist/agent-runtime/engine/piece-registry.d.ts +19 -0
  80. package/dist/agent-runtime/engine/piece-registry.js +62 -0
  81. package/dist/agent-runtime/engine/pieces/assign.d.ts +4 -0
  82. package/dist/agent-runtime/engine/pieces/assign.js +35 -0
  83. package/dist/agent-runtime/engine/pieces/control.d.ts +5 -0
  84. package/dist/agent-runtime/engine/pieces/control.js +49 -0
  85. package/dist/agent-runtime/engine/pieces/decider.d.ts +5 -0
  86. package/dist/agent-runtime/engine/pieces/decider.js +70 -0
  87. package/dist/agent-runtime/engine/pieces/implementation-binding.d.ts +12 -0
  88. package/dist/agent-runtime/engine/pieces/implementation-binding.js +40 -0
  89. package/dist/agent-runtime/engine/pieces/implementation-journal.d.ts +25 -0
  90. package/dist/agent-runtime/engine/pieces/implementation-journal.js +192 -0
  91. package/dist/agent-runtime/engine/pieces/implementation.d.ts +23 -0
  92. package/dist/agent-runtime/engine/pieces/implementation.js +176 -0
  93. package/dist/agent-runtime/engine/pieces/index.d.ts +7 -0
  94. package/dist/agent-runtime/engine/pieces/index.js +22 -0
  95. package/dist/agent-runtime/engine/pieces/openspec.d.ts +3 -0
  96. package/dist/agent-runtime/engine/pieces/openspec.js +69 -0
  97. package/dist/agent-runtime/engine/pieces/ports.d.ts +48 -0
  98. package/dist/agent-runtime/engine/pieces/ports.js +1 -0
  99. package/dist/agent-runtime/engine/pieces/project-memory.d.ts +3 -0
  100. package/dist/agent-runtime/engine/pieces/project-memory.js +13 -0
  101. package/dist/agent-runtime/engine/pieces/prompt.d.ts +5 -0
  102. package/dist/agent-runtime/engine/pieces/prompt.js +130 -0
  103. package/dist/agent-runtime/engine/pieces/role-turn.d.ts +7 -0
  104. package/dist/agent-runtime/engine/pieces/role-turn.js +73 -0
  105. package/dist/agent-runtime/engine/pieces/shared.d.ts +67 -0
  106. package/dist/agent-runtime/engine/pieces/shared.js +40 -0
  107. package/dist/agent-runtime/engine/pieces/shell.d.ts +6 -0
  108. package/dist/agent-runtime/engine/pieces/shell.js +47 -0
  109. package/dist/agent-runtime/engine/pieces/verify.d.ts +7 -0
  110. package/dist/agent-runtime/engine/pieces/verify.js +52 -0
  111. package/dist/agent-runtime/engine/preflight.d.ts +27 -0
  112. package/dist/agent-runtime/engine/preflight.js +54 -0
  113. package/dist/agent-runtime/engine/regex.d.ts +3 -0
  114. package/dist/agent-runtime/engine/regex.js +19 -0
  115. package/dist/agent-runtime/engine/run-status.d.ts +563 -0
  116. package/dist/agent-runtime/engine/run-status.js +61 -0
  117. package/dist/agent-runtime/engine/runs.d.ts +605 -0
  118. package/dist/agent-runtime/engine/runs.js +247 -0
  119. package/dist/agent-runtime/engine/state.d.ts +64 -0
  120. package/dist/agent-runtime/engine/state.js +98 -0
  121. package/dist/agent-runtime/engine/steering/inbox.d.ts +54 -0
  122. package/dist/agent-runtime/engine/steering/inbox.js +113 -0
  123. package/dist/agent-runtime/engine/storage/fork-allocation.d.ts +21 -0
  124. package/dist/agent-runtime/engine/storage/fork-allocation.js +143 -0
  125. package/dist/agent-runtime/engine/storage/private-path.d.ts +11 -0
  126. package/dist/agent-runtime/engine/storage/private-path.js +74 -0
  127. package/dist/agent-runtime/engine/store/sqlite-store.d.ts +26 -0
  128. package/dist/agent-runtime/engine/store/sqlite-store.js +227 -0
  129. package/dist/agent-runtime/evaluation-corpus.d.ts +125 -0
  130. package/dist/agent-runtime/evaluation-corpus.js +4 -0
  131. package/dist/agent-runtime/evaluation.d.ts +9 -3
  132. package/dist/agent-runtime/evaluation.js +50 -8
  133. package/dist/agent-runtime/executor-types.d.ts +53 -3
  134. package/dist/agent-runtime/executor-types.js +35 -2
  135. package/dist/agent-runtime/executors.js +2 -2
  136. package/dist/agent-runtime/graph/artifacts.d.ts +7 -0
  137. package/dist/agent-runtime/graph/artifacts.js +86 -10
  138. package/dist/agent-runtime/graph/convergence.d.ts +26 -0
  139. package/dist/agent-runtime/graph/convergence.js +62 -0
  140. package/dist/agent-runtime/graph/nodes.d.ts +2 -0
  141. package/dist/agent-runtime/graph/nodes.js +171 -24
  142. package/dist/agent-runtime/graph/roles.d.ts +6 -0
  143. package/dist/agent-runtime/graph/roles.js +23 -17
  144. package/dist/agent-runtime/graph/state.d.ts +5 -0
  145. package/dist/agent-runtime/graph/state.js +2 -0
  146. package/dist/agent-runtime/kimi-acp.js +6 -6
  147. package/dist/agent-runtime/openai-executor.js +17 -12
  148. package/dist/agent-runtime/openspec.d.ts +17 -2
  149. package/dist/agent-runtime/openspec.js +54 -20
  150. package/dist/agent-runtime/prompts.d.ts +11 -5
  151. package/dist/agent-runtime/prompts.js +71 -20
  152. package/dist/agent-runtime/recovery.d.ts +1 -1
  153. package/dist/agent-runtime/repository-context.js +20 -1
  154. package/dist/agent-runtime/role-routing.js +4 -2
  155. package/dist/agent-runtime/role-state.d.ts +5 -0
  156. package/dist/agent-runtime/verification-plan.d.ts +2 -0
  157. package/dist/agent-runtime/verification-plan.js +18 -3
  158. package/dist/agent-runtime/workflow-types.d.ts +2 -0
  159. package/dist/agent-runtime/workflow.d.ts +3 -1
  160. package/dist/agent-runtime/workflow.js +7 -3
  161. package/dist/agent-runtime/workspace-tools.d.ts +2 -2
  162. package/dist/agent-runtime/workspace-tools.js +6 -6
  163. package/dist/installer/cli.js +5 -0
  164. package/dist/installer/phases/scaffold.d.ts +2 -2
  165. package/dist/installer/phases/scaffold.js +3 -7
  166. package/dist/pipeline/pipeline-state.d.ts +49 -4
  167. package/dist/pipeline/pipeline-state.js +150 -23
  168. package/integration-contract.json +84 -12
  169. package/package.json +12 -5
  170. package/schemas/agent-runtime.schema.json +93 -1
  171. package/schemas/workflow-definition.schema.json +312 -0
  172. package/templates/kimi/specrails/run-skill.mjs +15 -1
  173. package/templates/commands/specrails/batch-implement.md +0 -32
@@ -1,15 +1,18 @@
1
1
  import { guardrailEnabled } from '../guardrails.js';
2
2
  import { addDeveloperChecks, bindPlan, expandedPlanCommands, initializeVerificationPlan, readVerificationPlan, validateProposedChecks } from '../verification-plan.js';
3
+ import { existsSync } from 'node:fs';
3
4
  import path from 'node:path';
4
5
  import { OpenSpecTools } from '../openspec.js';
5
- import { candidateManifest, fingerprintCandidate, frozenAcceptanceCriteria, recordAcceptance, transitionPipeline, validateAcceptanceReport, verifyPipeline, } from '../../pipeline/pipeline-state.js';
6
+ import { candidateManifest, fingerprintCandidate, frozenAcceptanceCriteria, inspectPipeline, recordAcceptance, transitionPipeline, validateAcceptanceReport, verifyPipeline, } from '../../pipeline/pipeline-state.js';
6
7
  import { ARCHITECT_OUTPUT_SCHEMA, DEVELOPER_OUTPUT_SCHEMA, REVIEW_OUTPUT_SCHEMA, correctionInstructions, deepenInstructions, roleInstructions } from '../prompts.js';
7
8
  import { archive, child, journal, object, parseArchitecture, proposedVerification, write, writeDesignConfidence } from './artifacts.js';
8
9
  import { evaluateReview } from './review-policy.js';
9
- import { installEnvironment, isEnvironmentFailure } from '../compact/environment.js';
10
+ import { hostPreconditionFailure, installEnvironment, isEnvironmentFailure } from '../compact/environment.js';
10
11
  import { unreachedTestFiles, unreachedTestsReason } from '../compact/test-reachability.js';
11
12
  import { exitCodeContradiction, exitHonestyReason } from '../compact/exit-code-honesty.js';
12
13
  import { boundedReviewManifest, reviewChanges } from '../review-context.js';
14
+ import { describeFailure, divergence, outcomesSinceResume, unchangedFailure } from './convergence.js';
15
+ import { captureOutOfScope, changeBases, changeSet, describeDiscarded, discardOutOfScopeEdits, renderChangeSet } from '../change-scope.js';
13
16
  /** Autonomous investigation passes before a low-confidence design asks or proceeds. */
14
17
  export const MAX_DEEPEN_PASSES = 1;
15
18
  const DEVELOPER_SUMMARY_LIMIT = 32_000;
@@ -63,6 +66,34 @@ export function developerRecord(output, text, result, provider) {
63
66
  incomplete,
64
67
  };
65
68
  }
69
+ /** The change so far as prompt lines; a checkout git cannot measure yields no section instead of failing the role. */
70
+ function measuredChange(context, change) {
71
+ try {
72
+ return renderChangeSet(changeSet(context, change));
73
+ }
74
+ catch {
75
+ return undefined;
76
+ }
77
+ }
78
+ /** The turn's record without the files Core undid: they are not part of the change, so they never count as progress. */
79
+ function withoutDiscarded(context, record, discarded) {
80
+ if (!discarded.length)
81
+ return record;
82
+ const undone = new Set(discarded.map(edit => edit.path));
83
+ const relative = (file) => {
84
+ let value = file.replace(/\\/g, '/').replace(/^\.\//, '');
85
+ for (const repository of context.repositories) {
86
+ const root = repository.path.replace(/\\/g, '/');
87
+ if (value.startsWith(root + '/')) {
88
+ value = value.slice(root.length + 1);
89
+ break;
90
+ }
91
+ }
92
+ return value;
93
+ };
94
+ const kept = (files) => files.filter(file => !undone.has(relative(file)));
95
+ return { ...record, files: kept(record.files), tests: kept(record.tests), discarded: discarded.map(edit => (context.repositories.length > 1 ? edit.repositoryId + ':' : '') + edit.path).slice(0, 200) };
96
+ }
66
97
  function architectNode(deps) {
67
98
  const { context, config, change, invoke, note } = deps;
68
99
  const accept = (output) => {
@@ -77,6 +108,8 @@ function architectNode(deps) {
77
108
  effect: 'write', ends: ['developer'],
78
109
  async run(state, step) {
79
110
  transitionPipeline(context, 'architect', 'running');
111
+ // The change is measured from here: record each repository's base before any role edits.
112
+ changeBases(context);
80
113
  // A resumed node collects the requester's answer first, so the pass that
81
114
  // asked the question is never repeated.
82
115
  const answers = [...state.answers];
@@ -164,7 +197,9 @@ async function implementationVisit(deps, state, step, mode) {
164
197
  // instructions.
165
198
  const fixer = fixing ? config.fixer : undefined;
166
199
  const provider = (fixer ?? config.agents.developer).provider;
167
- const full = roleInstructions('developer', context, change, { definition: fixing ? config.rolePrompts?.fixer : config.rolePrompts?.developer, ...(fixing ? { stance: 'fixer' } : {}), feedback, verification: state.plan }) + (config.efficiency?.acceptDeveloperChecks !== false ? '\nYou may propose verificationChecks in your JSON summary. These are additive required checks, never replacements for the baseline. Use kind command with structured command/args, or kind harness with entrypoint and 1–8 relative source files (64 KiB each, 256 KiB total). Core persists harnesses outside delivery, appends the absolute entrypoint to argv and supplies SPECRAILS_CHECK_REPO_ROOT. Stable keys revise your own checks; omission retains them. Do not claim a check passed until Core has executed it.' : '');
200
+ // What the run has changed so far, from git: a correction stays inside it.
201
+ const changes = state.development ? measuredChange(context, change) : undefined;
202
+ const full = roleInstructions('developer', context, change, { definition: fixing ? config.rolePrompts?.fixer : config.rolePrompts?.developer, ...(fixing ? { stance: 'fixer' } : {}), feedback, verification: state.plan, developer: state.development, ...(changes ? { changeSet: changes } : {}) }) + (config.efficiency?.acceptDeveloperChecks !== false ? '\nYou may propose verificationChecks in your JSON summary. These are additive required checks, never replacements for the baseline. Use kind command with structured command/args, or kind harness with entrypoint and 1–8 relative source files (64 KiB each, 256 KiB total). Core persists harnesses outside delivery, appends the absolute entrypoint to argv and supplies SPECRAILS_CHECK_REPO_ROOT. Stable keys revise your own checks; omission retains them. Do not claim a check passed until Core has executed it.' : '');
168
203
  const previous = state.development;
169
204
  // A CLI developer with a live session takes corrections and continuations
170
205
  // as a short follow-up in that session; a configured fixer engine always
@@ -173,11 +208,13 @@ async function implementationVisit(deps, state, step, mode) {
173
208
  const resumable = !fixer && previous?.sessionId !== undefined && previous.provider === provider && visits > 1;
174
209
  if (fixer)
175
210
  note('fixer', `Correction round on the fixer engine (${fixer.provider}/${fixer.model ?? 'provider default'}).`);
211
+ // Out-of-scope state before the turn: afterwards the host undoes exactly what the turn did outside the scope.
212
+ const guard = captureOutOfScope(context, change);
176
213
  const outcome = await invoke('developer', step, {
177
214
  kind: visits > 1 ? 'correction' : 'initial',
178
215
  ...(fixer ? { agentOverride: fixer } : {}), ...(fixing ? { stance: 'fixer' } : {}),
179
216
  ...(resumable
180
- ? { prompt: correctionInstructions('developer', feedback), resumeSessionId: previous.sessionId, fallbackPrompt: full }
217
+ ? { prompt: correctionInstructions('developer', feedback, { ...(changes ? { changeSet: changes } : {}), developer: state.development, focusedEvidence: deps.focusedCorrectionEvidence && config.efficiency?.contextMode !== 'full' }), resumeSessionId: previous.sessionId, fallbackPrompt: full }
181
218
  : { prompt: full }),
182
219
  structured: true, lenient: true, outputSchema: DEVELOPER_OUTPUT_SCHEMA,
183
220
  }, (output, text, result) => {
@@ -188,6 +225,19 @@ async function implementationVisit(deps, state, step, mode) {
188
225
  throw new Error('Developer verification proposals are disabled by the frozen policy');
189
226
  return { ...developerRecord(output, text, result, provider), ...(checks.length ? { verificationChecks: checks } : {}) };
190
227
  });
228
+ const who = fixing ? 'Fixer' : 'Developer';
229
+ let discarded;
230
+ try {
231
+ discarded = discardOutOfScopeEdits(context, guard);
232
+ }
233
+ catch (error) {
234
+ return { status: 'blocked', error: `${who} edited files outside the repository scope and Core could not undo them: ${error instanceof Error ? error.message : String(error)}. Restore those files in the worktree, then resume.` };
235
+ }
236
+ const multiple = context.repositories.length > 1;
237
+ if (discarded.length) {
238
+ const scopes = context.repositories.filter(repository => repository.scope?.length).map(repository => repository.scope.join(', ')).join('; ');
239
+ note(mode, `Undid ${plural(discarded.length, 'edit')} outside the repository scope (${scopes}): ${describeDiscarded(discarded, multiple)}. Only changes inside the scope are part of this change.`);
240
+ }
191
241
  // A timeout is a budget, not a defect: the groups already finished are
192
242
  // ticked in tasks.md and verified, and write_progress holds the handoff, so
193
243
  // the run BLOCKS (resume continues from the next open group, or raise
@@ -195,10 +245,9 @@ async function implementationVisit(deps, state, step, mode) {
195
245
  if (!outcome.ok)
196
246
  return settle(outcome, 'Finished task groups are ticked and verified; resume to continue from the next open group, or raise limits.timeoutMs.');
197
247
  new OpenSpecTools(deps.openspec.developer, step.signal).assertParticipation();
198
- const record = outcome.value;
248
+ const record = withoutDiscarded(context, outcome.value, discarded);
199
249
  const effective = addDeveloperChecks(context, record.verificationChecks ?? []);
200
250
  bindPlan(context, effective);
201
- const who = fixing ? 'Fixer' : 'Developer';
202
251
  note(mode, record.structured
203
252
  ? `${who} finished: ${plural(record.files.length, 'file')} changed, ${plural(record.tests.length, 'test file')} touched${record.incomplete.length ? `, ${plural(record.incomplete.length, 'task')} left incomplete` : ''}.`
204
253
  : `${who} finished without the structured summary; the prose summary is recorded instead.`);
@@ -237,8 +286,55 @@ function fixerNode(deps) {
237
286
  },
238
287
  };
239
288
  }
289
+ /** Where a missing dependency install belongs: the nearest directory with a lockfile or manifest above the failing command, else the repository root. */
290
+ const INSTALL_MARKERS = ['package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lock', 'bun.lockb', 'requirements.txt', 'pyproject.toml', 'go.mod', 'Cargo.toml'];
291
+ function installRoots(context, commands) {
292
+ const roots = [];
293
+ for (const command of commands) {
294
+ const repository = context.repositories.find(repo => repo.id === command.repositoryId);
295
+ if (!repository)
296
+ continue;
297
+ let directory = command.cwd;
298
+ let chosen = repository.path;
299
+ for (;;) {
300
+ const relative = path.relative(repository.path, directory);
301
+ if (relative.startsWith('..') || path.isAbsolute(relative))
302
+ break;
303
+ if (INSTALL_MARKERS.some(name => existsSync(path.join(directory, name)))) {
304
+ chosen = directory;
305
+ break;
306
+ }
307
+ if (!relative)
308
+ break;
309
+ directory = path.dirname(directory);
310
+ }
311
+ roots.push(chosen);
312
+ }
313
+ // Every repository root stays a candidate: its plan is a no-op when it is already installed.
314
+ return [...new Set([...roots, ...context.repositories.map(repository => repository.path)])];
315
+ }
316
+ /** A directory as the log should name it: relative to the checkout that contains it. */
317
+ function checkoutRelative(context, directory) {
318
+ const repository = context.repositories.find(repo => { const relative = path.relative(repo.path, directory); return !relative.startsWith('..') && !path.isAbsolute(relative); });
319
+ return repository ? path.relative(repository.path, directory).split(path.sep).join('/') || '.' : directory;
320
+ }
321
+ function hostPreconditionMessage(reason, command, args, directory) {
322
+ return `Verification cannot run in this environment: ${reason} (\`${[command, ...args].join(' ')}\` in ${directory}). This is not a defect in the change, so no correction round was started. Make it available to Specrails (for example in the login shell profile Specrails loads, or with a refreshed registry token), then resume.`;
323
+ }
324
+ /** A failed check the host must unblock (credentials, registry access, a missing variable): no correction round can repair it. */
325
+ function preconditionBlock(context, receipt) {
326
+ for (const command of receipt.commands) {
327
+ if (command.exitCode === 0)
328
+ continue;
329
+ const reason = hostPreconditionFailure(command.output);
330
+ if (reason)
331
+ return hostPreconditionMessage(reason, command.command, command.args, checkoutRelative(context, command.cwd));
332
+ }
333
+ return undefined;
334
+ }
240
335
  function verifyNode(deps) {
241
336
  const { context, note, config } = deps;
337
+ const roots = context.repositories.map(repository => repository.path);
242
338
  return {
243
339
  effect: 'write', ends: ['reviewer', 'developer', 'fixer'],
244
340
  async run(state, step) {
@@ -257,35 +353,85 @@ function verifyNode(deps) {
257
353
  throw new Error('Verification plan is unavailable');
258
354
  const plan = expandedPlanCommands(context, effective);
259
355
  const uncovered = uncoveredRepositories(context, plan);
260
- let receipt = await verifyPipeline(context, { kind: 'full', planHash: effective.planHash, commands: plan, ...(uncovered.length ? { unverified: true } : {}) }, deps.onVerificationOutput, step.signal, { ...(guardrailEnabled(config.guardrails, 'verify-idle-timeout') ? {} : { idleTimeoutMs: 0 }), onEvidence: async (kind, payload) => { await step.reportEfficiencyActivity?.(kind, payload); }, maxConcurrency: effective.executionPolicy.maxConcurrency, ...(step.remainingBudget().maxDurationMs === undefined ? {} : { deadline: Date.now() + step.remainingBudget().maxDurationMs }) });
356
+ // A correction round that left the failed candidate exactly as it was
357
+ // cannot pass the same checks: stop instead of re-running them.
358
+ const recent = outcomesSinceResume(state.verifyHistory ?? [], step.checkpoint);
359
+ const last = recent.at(-1);
360
+ const rejected = last?.passed === true && state.review !== null && !state.review.approved;
361
+ if (recent.some(entry => !entry.passed) || rejected) {
362
+ const candidate = fingerprintCandidate(journal(context));
363
+ const unchanged = unchangedFailure(recent, candidate, effective.planHash);
364
+ if (unchanged) {
365
+ note('fixer', 'The correction round did not change the candidate that failed verification; stopping instead of re-running the same checks.');
366
+ return { status: 'blocked', error: `Correction loop stopped: the last correction round left the change exactly as it was when verification failed (${unchanged.summary}). Re-running the same checks cannot pass. If the failure lies outside this change (a pre-existing failure, another package or the environment), fix it there and resume.`, usage: NO_SPEND };
367
+ }
368
+ // The reviewer rejected exactly this candidate and the correction round changed nothing: the
369
+ // fixer disputes the request. Its evidence still holds, so the reviewer decides again without
370
+ // re-running the same checks; a second rejection of the same change ends the dispute.
371
+ if (rejected && last.candidateHash === candidate && last.planHash === effective.planHash) {
372
+ const reviews = recent.filter(entry => entry.passed && entry.candidateHash === candidate && entry.planHash === effective.planHash).length;
373
+ if (reviews >= 2) {
374
+ note('fixer', 'The reviewer rejected the same unchanged change twice; stopping instead of another round.');
375
+ return { status: 'blocked', error: `Correction loop stopped: the reviewer rejected the same change twice and the correction rounds did not change it (${state.review.issues.slice(0, 3).join('; ').slice(0, 600) || state.review.summary.slice(0, 300)}). Decide whether the review request applies to this change, then resume.`, usage: NO_SPEND };
376
+ }
377
+ if (inspectPipeline(context).verification.valid && state.verifyResult) {
378
+ note('developer', 'The correction round did not change the verified change; reusing its verification evidence and returning to the reviewer.');
379
+ transitionPipeline(context, 'developer', 'done');
380
+ return { status: 'succeeded', next: 'reviewer', update: { verifyResult: state.verifyResult, verifyHistory: [{ ...last, at: new Date().toISOString() }] }, output: state.verifyResult, usage: NO_SPEND };
381
+ }
382
+ }
383
+ }
384
+ const run = () => verifyPipeline(context, { kind: 'full', planHash: effective.planHash, commands: plan, ...(uncovered.length ? { unverified: true } : {}) }, deps.onVerificationOutput, step.signal, { ...(guardrailEnabled(config.guardrails, 'verify-idle-timeout') ? {} : { idleTimeoutMs: 0 }), onEvidence: async (kind, payload) => { await step.reportEfficiencyActivity?.(kind, payload); }, maxConcurrency: effective.executionPolicy.maxConcurrency, ...(step.remainingBudget().maxDurationMs === undefined ? {} : { deadline: Date.now() + step.remainingBudget().maxDurationMs }) });
385
+ let receipt = await run();
386
+ const hostBlocked = (reason) => {
387
+ note('fixer', 'Verification failed on the environment, not on the change; stopping for the host instead of starting a correction round.');
388
+ return { status: 'blocked', error: reason, usage: NO_SPEND };
389
+ };
390
+ let precondition = receipt.valid ? undefined : preconditionBlock(context, receipt);
391
+ if (precondition)
392
+ return hostBlocked(precondition);
261
393
  // A failure that is really a missing toolchain or dependency (exit 127,
262
394
  // "command not found", "Cannot find module"…) is the HOST's to fix, not
263
395
  // the model's — for every developer: a fresh worktree has no
264
- // node_modules whatever wrote the code. Install once and re-verify; only
265
- // a second failure becomes developer feedback.
396
+ // node_modules whatever wrote the code. Install once where the failing
397
+ // command runs and re-verify; only a second failure becomes feedback.
266
398
  if (!receipt.valid && guardrailEnabled(config.guardrails, 'environment-repair') && receipt.commands.some(command => isEnvironmentFailure(command.exitCode, command.output))) {
267
- const installs = installEnvironment(context.repositories.map(repository => repository.path), { failureOutput: receipt.commands.map(command => command.output).join('\n'), lockfileRepair: guardrailEnabled(config.guardrails, 'lockfile-repair'), onEvent: event => note('developer', event.kind === 'text' ? event.text ?? '' : `[environment] ${event.tool ?? ''} ${event.detail ?? ''}`.trim()) });
399
+ const installs = installEnvironment(installRoots(context, receipt.commands.filter(command => command.exitCode !== 0)), { failureOutput: receipt.commands.map(command => command.output).join('\n'), lockfileRepair: guardrailEnabled(config.guardrails, 'lockfile-repair'), onEvent: event => note('developer', event.kind === 'text' ? event.text ?? '' : `[environment] ${event.tool ?? ''} ${event.detail ?? ''}`.trim()) });
400
+ const refused = installs.find(outcome => outcome.precondition);
401
+ if (refused)
402
+ return hostBlocked(hostPreconditionMessage(`installing its dependencies failed because ${refused.precondition}`, refused.command, refused.args, checkoutRelative(context, refused.root)));
268
403
  if (installs.some(outcome => outcome.ok)) {
269
404
  note('developer', 'Verification failed on the environment (missing dependencies or tools); the host installed them and is verifying again.');
270
- receipt = await verifyPipeline(context, { kind: 'full', planHash: effective.planHash, commands: plan, ...(uncovered.length ? { unverified: true } : {}) }, deps.onVerificationOutput, step.signal, { ...(guardrailEnabled(config.guardrails, 'verify-idle-timeout') ? {} : { idleTimeoutMs: 0 }), onEvidence: async (kind, payload) => { await step.reportEfficiencyActivity?.(kind, payload); }, maxConcurrency: effective.executionPolicy.maxConcurrency, ...(step.remainingBudget().maxDurationMs === undefined ? {} : { deadline: Date.now() + step.remainingBudget().maxDurationMs }) });
405
+ receipt = await run();
406
+ precondition = receipt.valid ? undefined : preconditionBlock(context, receipt);
407
+ if (precondition)
408
+ return hostBlocked(precondition);
271
409
  }
272
410
  }
273
411
  const evidence = {
274
412
  valid: receipt.valid, ...(receipt.reason ? { reason: receipt.reason } : {}), receiptId: receipt.id, unverifiedRepositories: uncovered,
275
413
  commands: receipt.commands.map(({ evidenceId, repositoryId, command, args, exitCode, output }) => ({ ...(evidenceId ? { evidenceId } : {}), repositoryId, command, args, exitCode, output: output.slice(-2000) })),
276
414
  };
277
- if (!receipt.valid) {
278
- note('fixer', `Verification failed (${receipt.reason ?? 'a command failed'}); handing the exact output to the fixer.`);
279
- return { status: 'succeeded', next: 'fixer', update: { verifyResult: evidence }, output: evidence, usage: NO_SPEND };
280
- }
415
+ const outcome = (passed, finding) => ({ at: new Date().toISOString(), candidateHash: receipt.candidateHash, planHash: effective.planHash, passed, ...(passed ? {} : describeFailure(receipt, roots, finding)) });
416
+ /** Hands a failure to the fixer unless the correction loop has stopped converging. */
417
+ const toFixer = (record, message, finding) => {
418
+ const failure = outcome(false, finding);
419
+ const loop = divergence(recent, failure);
420
+ if (loop) {
421
+ note('fixer', 'The correction loop is not converging; stopping instead of starting another round.');
422
+ return { status: 'blocked', error: `Correction loop stopped: ${loop} Inspect the evidence, fix what lies outside the change (or adjust the request), then resume.`, output: record, usage: NO_SPEND };
423
+ }
424
+ note('fixer', message);
425
+ return { status: 'succeeded', next: 'fixer', update: { verifyResult: record, verifyHistory: [failure] }, output: record, usage: NO_SPEND };
426
+ };
427
+ if (!receipt.valid)
428
+ return toFixer(evidence, `Verification failed (${receipt.reason ?? 'a command failed'}); handing the exact output to the fixer.`);
281
429
  // Exit 0 with "3 failed" in the output is a broken harness, not a pass.
282
430
  if (guardrailEnabled(config.guardrails, 'exit-code-honesty')) {
283
431
  const findings = receipt.commands.flatMap(command => { const found = exitCodeContradiction(command.exitCode, command.output); return found ? [{ ...found, command: [command.command, ...command.args].join(' ') }] : []; });
284
432
  if (findings.length) {
285
433
  const reason = exitHonestyReason(findings);
286
- note('fixer', `Verification exited 0 but reported failures (${findings.map(item => item.sample).join(' | ')}); handing it to the fixer as a failure.`);
287
- const dishonest = { ...evidence, valid: false, reason };
288
- return { status: 'succeeded', next: 'fixer', update: { verifyResult: dishonest }, output: dishonest, usage: NO_SPEND };
434
+ return toFixer({ ...evidence, valid: false, reason }, `Verification exited 0 but reported failures (${findings.map(item => item.sample).join(' | ')}); handing it to the fixer as a failure.`, reason);
289
435
  }
290
436
  }
291
437
  // Every command exited 0 — but did any of them RUN the tests the
@@ -299,14 +445,12 @@ function verifyNode(deps) {
299
445
  const unreached = context.repositories.flatMap(repository => unreachedTestFiles(repository.path, plan.filter(command => command.repositoryId === repository.id).map(({ command, args, cwd }) => ({ command, args, ...(cwd ? { cwd } : {}) })), reported).map(file => context.repositories.length > 1 ? `${repository.id}:${file}` : file));
300
446
  if (unreached.length) {
301
447
  const reason = unreachedTestsReason(unreached);
302
- note('fixer', `Verification incomplete: ${unreached.join(', ')} ${unreached.length === 1 ? 'is' : 'are'} not run by any verification command; handing it to the fixer.`);
303
- const incomplete = { ...evidence, valid: false, reason };
304
- return { status: 'succeeded', next: 'fixer', update: { verifyResult: incomplete }, output: incomplete, usage: NO_SPEND };
448
+ return toFixer({ ...evidence, valid: false, reason }, `Verification incomplete: ${unreached.join(', ')} ${unreached.length === 1 ? 'is' : 'are'} not run by any verification command; handing it to the fixer.`, reason);
305
449
  }
306
450
  }
307
451
  transitionPipeline(context, 'developer', 'done');
308
452
  note('developer', plan.length ? `Verification passed: ${plural(plan.length, 'command')} exited 0.` : 'No verification commands available; relying on task completion and review.');
309
- return { status: 'succeeded', next: 'reviewer', update: { verifyResult: evidence }, output: evidence, usage: NO_SPEND };
453
+ return { status: 'succeeded', next: 'reviewer', update: { verifyResult: evidence, verifyHistory: [outcome(true)] }, output: evidence, usage: NO_SPEND };
310
454
  },
311
455
  };
312
456
  }
@@ -365,9 +509,12 @@ function reviewerNode(deps) {
365
509
  : undefined;
366
510
  if (reReview)
367
511
  note('reviewer', `Re-review: ${plural(reReview.changes.length, 'file')} changed since the previous verdict; certifying the previous issues instead of re-reading the candidate.`);
368
- const prompt = roleInstructions('reviewer', context, change, { definition: config.rolePrompts?.reviewer, feedback: feedbackFor(state), verification: state.plan, policy, criteria, developer: state.development, ...(reReview ? { reReview } : {}) });
512
+ // The reviewer judges the change itself, measured from git — not the whole checkout.
513
+ const changes = measuredChange(context, change);
514
+ const prompt = roleInstructions('reviewer', context, change, { definition: config.rolePrompts?.reviewer, feedback: feedbackFor(state), verification: state.plan, policy, criteria, developer: state.development, ...(changes ? { changeSet: changes } : {}), ...(reReview ? { reReview } : {}) });
369
515
  const incremental = config.efficiency?.reviewMode !== 'full' && delta.mode === 'incremental' && state.review?.sessionId;
370
- const followup = correctionInstructions('reviewer', feedbackFor(state)) + '\nChanges since YOUR previous reviewed candidate:\n' + JSON.stringify(delta.changes)
516
+ const followup = correctionInstructions('reviewer', feedbackFor(state), { focusedEvidence: deps.focusedCorrectionEvidence && config.efficiency?.contextMode !== 'full' }) + '\nChanges since YOUR previous reviewed candidate:\n' + JSON.stringify(delta.changes)
517
+ + (changes ? '\nThe complete change under review (from git, against the run\'s base) — review these files only:\n' + changes.join('\n') : '')
371
518
  + '\nRecertify EVERY current acceptance criterion; previous met results are not current evidence:\n' + JSON.stringify(criteria)
372
519
  + '\nCurrent developer handoff:\n' + JSON.stringify(state.development);
373
520
  const outcome = await invoke('reviewer', step, { kind: state.review ? 'correction' : 'initial', prompt: incremental ? followup : prompt, ...(incremental ? { resumeSessionId: state.review.sessionId, fallbackPrompt: prompt } : {}), structured: true, outputSchema: REVIEW_OUTPUT_SCHEMA }, output => {
@@ -1,3 +1,4 @@
1
+ import { type RoleStatePort } from '../role-state.js';
1
2
  import { type InvocationKind } from '../role-routing.js';
2
3
  import { type PipelineContext } from '../../pipeline/pipeline-state.js';
3
4
  import type { ExecutorRegistry } from '../executors.js';
@@ -17,6 +18,9 @@ export interface InvokeOptions {
17
18
  /** Run this invocation on another configured engine (the fixer) with the matching stance; sessions never carry across engines. */
18
19
  agentOverride?: RuntimeAgentConfig;
19
20
  stance?: 'fixer';
21
+ /** Per-piece invocation bounds; explicit zero disables only that timer. */
22
+ timeoutMs?: number;
23
+ idleTimeoutMs?: number;
20
24
  }
21
25
  export type InvokeOutcome<T> = {
22
26
  ok: true;
@@ -36,6 +40,8 @@ export interface RoleInvokerDeps {
36
40
  config: RuntimeConfig;
37
41
  registry: ExecutorRegistry;
38
42
  openspec?: Record<AgentRole, import('../openspec.js').OpenSpecRoleContext>;
43
+ /** Engine hosts isolate sessions/routes by scope and node instead of a role-global file. */
44
+ roleState?: RoleStatePort;
39
45
  /** `fixer` labels the developer step's events during a correction round on the fixer stance, so a log reader sees who is acting. */
40
46
  onAgentEvent?: (role: AgentEventRole, event: AgentEvent) => void;
41
47
  }
@@ -1,8 +1,10 @@
1
+ import { resolveRoleDescriptor } from '../config.js';
2
+ import { isBuiltinRole } from '../executor-types.js';
1
3
  import { repositoryContextSnapshot, renderRepositoryContext } from '../repository-context.js';
2
4
  import { readRoleState, writeRoleState } from '../role-state.js';
3
5
  import { fingerprint } from '../durable-store.js';
4
6
  import { selectRoleRoute } from '../role-routing.js';
5
- import { ROLE_SKILLS, OPENSPEC_VERSION, OpenSpecTools, OpenSpecParticipationError, openSpecRepairPrompt } from '../openspec.js';
7
+ import { openSpecSkill, OPENSPEC_VERSION, OpenSpecTools, OpenSpecParticipationError, openSpecRepairPrompt } from '../openspec.js';
6
8
  import path from 'node:path';
7
9
  import { performance } from 'node:perf_hooks';
8
10
  import { frozenAcceptanceCriteria, pipelineStateDirectory } from '../../pipeline/pipeline-state.js';
@@ -48,31 +50,33 @@ export function createRoleInvoker(deps) {
48
50
  }
49
51
  catch { /* Observer cannot replay agent effects. */ } };
50
52
  const execute = async (role, step, prompt, structured, extra) => {
51
- const directory = deps.openspec?.[role].stateDirectory ?? path.join(pipelineStateDirectory(context), 'agent-workflow');
52
- const saved = readRoleState(directory);
53
+ const directory = deps.openspec?.[role]?.stateDirectory ?? path.join(pipelineStateDirectory(context), 'agent-workflow');
54
+ const saved = deps.roleState?.read() ?? readRoleState(directory);
55
+ const persist = () => deps.roleState ? deps.roleState.write(saved) : writeRoleState(directory, saved);
53
56
  const budget = step.remainingBudget();
54
57
  if (budget.maxTokens === 0 || budget.maxCostUsd === 0 || step.signal.aborted)
55
58
  throw new AgentExecutionError('No budget remains for another role invocation', step.signal.aborted ? 'aborted' : 'budget_exhausted', { inputTokens: 0, outputTokens: 0, costUsd: 0 });
56
59
  const kind = extra.kind ?? (extra.resumeSessionId ? 'correction' : 'initial');
57
- const route = selectRoleRoute(role, extra.agentOverride ?? config.agents[role], kind, step.checkpoint, extra.agentOverride ? undefined : saved.routes[role]);
60
+ const descriptor = resolveRoleDescriptor(config, role);
61
+ const route = selectRoleRoute(role, extra.agentOverride ?? (isBuiltinRole(role) ? config.agents[role] : descriptor), kind, step.checkpoint, extra.agentOverride ? undefined : saved.routes[role]);
58
62
  const selected = route.selection;
59
63
  const capabilities = await registry.capabilities(selected.provider, selected.model);
60
- const identity = fingerprint({ role, selected, transport: capabilities.transport, instructionsVersion: ROLE_INSTRUCTIONS_VERSION, provider: config.providers.find(provider => provider.id === selected.provider) ?? selected.provider, definition: (extra.agentOverride ? config.rolePrompts?.fixer : config.rolePrompts?.[role]) ?? null, openspec: deps.openspec?.[role] ?? null, context });
64
+ const identity = fingerprint({ role, selected, transport: capabilities.transport, instructionsVersion: ROLE_INSTRUCTIONS_VERSION, provider: config.providers.find(provider => provider.id === selected.provider) ?? selected.provider, definition: (extra.agentOverride ? config.rolePrompts?.fixer : config.rolePrompts?.[role] ?? descriptor.prompt) ?? null, ...(isBuiltinRole(role) ? {} : { policy: descriptor }), openspec: deps.openspec?.[role] ?? null, context });
61
65
  const previous = saved.sessions[role];
62
66
  const resumeSessionId = capabilities.continuation === 'supported' && previous?.identity === identity && previous.sessionId === extra.resumeSessionId ? extra.resumeSessionId : undefined;
63
67
  const snapshot = repositoryContextSnapshot(context);
64
- const incremental = Boolean(resumeSessionId && config.efficiency?.contextMode !== 'full');
68
+ const incremental = Boolean(resumeSessionId && capabilities.resumeRequiresFullContext !== true && config.efficiency?.contextMode !== 'full');
65
69
  const packet = renderRepositoryContext(snapshot, incremental ? previous?.context : undefined);
66
- const rolePrompt = !resumeSessionId && extra.fallbackPrompt ? extra.fallbackPrompt : prompt;
70
+ const rolePrompt = (!resumeSessionId || capabilities.resumeRequiresFullContext) && extra.fallbackPrompt ? extra.fallbackPrompt : prompt;
67
71
  const obligations = '\nCurrent frozen acceptance obligations (all remain required):\n' + JSON.stringify(frozenAcceptanceCriteria(context));
68
- const fullPrompt = rolePrompt + obligations + '\n\n' + packet;
72
+ const fullPrompt = rolePrompt + (step.operatorSteering ?? '') + obligations + '\n\n' + packet;
69
73
  if (extra.agentOverride)
70
74
  note(role, `Fixer route: ${selected.provider}/${selected.model ?? 'provider default'} — correction round on the fixer engine`);
71
75
  else if (saved.routes[role]?.tier !== route.tier)
72
76
  note(role, `Role route: ${selected.provider}/${selected.model ?? 'provider default'} — ${route.reason}`);
73
77
  if (!extra.agentOverride) {
74
78
  saved.routes[role] = { tier: route.tier, reason: route.reason, attemptId: step.attemptId };
75
- writeRoleState(directory, saved);
79
+ persist();
76
80
  }
77
81
  const measurement = { provider: selected.provider, ...(selected.model ? { model: selected.model } : {}),
78
82
  contextMode: incremental ? 'incremental' : 'full', promptBytes: Buffer.byteLength(fullPrompt), contextBytes: Buffer.byteLength(packet), handoffBytes: Buffer.byteLength(rolePrompt), requestedEffort: selected.effort ?? null,
@@ -85,11 +89,12 @@ export function createRoleInvoker(deps) {
85
89
  const onEvent = forward(extra.stance === 'fixer' ? 'fixer' : role, structured);
86
90
  try {
87
91
  if (deps.openspec?.[role])
88
- note(role, `OpenSpec ${OPENSPEC_VERSION}: ${ROLE_SKILLS[role]} (official skill document through scoped tools).`);
92
+ note(role, `OpenSpec ${OPENSPEC_VERSION}: ${openSpecSkill(deps.openspec[role])} (official skill document through scoped tools).`);
89
93
  const result = await registry.execute(selected.provider, {
90
- role, prompt: fullPrompt, openspec: deps.openspec?.[role] ? { ...deps.openspec[role], ...(role === 'architect' ? {} : { evidenceScope: { backlogRoot: context.backlogRoot, runId: context.runId } }) } : undefined, cwd: context.artifactRoot, allowedRoots: context.repositories.map(repo => repo.path),
94
+ role, access: descriptor.access, artifacts: descriptor.artifacts, instructions: 'role', prompt: fullPrompt, openspec: deps.openspec?.[role] ? { ...deps.openspec[role], ...(role === 'architect' ? {} : { evidenceScope: { backlogRoot: context.backlogRoot, runId: context.runId } }) } : undefined, cwd: context.artifactRoot, allowedRoots: context.repositories.map(repo => repo.path),
91
95
  model: selected.model, effort: selected.effort, ...(selected.thinking ? { thinking: selected.thinking } : {}), maxTurns: selected.maxTurns, signal: step.signal,
92
- timeoutMs: config.limits?.timeoutMs,
96
+ timeoutMs: extra.timeoutMs ?? config.limits?.timeoutMs,
97
+ idleTimeoutMs: extra.idleTimeoutMs ?? config.limits?.idleTimeoutMs,
93
98
  maxTokens: budget.maxTokens, maxCostUsd: budget.maxCostUsd,
94
99
  ...(config.guardrails ? { guardrails: config.guardrails } : {}),
95
100
  ...(extra.stance ? { stance: extra.stance } : {}),
@@ -103,7 +108,7 @@ export function createRoleInvoker(deps) {
103
108
  saved.sessions[role] = { identity, sessionId: result.sessionId, context: snapshot };
104
109
  else
105
110
  delete saved.sessions[role];
106
- writeRoleState(directory, saved);
111
+ persist();
107
112
  succeeded = true;
108
113
  return result;
109
114
  }
@@ -119,6 +124,7 @@ export function createRoleInvoker(deps) {
119
124
  }
120
125
  };
121
126
  return async (role, step, options, accept) => {
127
+ const timers = { timeoutMs: options.timeoutMs, idleTimeoutMs: options.idleTimeoutMs };
122
128
  const structured = options.structured === true;
123
129
  const workflow = deps.openspec?.[role] ? new OpenSpecTools(deps.openspec[role]) : undefined;
124
130
  const cursor = workflow?.participationCursor() ?? 0;
@@ -146,17 +152,17 @@ export function createRoleInvoker(deps) {
146
152
  // A correction pass continues the role's own session: the work it did and
147
153
  // the reasons behind it are already in context.
148
154
  try {
149
- result = await execute(role, step, options.prompt, structured, { kind: options.kind, resumeSessionId: options.resumeSessionId, outputSchema: options.outputSchema, fallbackPrompt: options.fallbackPrompt, agentOverride: options.agentOverride, stance: options.stance });
155
+ result = await execute(role, step, options.prompt, structured, { ...timers, kind: options.kind, resumeSessionId: options.resumeSessionId, outputSchema: options.outputSchema, fallbackPrompt: options.fallbackPrompt, agentOverride: options.agentOverride, stance: options.stance });
150
156
  }
151
157
  catch (error) {
152
158
  if (!(error instanceof AgentExecutionError) || !SESSION_FALLBACK_CODES.has(error.code))
153
159
  throw error;
154
160
  note(role, `Previous ${role} session is unavailable; starting a fresh ${role} turn with the same instructions.`);
155
- result = await execute(role, step, options.fallbackPrompt, structured, { kind: 'session-fallback', outputSchema: options.outputSchema, agentOverride: options.agentOverride, stance: options.stance });
161
+ result = await execute(role, step, options.fallbackPrompt, structured, { ...timers, kind: 'session-fallback', outputSchema: options.outputSchema, agentOverride: options.agentOverride, stance: options.stance });
156
162
  }
157
163
  }
158
164
  else {
159
- result = await execute(role, step, options.prompt, structured, { kind: options.kind, resumeSessionId: options.resumeSessionId, outputSchema: options.outputSchema, fallbackPrompt: options.fallbackPrompt, agentOverride: options.agentOverride, stance: options.stance });
165
+ result = await execute(role, step, options.prompt, structured, { ...timers, kind: options.kind, resumeSessionId: options.resumeSessionId, outputSchema: options.outputSchema, fallbackPrompt: options.fallbackPrompt, agentOverride: options.agentOverride, stance: options.stance });
160
166
  }
161
167
  let problem, omittedWorkflow = false;
162
168
  try {
@@ -175,7 +181,7 @@ export function createRoleInvoker(deps) {
175
181
  note(role, omittedWorkflow
176
182
  ? `The ${role} omitted its required OpenSpec workflow; requesting one correction in the same role before accepting the result.`
177
183
  : `The ${role} reply was not a valid result (${problem}); asking the same session to resend it.`);
178
- const repaired = await execute(role, step, result.sessionId ? repair : (options.fallbackPrompt ?? options.prompt) + '\n' + repair, structured, { kind: 'repair', resumeSessionId: result.sessionId, outputSchema: options.outputSchema,
184
+ const repaired = await execute(role, step, result.sessionId ? repair : (options.fallbackPrompt ?? options.prompt) + '\n' + repair, structured, { ...timers, kind: 'repair', resumeSessionId: result.sessionId, outputSchema: options.outputSchema,
179
185
  fallbackPrompt: (options.fallbackPrompt ?? options.prompt) + '\nPrevious response (bounded):\n' + result.text.slice(-32_000) + '\n' + repair,
180
186
  });
181
187
  try {
@@ -1,4 +1,5 @@
1
1
  import type { VerificationCommand } from '../../pipeline/pipeline-state.js';
2
+ import type { VerifyOutcome } from './convergence.js';
2
3
  export type DesignConfidence = 'high' | 'medium' | 'low';
3
4
  export interface ArchitectureRecord {
4
5
  planningDepth?: 'focused' | 'full';
@@ -28,6 +29,8 @@ export interface DeveloperRecord {
28
29
  }>;
29
30
  /** False when the provider returned prose instead of the structured summary. */
30
31
  structured: boolean;
32
+ /** Edits outside the repository scope that Core undid after this turn (repository-qualified when several repositories are in scope). */
33
+ discarded?: string[];
31
34
  }
32
35
  export interface VerificationRecord {
33
36
  valid: boolean;
@@ -76,6 +79,8 @@ export declare const CoreState: import("@langchain/langgraph").AnnotationRoot<{
76
79
  /** Autonomous investigation passes the architect already spent on a low-confidence design. */
77
80
  deepenPasses: import("@langchain/langgraph").BaseChannel<number, number | import("@langchain/langgraph").OverwriteValue<number>, unknown>;
78
81
  archived: import("@langchain/langgraph").BaseChannel<ArchiveRecord | null, ArchiveRecord | import("@langchain/langgraph").OverwriteValue<ArchiveRecord | null> | null, unknown>;
82
+ /** Recent verify outcomes (bounded), so the host can tell a converging correction loop from a circular one. */
83
+ verifyHistory: import("@langchain/langgraph").BaseChannel<VerifyOutcome[], VerifyOutcome[] | import("@langchain/langgraph").OverwriteValue<VerifyOutcome[]>, unknown>;
79
84
  }>;
80
85
  export type CoreStateType = typeof CoreState.State;
81
86
  export type CoreStateUpdate = typeof CoreState.Update;
@@ -18,6 +18,8 @@ export const CoreState = Annotation.Root({
18
18
  /** Autonomous investigation passes the architect already spent on a low-confidence design. */
19
19
  deepenPasses: Annotation({ ...replace(), default: () => 0 }),
20
20
  archived: Annotation({ ...replace(), default: () => null }),
21
+ /** Recent verify outcomes (bounded), so the host can tell a converging correction loop from a circular one. */
22
+ verifyHistory: Annotation({ reducer: (previous, next) => [...previous, ...next].slice(-50), default: () => [] }),
21
23
  });
22
24
  /** Graph order. `fixer` is the correction node: verify (failed, tasks ticked) and reviewer (rejected) route there; it hands back to verify. */
23
25
  export const CORE_NODE_ORDER = ['architect', 'developer', 'fixer', 'verify', 'reviewer', 'archive'];
@@ -1,22 +1,22 @@
1
1
  import { toolEvent } from './tool-event.js';
2
2
  import { normalizeKimiCliModel } from './kimi-model.js';
3
- import { AgentExecutionError, unknownUsage, validateAgentRequest } from './executor-types.js';
3
+ import { AgentExecutionError, unknownUsage, normalizeAgentRequest } from './executor-types.js';
4
4
  import { runCliProcess } from './cli-process.js';
5
5
  import { parseStructuredText } from './openai-executor.js';
6
6
  import { WorkspaceTools } from './workspace-tools.js';
7
7
  function record(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; }
8
8
  /** Native ACP v1 keeps Kimi 0.27 read-only roles usable without a platform skill or a model call during capability negotiation. */
9
9
  export async function executeKimiReadonlyAcp(request, options = {}) {
10
- validateAgentRequest(request);
11
- if (request.role === 'developer' && !options.openspecBridge)
10
+ request = normalizeAgentRequest(request);
11
+ if (request.access === 'write' && !options.openspecBridge)
12
12
  throw new AgentExecutionError('Read-only ACP transport cannot execute developer roles', 'invalid_request');
13
13
  if (request.maxCostUsd !== undefined)
14
14
  throw new AgentExecutionError('Kimi cannot enforce a strict USD cap', 'cost_limit_unsupported');
15
15
  if (request.maxTokens !== undefined)
16
16
  throw new AgentExecutionError('Kimi 0.27 ACP does not report authoritative token usage. Remove the token cap or choose another provider for this role.', 'usage_unavailable');
17
- const readOnly = request.role !== 'developer';
17
+ const readOnly = request.access === 'read';
18
18
  const mode = readOnly ? 'plan' : 'auto';
19
- const tools = new WorkspaceTools(request.cwd, request.allowedRoots, request.role);
19
+ const tools = new WorkspaceTools(request.cwd, request.allowedRoots, request.access);
20
20
  let transport, nextId = 0, sessionId, text = '', completed = false, turns = 0, permissionDenied = false;
21
21
  const calls = new Map();
22
22
  const send = (method, params, success) => {
@@ -157,7 +157,7 @@ export async function executeKimiReadonlyAcp(request, options = {}) {
157
157
  call.success(record(message.result));
158
158
  };
159
159
  const result = await (options.runProcess ?? runCliProcess)({ command: 'kimi', args: ['acp'] }, {
160
- cwd: tools.cwd, signal: request.signal, timeoutMs: request.timeoutMs ?? 15 * 60_000, env: options.env, onLine: onMessage,
160
+ cwd: tools.cwd, signal: request.signal, timeoutMs: request.timeoutMs ?? 15 * 60_000, idleTimeoutMs: request.idleTimeoutMs, env: options.env, onLine: onMessage,
161
161
  duplex: control => {
162
162
  transport = control;
163
163
  send('initialize', { protocolVersion: 1, clientInfo: { name: 'specrails-core', version: '1' }, clientCapabilities: { fs: { readTextFile: readOnly, writeTextFile: readOnly }, terminal: readOnly } }, initialized => {
@@ -1,7 +1,7 @@
1
1
  import { readVerificationEvidence } from '../pipeline/pipeline-state.js';
2
2
  import { assertEffortSupported, unknownCapabilities } from './capabilities.js';
3
3
  import { OpenSpecTools, OPENSPEC_TOOL_DEFINITION, openSpecPrompt } from './openspec.js';
4
- import { AgentExecutionError, unknownUsage, validateAgentRequest } from './executor-types.js';
4
+ import { AgentExecutionError, unknownUsage, normalizeAgentRequest, isBuiltinRole } from './executor-types.js';
5
5
  import { WorkspaceTools } from './workspace-tools.js';
6
6
  import { ChatClient, record } from './compact/chat-client.js';
7
7
  import { DEFAULT_CONTEXT_WINDOW_TOKENS, runToolLoop } from './compact/guarded-loop.js';
@@ -40,13 +40,15 @@ export class OpenAICompatibleExecutor {
40
40
  throw new AgentExecutionError('A strict USD cap requires an executor with a native spending limit; OpenAI-compatible endpoints do not provide one. Use a token cap or an executor with native cost enforcement.', 'cost_limit_unsupported');
41
41
  }
42
42
  async execute(request) {
43
- validateAgentRequest(request);
43
+ request = normalizeAgentRequest(request);
44
+ if (request.nativeCommand)
45
+ throw new AgentExecutionError('OpenAI-compatible providers do not support native commands', 'native_command_unsupported');
44
46
  assertEffortSupported(request, this.capabilities());
45
47
  this.validateLimits(request);
46
48
  if (!request.model?.trim())
47
49
  throw new AgentExecutionError('OpenAI-compatible execution requires a model', 'invalid_model');
48
50
  const controller = new AbortController();
49
- const toolset = new WorkspaceTools(request.cwd, request.allowedRoots, request.role);
51
+ const toolset = new WorkspaceTools(request.cwd, request.allowedRoots, request.access);
50
52
  const openspec = request.openspec ? new OpenSpecTools(request.openspec, controller.signal) : undefined;
51
53
  const evidenceScope = request.openspec?.evidenceScope && request.role !== 'architect' ? request.openspec.evidenceScope : undefined;
52
54
  const definitions = [...toolset.definitions(), ...(openspec ? [OPENSPEC_TOOL_DEFINITION] : []), ...(evidenceScope ? [{ type: 'function', function: { name: 'read_verification_evidence', description: 'Read host verification evidence and source files with opaque IDs and bounded cursors. List to discover IDs.', parameters: { type: 'object', additionalProperties: false, properties: { id: { type: 'string' }, section: { type: 'string', enum: ['summary', 'stdout', 'stderr', 'source'] }, sourceId: { type: 'string' }, cursor: { type: 'string' }, limit: { type: 'integer', minimum: 1, maximum: 100 } } } } }] : [])];
@@ -56,11 +58,13 @@ export class OpenAICompatibleExecutor {
56
58
  // The compact pipelines are several bounded model calls per role (the
57
59
  // architect alone is ~8); a role budget sized for one agentic call would
58
60
  // cut them off. Scale the DEFAULT only — an explicit timeout is respected.
59
- const compact = resolveAgentLoop(this.provider) === 'compact' && request.openspec !== undefined;
61
+ const compact = isBuiltinRole(request.role) && request.instructions === 'role' && resolveAgentLoop(this.provider) === 'compact' && request.openspec !== undefined;
60
62
  const timeoutMs = request.timeoutMs ?? this.options.defaultTimeoutMs ?? (compact ? 45 * 60_000 : 15 * 60_000);
61
- const idleTimeoutMs = this.options.idleTimeoutMs ?? IDLE_TIMEOUT_MS;
62
- if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1)
63
- throw new AgentExecutionError('timeoutMs must be a positive integer', 'invalid_limit');
63
+ const idleTimeoutMs = request.idleTimeoutMs ?? this.options.idleTimeoutMs ?? IDLE_TIMEOUT_MS;
64
+ for (const value of [timeoutMs, idleTimeoutMs]) {
65
+ if (!Number.isSafeInteger(value) || value < 0 || value > 2_147_483_647)
66
+ throw new AgentExecutionError('Timeouts must be nonnegative timer integers', 'invalid_limit');
67
+ }
64
68
  const env = this.options.env ?? process.env;
65
69
  const key = this.provider.apiKeyEnv ? env[this.provider.apiKeyEnv] : undefined;
66
70
  if (this.provider.apiKeyEnv && !key)
@@ -81,10 +85,11 @@ export class OpenAICompatibleExecutor {
81
85
  // group role while it is still producing (observed: groups 1–2 verified
82
86
  // green, group 3 cut mid-patch, whole run failed). A genuine hang is the
83
87
  // idle watchdog's job: no tool call, reply or usage for IDLE_TIMEOUT_MS.
84
- let timer = setTimeout(() => controller.abort(new Error('Agent timeout')), timeoutMs);
85
- const resetDeadline = () => { clearTimeout(timer); timer = setTimeout(() => controller.abort(new Error('Agent timeout')), timeoutMs); };
88
+ let timer;
89
+ const resetDeadline = () => { clearTimeout(timer); timer = timeoutMs > 0 ? setTimeout(() => controller.abort(new Error('Agent timeout')), timeoutMs) : undefined; };
90
+ resetDeadline();
86
91
  let lastActivity = Date.now();
87
- const idle = compact ? setInterval(() => { if (Date.now() - lastActivity > idleTimeoutMs)
92
+ const idle = (compact || request.idleTimeoutMs !== undefined) && idleTimeoutMs > 0 ? setInterval(() => { if (Date.now() - lastActivity > idleTimeoutMs)
88
93
  controller.abort(new Error('Agent idle timeout')); }, Math.min(15_000, Math.max(50, Math.floor(idleTimeoutMs / 4)))) : undefined;
89
94
  idle?.unref?.();
90
95
  const onEvent = request.onEvent || idle ? (event) => { lastActivity = Date.now(); request.onEvent?.(event); } : undefined;
@@ -95,13 +100,13 @@ export class OpenAICompatibleExecutor {
95
100
  try {
96
101
  // Compact pipelines need the official workflow to drive; a plain role task
97
102
  // without an OpenSpec binding runs the guarded loop instead.
98
- if (resolveAgentLoop(this.provider) === 'compact' && openspec) {
103
+ if (compact && openspec) {
99
104
  compactMode = true;
100
105
  const compact = { client, request, inputs: extractPromptInputs(request.prompt), toolset, openspec, contextWindowTokens, ...(maxOutputTokens === undefined ? {} : { maxOutputTokens }), resetDeadline, signal: controller.signal, onEvent, guardrails: request.guardrails, stance: request.stance };
101
106
  return request.role === 'architect' ? await runCompactArchitect(compact) : request.role === 'developer' ? await runCompactDeveloper(compact) : await runCompactReviewer(compact);
102
107
  }
103
108
  const messages = [
104
- { role: 'system', content: `You execute one ${request.role} task. Use only the provided workspace tools. Allowed roots: ${JSON.stringify(toolset.roots)}. Working directory: ${toolset.cwd}. Return the complete requested final result. Do not coordinate another workflow. Execute only the assigned role and its supplied OpenSpec workflow.` },
109
+ ...(request.instructions === 'none' ? [] : [{ role: 'system', content: `You execute one ${request.role} task. Use only the provided workspace tools. Allowed roots: ${JSON.stringify(toolset.roots)}. Working directory: ${toolset.cwd}. Return the complete requested final result. Do not coordinate another workflow. Execute only the assigned role and its supplied OpenSpec workflow.` }]),
105
110
  { role: 'user', content: (request.openspec ? openSpecPrompt(request.openspec) : '') + request.prompt },
106
111
  ];
107
112
  const { text } = await runToolLoop({