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
@@ -10,6 +10,13 @@ import { ExecutorRegistry } from './executors.js';
10
10
  import { OpenSpecTools } from './openspec.js';
11
11
  import { validatePipelineContext } from '../pipeline/pipeline-state.js';
12
12
  import { runtimeEfficiency } from './efficiency.js';
13
+ import { validateWorkflowDefinition } from './engine/definition-validator.js';
14
+ import { validationPieceRegistry } from './engine/pieces/index.js';
15
+ import { configuredRoles } from './engine/preflight.js';
16
+ import { createRun, definitionRunDirectory } from './engine/runs.js';
17
+ import { observeLedger } from './engine/run-status.js';
18
+ import { RunDatabase } from './engine/checkpoint/database.js';
19
+ import { ledgerWorkflowState } from './engine/invocation-context.js';
13
20
  function oracle(test, file) {
14
21
  return spawnSync(process.execPath, ['-e', 'const assert = require("node:assert/strict"); const api = require(process.argv[1]);' + test.oracle, file], { encoding: 'utf8', timeout: 10000 }).status === 0;
15
22
  }
@@ -26,12 +33,13 @@ function prepare(test, root) {
26
33
  });
27
34
  return validatePipelineContext({ schemaVersion: 1, runId: randomUUID(), backlogRoot: root, artifactRoot: repositories[0].path, artifactRepositoryId: repositories[0].id, repositories, ownership: { git: 'host', backlog: 'host', worktrees: 'host' }, specs: [{ id: 1, title: test.id, description: test.description + ' Implement behavior in implementation.cjs in every selected repository.', repositoryIds: test.repositories, acceptanceCriteria: [test.description] }] });
28
35
  }
29
- function fixtureRegistry(test, context) {
36
+ function fixtureRegistry(test, context, onPrompt) {
30
37
  let developers = 0, reviews = 0;
31
38
  const sessions = new Map();
32
39
  return new ExecutorRegistry().register('fixture', {
33
40
  capabilities: () => ({ transport: 'offline-fixture', continuation: 'supported', effortSupport: 'unsupported', supportedEfforts: [], observedModel: false, observedEffort: false }),
34
41
  async execute(request) {
42
+ onPrompt?.(request);
35
43
  const sessionId = 'fixture-' + request.role;
36
44
  if (request.resumeSessionId && !sessions.has(request.resumeSessionId))
37
45
  throw new Error('Fixture session was not restored');
@@ -57,11 +65,11 @@ function fixtureRegistry(test, context) {
57
65
  }
58
66
  else {
59
67
  await tools.execute({ action: 'instructions', artifact: 'apply' });
60
- if (request.role === 'developer') {
68
+ if (request.role === 'developer' || request.role === 'fixer') {
61
69
  developers++;
62
70
  for (const repo of context.repositories)
63
71
  writeFileSync(path.join(repo.path, 'implementation.cjs'), test.correction && developers === 1 ? test.defects[0] : test.solution);
64
- writeFileSync(path.join(context.artifactRoot, 'openspec/changes/evaluation-change/tasks.md'), '## 1. Implementation\n- [x] 1.1 Implement and test the requested behavior\n');
72
+ await tools.execute({ action: 'write_artifact', path: 'tasks.md', content: '## 1. Implementation\n- [x] 1.1 Implement and test the requested behavior\n' });
65
73
  output = { summary: 'Implemented fixture behavior', files: ['implementation.cjs'], tests: [], verification: 'Core executes the frozen checks', incomplete: [] };
66
74
  }
67
75
  else {
@@ -75,18 +83,23 @@ function fixtureRegistry(test, context) {
75
83
  });
76
84
  }
77
85
  export async function runEvaluation(options) {
86
+ if (options.capturePrompts && options.real)
87
+ throw new Error('Prompt capture is limited to offline fixture evaluation');
78
88
  const repetitions = options.repetitions ?? 1;
79
89
  if (!Number.isSafeInteger(repetitions) || repetitions < 1 || repetitions > 20)
80
90
  throw new Error('Evaluation repetitions must be 1–20');
81
91
  if (options.real && (!options.config || !Number.isFinite(options.maxCostUsd) || options.maxCostUsd <= 0 || Object.values(options.config.agents).some(role => !role.model)))
82
92
  throw new Error('Real evaluation requires explicit per-role models and a positive aggregate spend limit');
93
+ const corpus = options.caseIds ? EVALUATION_CORPUS.filter(item => options.caseIds.includes(item.id)) : EVALUATION_CORPUS;
94
+ if (!corpus.length || options.caseIds?.some(id => !corpus.some(item => item.id === id)))
95
+ throw new Error('Unknown or empty evaluation case selection');
83
96
  const identity = coreRuntimeIdentity();
84
97
  const observations = [];
85
- let spend = 0, stopReason = null;
98
+ let spend = 0, stopReason = null, evaluatedDefinitionVersion = null;
86
99
  const root = mkdtempSync(path.join(tmpdir(), 'specrails-evaluation-'));
87
100
  try {
88
101
  for (let repeat = 0; repeat < repetitions && !stopReason; repeat++)
89
- for (const test of EVALUATION_CORPUS) {
102
+ for (const test of corpus) {
90
103
  // Check the independent oracle itself against defective candidates.
91
104
  const probe = path.join(root, 'oracle-probe.cjs');
92
105
  writeFileSync(probe, test.solution);
@@ -113,7 +126,33 @@ export async function runEvaluation(options) {
113
126
  const started = Date.now();
114
127
  let state;
115
128
  try {
116
- state = await runCoreWorkflow({ context, change: 'evaluation-change', config, ...(options.real ? {} : { registry: fixtureRegistry(test, context) }) });
129
+ let promptNumber = 0;
130
+ const capture = options.capturePrompts ? (request) => {
131
+ const directory = path.join(options.output, 'prompts', test.id + '-' + repeat + '-' + mode);
132
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
133
+ const filename = String(++promptNumber).padStart(2, '0') + '-' + request.role + (request.stance ? '-' + request.stance : '');
134
+ writeFileSync(path.join(directory, filename + '.txt'), request.prompt, { mode: 0o600 });
135
+ writeFileSync(path.join(directory, filename + '.json'), JSON.stringify({ role: request.role, stance: request.stance ?? null,
136
+ model: request.model ?? null, resumeSessionId: request.resumeSessionId ?? null, promptBytes: Buffer.byteLength(request.prompt),
137
+ sections: request.prompt.split(/(?=^#{1,4} )/m).map(section => ({ heading: section.split('\n')[0].slice(0, 150), bytes: Buffer.byteLength(section) })) }, null, 2) + '\n', { mode: 0o600 });
138
+ } : undefined;
139
+ const registry = options.real ? undefined : fixtureRegistry(test, context, capture);
140
+ if (options.definition === undefined)
141
+ state = await runCoreWorkflow({ context, change: 'evaluation-change', config, ...(registry ? { registry } : {}) });
142
+ else {
143
+ const validation = validateWorkflowDefinition(options.definition, validationPieceRegistry(), configuredRoles(config), { published: true });
144
+ if (!validation.ok)
145
+ throw new Error('Evaluation definition failed validation: ' + JSON.stringify(validation.errors));
146
+ evaluatedDefinitionVersion = validation.version;
147
+ await createRun({ context, change: 'evaluation-change', config, definition: validation.definition, ...(registry ? { registry } : {}) });
148
+ const database = await RunDatabase.open(path.join(definitionRunDirectory(context), 'run.sqlite'), { readOnly: true });
149
+ try {
150
+ state = ledgerWorkflowState(observeLedger(database), validation.definition, '*');
151
+ }
152
+ finally {
153
+ database.close();
154
+ }
155
+ }
117
156
  }
118
157
  catch (error) {
119
158
  observations.push({ caseId: test.id, repeat, mode, status: 'blocked', independentAccepted: false, error: error instanceof Error ? error.message : String(error), runtimeIdentity: identity, taskHash: fingerprint(test), oracleHash: fingerprint(test.oracle), repositories: context.repositories.map(({ id, baseSha }) => ({ id, baseSha })), configHash: fingerprint(config) });
@@ -145,7 +184,7 @@ export async function runEvaluation(options) {
145
184
  return [mode, { samples: rows.length, independentlyAccepted: accepted, costPerAcceptedUsd: options.real && knownCost !== null && accepted ? knownCost / accepted : null, medianActiveDurationMs: median(durations), minActiveDurationMs: durations.length ? Math.min(...durations) : null, maxActiveDurationMs: durations.length ? Math.max(...durations) : null, standardDeviationMs: average === null ? null : Math.sqrt(durations.reduce((sum, value) => sum + (value - average) ** 2, 0) / durations.length) }];
146
185
  }));
147
186
  const full = groups.full, optimized = groups.optimized;
148
- const paired = observations.length === EVALUATION_CORPUS.length * repetitions * 2 && full.samples === optimized.samples;
187
+ const paired = observations.length === corpus.length * repetitions * 2 && full.samples === optimized.samples;
149
188
  const noObservedQualityDrop = paired && optimized.independentlyAccepted >= full.independentlyAccepted;
150
189
  const promptPairs = observations.filter(row => row.mode === 'full' && row.caseId === 'verification-correction').map(row => {
151
190
  const other = observations.find(item => item.mode === 'optimized' && item.caseId === row.caseId && item.repeat === row.repeat);
@@ -158,7 +197,10 @@ export async function runEvaluation(options) {
158
197
  return Array.isArray(row.invocations) && Array.isArray(other?.invocations) && other.invocations.length <= row.invocations.length;
159
198
  });
160
199
  const monetaryConclusion = options.real && paired && full.costPerAcceptedUsd !== null && optimized.costPerAcceptedUsd !== null && full.costPerAcceptedUsd > 0 ? optimized.costPerAcceptedUsd <= full.costPerAcceptedUsd * 0.8 && noObservedQualityDrop ? 'target-met-in-this-sample' : 'target-not-met-in-this-sample' : 'inconclusive';
161
- const report = { schemaVersion: 1, experiment: Object.values(options.config?.agents ?? {}).some(role => role.escalation) ? 'routing' : 'same-model', noExtraInvocations, mode: options.real ? 'real' : 'offline', runtimeIdentity: identity, corpusHash: fingerprint(EVALUATION_CORPUS), measurement: options.real ? 'reported-provider-usage' : 'synthetic-fixture-usage; no actual AI savings measured', monetaryTarget: 0.2, monetaryConclusion, groups, noObservedQualityDrop, promptPairs, correctionPromptTargetMet: promptPairs.length > 0 && promptPairs.every(pair => pair.reduction !== null && pair.reduction >= 0.4), sampleLimitations: 'Small descriptive paired sample; does not establish a universal quality or savings guarantee.', stopReason, reportedSpendUsd: options.real ? spend : 0, observations };
200
+ const report = { schemaVersion: 1, isDefinitionEvaluation: options.definition !== undefined,
201
+ ...(options.definition === undefined ? {} : { definitionHash: evaluatedDefinitionVersion }),
202
+ acceptedCases: observations.filter(row => row.independentAccepted).length,
203
+ allCasesAccepted: paired && observations.every(row => row.independentAccepted), experiment: Object.values(options.config?.agents ?? {}).some(role => role.escalation) ? 'routing' : 'same-model', noExtraInvocations, mode: options.real ? 'real' : 'offline', runtimeIdentity: identity, corpusHash: fingerprint(corpus), measurement: options.real ? 'reported-provider-usage' : 'synthetic-fixture-usage; no actual AI savings measured', monetaryTarget: 0.2, monetaryConclusion, groups, noObservedQualityDrop, promptPairs, correctionPromptTargetMet: promptPairs.length > 0 && promptPairs.every(pair => pair.reduction !== null && pair.reduction >= 0.4), sampleLimitations: 'Small descriptive paired sample; does not establish a universal quality or savings guarantee.', stopReason, reportedSpendUsd: options.real ? spend : 0, observations };
162
204
  mkdirSync(options.output, { recursive: true });
163
205
  writeFileSync(path.join(options.output, 'evaluation.json'), JSON.stringify(report, null, 2) + '\n');
164
206
  writeFileSync(path.join(options.output, 'evaluation.md'), `# Implementation efficiency evaluation\n\nMode: ${report.mode}. ${report.measurement}.\n\nMonetary target: 20% lower aggregate cost per independently accepted output. Conclusion: ${monetaryConclusion}.\n\n${JSON.stringify(groups, null, 2)}\n\nCorrection prompt comparison: ${JSON.stringify(promptPairs)}\n\n${observations.map(row => `- ${row.caseId}, ${row.mode}: ${row.status}; independent acceptance ${row.independentAccepted}; ${row.activeDurationMs} ms`).join('\n')}\n\n${stopReason ?? (options.real ? report.sampleLimitations : 'No paid benchmark has established monetary savings.')}\n`);
@@ -3,7 +3,31 @@ import type { CacheTokenUsage } from './efficiency-types.js';
3
3
  import type { GuardrailSettings } from './guardrails.js';
4
4
  export declare const DEFAULT_AGENT_TIMEOUT_MS: number;
5
5
  export declare const DEFAULT_AGENT_IDLE_TIMEOUT_MS: number;
6
- export type AgentRole = 'architect' | 'developer' | 'reviewer';
6
+ export declare const BUILTIN_ROLES: readonly ["architect", "developer", "reviewer"];
7
+ export type BuiltinAgentRole = typeof BUILTIN_ROLES[number];
8
+ export type AgentRole = string;
9
+ export type AgentAccess = 'read' | 'write';
10
+ export type ArtifactAccess = 'none' | 'tasks-checkboxes' | 'all';
11
+ export type OpenSpecSkill = 'openspec-ff-change' | 'openspec-apply-change' | 'openspec-verify-change';
12
+ export declare const ROLE_ID: RegExp;
13
+ export declare function isBuiltinRole(role: string): role is BuiltinAgentRole;
14
+ export declare const BUILTIN_ROLE_POLICY: {
15
+ readonly architect: {
16
+ readonly access: "read";
17
+ readonly artifacts: "all";
18
+ readonly openspecSkill: "openspec-ff-change";
19
+ };
20
+ readonly developer: {
21
+ readonly access: "write";
22
+ readonly artifacts: "tasks-checkboxes";
23
+ readonly openspecSkill: "openspec-apply-change";
24
+ };
25
+ readonly reviewer: {
26
+ readonly access: "read";
27
+ readonly artifacts: "none";
28
+ readonly openspecSkill: "openspec-verify-change";
29
+ };
30
+ };
7
31
  /** Who an observer sees acting: a pipeline role, or the FIXER stance the developer step takes on a correction round. */
8
32
  export type AgentEventRole = AgentRole | 'fixer';
9
33
  export type CliProvider = 'claude' | 'codex' | 'gemini' | 'kimi';
@@ -37,6 +61,15 @@ export interface RuntimeAgentConfig {
37
61
  effort?: string;
38
62
  };
39
63
  }
64
+ export interface RuntimeRoleConfig extends RuntimeAgentConfig {
65
+ access: AgentAccess;
66
+ artifacts: ArtifactAccess;
67
+ prompt?: string;
68
+ openspecSkill?: OpenSpecSkill;
69
+ }
70
+ export interface RoleDescriptor extends RuntimeRoleConfig {
71
+ id: AgentRole;
72
+ }
40
73
  export interface EfficiencyPolicy {
41
74
  schemaVersion: 1;
42
75
  contextMode?: 'full' | 'incremental';
@@ -64,7 +97,8 @@ export interface RuntimeConfig {
64
97
  efficiency?: EfficiencyPolicy;
65
98
  enabled: boolean;
66
99
  providers: RuntimeProviderConfig[];
67
- agents: Record<AgentRole, RuntimeAgentConfig>;
100
+ agents: Record<BuiltinAgentRole, RuntimeAgentConfig>;
101
+ roles?: Record<AgentRole, RuntimeRoleConfig>;
68
102
  /** Optional engine for correction rounds (after a failed verification or a rejected review); unset ⇒ the developer corrects. */
69
103
  fixer?: RuntimeAgentConfig;
70
104
  limits?: {
@@ -110,6 +144,14 @@ export interface AgentEvent {
110
144
  export interface AgentRequest {
111
145
  openspec?: import('./openspec.js').OpenSpecRoleContext;
112
146
  role: AgentRole;
147
+ /** Optional only for backward-compatible built-in requests. Custom roles require explicit policy. */
148
+ access?: AgentAccess;
149
+ artifacts?: ArtifactAccess;
150
+ instructions?: 'role' | 'none';
151
+ nativeCommand?: {
152
+ id: string;
153
+ args?: string;
154
+ };
113
155
  prompt: string;
114
156
  cwd: string;
115
157
  allowedRoots: string[];
@@ -118,8 +160,9 @@ export interface AgentRequest {
118
160
  /** Local engines: private thinking on/off for this invocation (see RuntimeAgentConfig.thinking). */
119
161
  thinking?: 'on' | 'off';
120
162
  maxTurns?: number;
163
+ /** Total invocation limit; zero disables this timer without disabling cancellation or run budgets. */
121
164
  timeoutMs?: number;
122
- /** Maximum silence from the provider; independent of the total invocation limit. */
165
+ /** Maximum silence from the provider; zero disables the idle timer. */
123
166
  idleTimeoutMs?: number;
124
167
  maxTokens?: number;
125
168
  maxCostUsd?: number;
@@ -157,3 +200,10 @@ export declare class AgentExecutionError extends Error {
157
200
  }
158
201
  export declare function unknownUsage(): AgentUsage;
159
202
  export declare function validateAgentRequest(request: AgentRequest): void;
203
+ /** Resolve compatible defaults without changing the caller's frozen request. */
204
+ export declare function requestPolicy(request: Pick<AgentRequest, 'role' | 'access' | 'artifacts' | 'instructions'>): {
205
+ access: AgentAccess;
206
+ artifacts: ArtifactAccess;
207
+ instructions: 'role' | 'none';
208
+ };
209
+ export declare function normalizeAgentRequest(request: AgentRequest): AgentRequest & ReturnType<typeof requestPolicy>;
@@ -1,5 +1,13 @@
1
1
  export const DEFAULT_AGENT_TIMEOUT_MS = 60 * 60_000;
2
2
  export const DEFAULT_AGENT_IDLE_TIMEOUT_MS = 15 * 60_000;
3
+ export const BUILTIN_ROLES = ['architect', 'developer', 'reviewer'];
4
+ export const ROLE_ID = /^[a-z][a-z0-9-]{0,63}$/;
5
+ export function isBuiltinRole(role) { return BUILTIN_ROLES.includes(role); }
6
+ export const BUILTIN_ROLE_POLICY = {
7
+ architect: { access: 'read', artifacts: 'all', openspecSkill: 'openspec-ff-change' },
8
+ developer: { access: 'write', artifacts: 'tasks-checkboxes', openspecSkill: 'openspec-apply-change' },
9
+ reviewer: { access: 'read', artifacts: 'none', openspecSkill: 'openspec-verify-change' },
10
+ };
3
11
  export class AgentExecutionError extends Error {
4
12
  code;
5
13
  usage;
@@ -15,13 +23,21 @@ export class AgentExecutionError extends Error {
15
23
  }
16
24
  export function unknownUsage() { return { inputTokens: null, outputTokens: null, costUsd: null }; }
17
25
  export function validateAgentRequest(request) {
18
- if (!['architect', 'developer', 'reviewer'].includes(request.role) || typeof request.prompt !== 'string' || !request.prompt.trim() || request.prompt.includes('\0'))
26
+ if (typeof request.role !== 'string' || !ROLE_ID.test(request.role) || typeof request.prompt !== 'string' || request.prompt.includes('\0') || (!request.nativeCommand && !request.prompt.trim()))
19
27
  throw new AgentExecutionError('Invalid role request', 'invalid_request');
28
+ const policy = requestPolicy(request);
29
+ if (request.nativeCommand !== undefined) {
30
+ const command = request.nativeCommand;
31
+ if (!command || typeof command !== 'object' || Array.isArray(command) || Object.keys(command).some(key => !['id', 'args'].includes(key)) || typeof command.id !== 'string' || !/^[a-z][a-z0-9:_-]{0,63}$/.test(command.id) || (command.args !== undefined && (typeof command.args !== 'string' || command.args.includes('\0'))) || request.prompt.trim() || policy.instructions !== 'none' || request.openspec)
32
+ throw new AgentExecutionError('Invalid native command request', 'invalid_request');
33
+ }
34
+ if (policy.instructions === 'none' && request.openspec)
35
+ throw new AgentExecutionError('Free prompts cannot bind role instructions', 'invalid_request');
20
36
  if (request.model !== undefined && (typeof request.model !== 'string' || !request.model.trim() || request.model.length > 256 || /^-/.test(request.model) || /[\0\r\n]/.test(request.model)))
21
37
  throw new AgentExecutionError('Invalid model identifier', 'invalid_model');
22
38
  for (const key of ['maxTurns', 'timeoutMs', 'idleTimeoutMs', 'maxTokens', 'maxCostUsd']) {
23
39
  const value = request[key];
24
- if (value !== undefined && (!Number.isFinite(value) || value <= 0 || (key !== 'maxCostUsd' && !Number.isSafeInteger(value))))
40
+ if (value !== undefined && (!Number.isFinite(value) || (value < 0 || (value === 0 && key !== 'timeoutMs' && key !== 'idleTimeoutMs')) || (key !== 'maxCostUsd' && !Number.isSafeInteger(value))))
25
41
  throw new AgentExecutionError(`Invalid ${key}`, 'invalid_limit');
26
42
  }
27
43
  if (request.resumeSessionId !== undefined && (typeof request.resumeSessionId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/.test(request.resumeSessionId)))
@@ -31,3 +47,20 @@ export function validateAgentRequest(request) {
31
47
  if (request.signal?.aborted)
32
48
  throw new AgentExecutionError('Agent cancelled', 'aborted');
33
49
  }
50
+ /** Resolve compatible defaults without changing the caller's frozen request. */
51
+ export function requestPolicy(request) {
52
+ const legacy = isBuiltinRole(request.role) ? BUILTIN_ROLE_POLICY[request.role] : undefined;
53
+ const access = request.access ?? legacy?.access;
54
+ const artifacts = request.artifacts ?? legacy?.artifacts;
55
+ const instructions = request.instructions ?? (legacy ? 'role' : undefined);
56
+ if ((access !== 'read' && access !== 'write') || (artifacts !== 'none' && artifacts !== 'tasks-checkboxes' && artifacts !== 'all') || (instructions !== 'role' && instructions !== 'none'))
57
+ throw new AgentExecutionError('Explicit access, artifacts and instructions are required', 'invalid_request');
58
+ return { access, artifacts, instructions };
59
+ }
60
+ export function normalizeAgentRequest(request) {
61
+ validateAgentRequest(request);
62
+ const policy = requestPolicy(request);
63
+ const legacy = isBuiltinRole(request.role) ? BUILTIN_ROLE_POLICY[request.role] : undefined;
64
+ const unchangedBinding = request.openspec && request.openspec.role === request.role && request.openspec.access === undefined && request.openspec.artifacts === undefined && policy.access === legacy?.access && policy.artifacts === legacy?.artifacts;
65
+ return { ...request, ...policy, ...(request.openspec && !unchangedBinding ? { openspec: { ...request.openspec, access: policy.access, artifacts: policy.artifacts } } : {}) };
66
+ }
@@ -1,4 +1,4 @@
1
- import { AgentExecutionError, validateAgentRequest } from './executor-types.js';
1
+ import { AgentExecutionError, normalizeAgentRequest } from './executor-types.js';
2
2
  import { validateRuntimeConfig } from './config.js';
3
3
  import { CliExecutor } from './cli-executor.js';
4
4
  import { OpenAICompatibleExecutor } from './openai-executor.js';
@@ -24,7 +24,7 @@ export class ExecutorRegistry {
24
24
  async capabilities(id, model) { return await this.get(id).capabilities?.(model) ?? unknownCapabilities('registered:' + id); }
25
25
  validateLimits(id, limits) { this.get(id).validateLimits?.(limits); }
26
26
  async execute(id, request) {
27
- validateAgentRequest(request);
27
+ request = normalizeAgentRequest(request);
28
28
  if (request.effort !== undefined)
29
29
  assertEffortSupported(request, await this.capabilities(id, request.model));
30
30
  const result = await this.get(id).execute(request);
@@ -1,6 +1,8 @@
1
1
  import { type PipelineContext, type PipelineState, type VerificationCommand } from '../../pipeline/pipeline-state.js';
2
2
  import type { DesignConfidence } from './state.js';
3
3
  export declare const SLUG: RegExp;
4
+ /** Historical target lookup, not an archive or verification receipt. */
5
+ export declare function archivedOpenSpecChange(context: PipelineContext, change: string): string | undefined;
4
6
  export declare function object(value: unknown): Record<string, unknown>;
5
7
  /** Accepts the object wherever the model put it: bare, fenced, or surrounded by commentary. */
6
8
  export declare function parseAgentObject(text: string): Record<string, unknown>;
@@ -23,4 +25,9 @@ export declare function writeDesignConfidence(context: PipelineContext, change:
23
25
  }): void;
24
26
  /** Commands the architect proposed for repositories the configuration leaves uncovered. */
25
27
  export declare function proposedVerification(context: PipelineContext, configured: VerificationCommand[], verification: unknown): VerificationCommand[];
28
+ /** Recoverable pinned-CLI archive for ledger-only runs; no implementation journal. */
29
+ export declare function archiveOpenSpecChange(context: PipelineContext, change: string, adapter: {
30
+ directory: string;
31
+ beforePublish(): void;
32
+ }, signal?: AbortSignal): Promise<void>;
26
33
  export declare function archive(context: PipelineContext, change: string, signal?: AbortSignal): Promise<void>;
@@ -3,7 +3,25 @@ import { randomUUID } from 'node:crypto';
3
3
  import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { checkArchive, pipelineStateDirectory, transitionPipeline, validateVerificationRequest, } from '../../pipeline/pipeline-state.js';
6
+ import { assertProposalInScope, withScopeDefault } from '../change-scope.js';
6
7
  export const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
8
+ /** Historical target lookup, not an archive or verification receipt. */
9
+ export function archivedOpenSpecChange(context, change) {
10
+ if (!SLUG.test(change) || change.length > 64)
11
+ throw new Error('Invalid OpenSpec change identifier');
12
+ if (existsSync(artifactPath(context.artifactRoot, 'openspec/changes/' + change)))
13
+ return undefined;
14
+ const relative = 'openspec/changes/archive', directory = artifactPath(context.artifactRoot, relative);
15
+ if (!existsSync(directory))
16
+ return undefined;
17
+ const candidate = readdirSync(directory, { withFileTypes: true }).filter(entry => entry.isDirectory() &&
18
+ (entry.name === change || /^\d{4}-\d{2}-\d{2}-/.test(entry.name) && entry.name.slice(11) === change))
19
+ .sort((left, right) => left.name.localeCompare(right.name, 'en'))[0];
20
+ if (!candidate)
21
+ return undefined;
22
+ artifactPath(context.artifactRoot, relative + '/' + candidate.name);
23
+ return relative + '/' + candidate.name;
24
+ }
7
25
  export function object(value) {
8
26
  if (!value || typeof value !== 'object' || Array.isArray(value))
9
27
  throw new Error('Expected a structured JSON object from agent');
@@ -105,14 +123,16 @@ export function proposedVerification(context, configured, verification) {
105
123
  throw new Error('Invalid proposed verification command');
106
124
  return { repositoryId: command.repositoryId, command: command.command, args: command.args, ...(typeof command.cwd === 'string' && command.cwd ? { cwd: command.cwd } : {}) };
107
125
  }).filter(command => !covered.has(command.repositoryId) && context.repositories.some(repository => repository.id === command.repositoryId));
126
+ for (const command of proposed)
127
+ assertProposalInScope(context, command, 'proposed verification command');
108
128
  if (proposed.length)
109
- validateVerificationRequest(context, { kind: 'scoped', commands: proposed });
129
+ validateVerificationRequest(context, { kind: 'scoped', commands: proposed.map(command => withScopeDefault(context, command)) });
110
130
  return proposed;
111
131
  }
112
132
  /** Publish an archive prepared by the real CLI. The durable write set makes a
113
133
  * crash between main-spec updates recoverable without applying a delta twice. */
114
- async function archiveWithOpenSpec(context, change, active, signal) {
115
- const directory = pipelineStateDirectory(context);
134
+ async function archiveWithOpenSpec(context, change, active, signal, adapter) {
135
+ const directory = adapter?.directory ?? pipelineStateDirectory(context);
116
136
  const receipt = path.join(directory, 'openspec-archive.json');
117
137
  const files = (root, prefix = '') => {
118
138
  if (!existsSync(root))
@@ -124,7 +144,8 @@ async function archiveWithOpenSpec(context, change, active, signal) {
124
144
  return entry.isDirectory() ? files(path.join(root, entry.name), relative + '/') : [relative];
125
145
  });
126
146
  };
127
- const activeHash = hash(JSON.stringify(files(active).sort().map(file => [file, readFileSync(artifactPath(active, file), 'utf8')])));
147
+ const treeHash = (root) => hash(JSON.stringify(files(root).sort().map(file => [file, readFileSync(artifactPath(root, file), 'utf8')])));
148
+ const activeHash = treeHash(active);
128
149
  let plan;
129
150
  if (existsSync(receipt))
130
151
  plan = JSON.parse(readFileSync(receipt, 'utf8'));
@@ -134,6 +155,22 @@ async function archiveWithOpenSpec(context, change, active, signal) {
134
155
  const source = artifactPath(context.artifactRoot, 'openspec');
135
156
  files(source); // Reject symlinks before copying or invoking the external CLI.
136
157
  cpSync(source, path.join(staging, 'openspec'), { recursive: true });
158
+ // A fork prepares its delta against checkpoint-owned main specs. Publish
159
+ // still compares all current preimages below; no source files are rewound.
160
+ const forkBase = path.join(directory, 'openspec-archive-base.json');
161
+ let beforeRoot = path.join(source, 'specs');
162
+ let sourceChange;
163
+ if (existsSync(forkBase)) {
164
+ const baseline = object(JSON.parse(readFileSync(forkBase, 'utf8')));
165
+ if (baseline.schemaVersion !== 1 || baseline.directory !== 'agent-workflow/openspec-base-specs' || typeof baseline.snapshot !== 'string' || !/^[a-f0-9]{64}$/.test(baseline.snapshot)
166
+ || typeof baseline.sourceChange !== 'string' || baseline.sourceChange.length > 64 || !SLUG.test(baseline.sourceChange))
167
+ throw new Error('Invalid fork archive baseline');
168
+ sourceChange = baseline.sourceChange;
169
+ beforeRoot = child(directory, baseline.directory);
170
+ files(beforeRoot);
171
+ rmSync(path.join(staging, 'openspec/specs'), { recursive: true, force: true });
172
+ cpSync(beforeRoot, path.join(staging, 'openspec/specs'), { recursive: true });
173
+ }
137
174
  const output = await runOpenSpec(resolveOpenSpecCli(), staging, ['archive', change, '--yes'], signal);
138
175
  const archiveRoot = path.join(staging, 'openspec/changes/archive');
139
176
  const archives = existsSync(archiveRoot) ? readdirSync(archiveRoot).filter(name => name.endsWith('-' + change)) : [];
@@ -148,20 +185,40 @@ async function archiveWithOpenSpec(context, change, active, signal) {
148
185
  const destination = 'openspec/changes/archive/' + archives[0];
149
186
  if (existsSync(artifactPath(context.artifactRoot, destination)))
150
187
  throw new Error('OpenSpec archive destination already exists');
151
- const beforeRoot = path.join(source, 'specs'), afterRoot = path.join(staging, 'openspec/specs');
188
+ const afterRoot = path.join(staging, 'openspec/specs');
152
189
  const names = new Set([...files(beforeRoot), ...files(afterRoot)]);
153
190
  const contents = (root, name) => existsSync(path.join(root, name)) ? readFileSync(path.join(root, name), 'utf8') : null;
154
- plan = { activeHash, destination, writes: [...names].map(name => ({ path: 'openspec/specs/' + name, before: contents(beforeRoot, name), after: contents(afterRoot, name) })).filter(item => item.before !== item.after) };
191
+ plan = { activeHash, destination, writes: [...names].map(name => {
192
+ const before = contents(beforeRoot, name);
193
+ let after = contents(afterRoot, name);
194
+ // Pinned OpenSpec inserts a change ID into the placeholder for a newly
195
+ // created specification. Preserve that provenance during fork replay;
196
+ // authored text and all byte-for-byte publication guards stay intact.
197
+ if (sourceChange && before === null && after !== null)
198
+ after = after.replace(`\n## Purpose\nTBD - created by archiving change ${change}. Update Purpose after archive.\n## Requirements\n`, `\n## Purpose\nTBD - created by archiving change ${sourceChange}. Update Purpose after archive.\n## Requirements\n`);
199
+ return { path: 'openspec/specs/' + name, before, after };
200
+ }).filter(item => item.before !== item.after) };
155
201
  write(receipt, JSON.stringify(plan));
156
202
  }
157
203
  finally {
158
204
  rmSync(staging, { recursive: true, force: true });
159
205
  }
160
206
  }
161
- if (plan.activeHash !== activeHash)
162
- throw new Error('Reviewed artifacts changed since the OpenSpec archive was prepared');
163
207
  if (!new RegExp('^openspec/changes/archive/[0-9]{4}-[0-9]{2}-[0-9]{2}-' + change + '$').test(plan.destination))
164
208
  throw new Error('Invalid OpenSpec archive receipt');
209
+ if (adapter && !existsSync(active)) {
210
+ const archived = artifactPath(context.artifactRoot, plan.destination);
211
+ if (!existsSync(archived) || treeHash(archived) !== plan.activeHash || plan.writes.some(item => {
212
+ if (!item.path.startsWith('openspec/specs/'))
213
+ throw new Error('Invalid OpenSpec archive write');
214
+ const target = artifactPath(context.artifactRoot, item.path);
215
+ return (existsSync(target) ? readFileSync(target, 'utf8') : null) !== item.after;
216
+ }))
217
+ throw new Error('OpenSpec archive recovery does not match the prepared write set');
218
+ return;
219
+ }
220
+ if (plan.activeHash !== activeHash)
221
+ throw new Error('Reviewed artifacts changed since the OpenSpec archive was prepared');
165
222
  const targets = plan.writes.map(item => {
166
223
  if (!item.path.startsWith('openspec/specs/'))
167
224
  throw new Error('Invalid OpenSpec archive write');
@@ -169,16 +226,23 @@ async function archiveWithOpenSpec(context, change, active, signal) {
169
226
  const current = existsSync(target) ? readFileSync(target, 'utf8') : null;
170
227
  if (current !== item.before && current !== item.after)
171
228
  throw new Error('Main specification changed during archive; refusing to overwrite: ' + item.path);
172
- return { ...item, target };
229
+ return { ...item, target, unchanged: current === item.after };
173
230
  });
174
231
  // The external CLI ran asynchronously; recheck the candidate and artifacts before publishing.
175
232
  signal?.throwIfAborted();
176
- checkArchive(context);
233
+ if (treeHash(active) !== activeHash)
234
+ throw new Error('Reviewed artifacts changed during OpenSpec archive preparation');
235
+ if (adapter)
236
+ adapter.beforePublish();
237
+ else
238
+ checkArchive(context);
177
239
  // Validate every path and preimage before publishing the first output.
178
240
  const destination = artifactPath(context.artifactRoot, plan.destination);
179
241
  if (existsSync(destination))
180
242
  throw new Error('Archive destination already exists');
181
243
  for (const item of targets) {
244
+ if (item.unchanged)
245
+ continue;
182
246
  if (item.after === null)
183
247
  rmSync(item.target, { force: true });
184
248
  else
@@ -187,6 +251,18 @@ async function archiveWithOpenSpec(context, change, active, signal) {
187
251
  mkdirSync(path.dirname(destination), { recursive: true });
188
252
  renameSync(active, destination);
189
253
  }
254
+ /** Recoverable pinned-CLI archive for ledger-only runs; no implementation journal. */
255
+ export async function archiveOpenSpecChange(context, change, adapter, signal) {
256
+ if (!SLUG.test(change) || change.length > 64)
257
+ throw new Error('Invalid OpenSpec change identifier');
258
+ mkdirSync(adapter.directory, { recursive: true, mode: 0o700 });
259
+ if (lstatSync(adapter.directory).isSymbolicLink())
260
+ throw new Error('Refusing symlink archive adapter directory');
261
+ const active = artifactPath(context.artifactRoot, 'openspec/changes/' + change);
262
+ if (!existsSync(active) && !existsSync(path.join(adapter.directory, 'openspec-archive.json')))
263
+ throw new Error('OpenSpec active change does not exist');
264
+ await archiveWithOpenSpec(context, change, active, signal, adapter);
265
+ }
190
266
  export async function archive(context, change, signal) {
191
267
  const state = journal(context);
192
268
  if (state.phases.archive.status !== 'done') {
@@ -0,0 +1,26 @@
1
+ import type { VerificationReceipt } from '../../pipeline/pipeline-state.js';
2
+ import type { WorkflowState } from '../workflow-types.js';
3
+ /** One verify visit, as the convergence guard remembers it. */
4
+ export interface VerifyOutcome {
5
+ at: string;
6
+ candidateHash: string;
7
+ planHash: string;
8
+ passed: boolean;
9
+ /** Normalized identity of the first failure: the command plus its output with volatile details removed. */
10
+ signature?: string;
11
+ summary?: string;
12
+ }
13
+ /** Verify visits since the latest explicit resume: a human continuing a blocked run grants a fresh budget, as for attempts. */
14
+ export declare function outcomesSinceResume(history: readonly VerifyOutcome[], checkpoint: WorkflowState): VerifyOutcome[];
15
+ /** Identity and one-line description of a failed receipt (or of a host finding that failed a green receipt). */
16
+ export declare function describeFailure(receipt: Pick<VerificationReceipt, 'commands' | 'reason'>, roots: readonly string[], finding?: string): {
17
+ signature: string;
18
+ summary: string;
19
+ };
20
+ /**
21
+ * Why the failure just recorded (`current`, not yet in `history`) proves the
22
+ * correction loop is not converging, or undefined when another round can help.
23
+ */
24
+ export declare function divergence(history: readonly VerifyOutcome[], current: VerifyOutcome): string | undefined;
25
+ /** The same test for a candidate that has not been verified again yet: an unchanged failed candidate is not worth a new run. */
26
+ export declare function unchangedFailure(history: readonly VerifyOutcome[], candidateHash: string, planHash: string): VerifyOutcome | undefined;
@@ -0,0 +1,62 @@
1
+ import { fingerprint } from '../durable-store.js';
2
+ /** Verify visits since the latest explicit resume: a human continuing a blocked run grants a fresh budget, as for attempts. */
3
+ export function outcomesSinceResume(history, checkpoint) {
4
+ const resumed = [...checkpoint.events].reverse().find(event => event.type === 'workflow_resumed');
5
+ return history.filter(entry => !resumed || entry.at >= resumed.timestamp);
6
+ }
7
+ function normalized(output, roots) {
8
+ let text = output.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, '');
9
+ for (const root of [...roots].sort((a, b) => b.length - a.length))
10
+ text = text.split(root).join('<repo>');
11
+ return text.split(/\r?\n/)
12
+ .map(line => line.replace(/\b[0-9a-f]{7,64}\b/gi, '#').replace(/\d+(?:[.,]\d+)*/g, '0').replace(/\s+/g, ' ').trim())
13
+ .filter(Boolean).slice(-40).join('\n');
14
+ }
15
+ /** The last line that says what went wrong, bounded. */
16
+ function headline(output) {
17
+ const lines = output.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, '').split(/\r?\n/).map(line => line.trim()).filter(Boolean);
18
+ const telling = [...lines].reverse().find(line => /error|fail|✕|×|not found|cannot|denied|refused|forbidden|unauthori[sz]ed/i.test(line));
19
+ return (telling ?? lines.at(-1) ?? '').slice(0, 300);
20
+ }
21
+ /** Identity and one-line description of a failed receipt (or of a host finding that failed a green receipt). */
22
+ export function describeFailure(receipt, roots, finding) {
23
+ if (finding)
24
+ return { signature: fingerprint({ finding: normalized(finding, roots) }), summary: finding.slice(0, 300) };
25
+ const failed = receipt.commands.find(command => command.exitCode !== 0);
26
+ if (!failed)
27
+ return { signature: fingerprint({ reason: receipt.reason ?? 'verification failed' }), summary: receipt.reason ?? 'verification failed' };
28
+ const name = failed.label ?? [failed.command, ...failed.args].join(' ');
29
+ const detail = headline(failed.output);
30
+ return {
31
+ signature: fingerprint({ check: failed.key ?? [failed.command, ...failed.args, failed.cwd], exitCode: failed.exitCode, output: normalized(failed.output, roots) }),
32
+ summary: `${name} exited ${failed.exitCode}${detail ? `: ${detail}` : ''}`.slice(0, 400),
33
+ };
34
+ }
35
+ /**
36
+ * Why the failure just recorded (`current`, not yet in `history`) proves the
37
+ * correction loop is not converging, or undefined when another round can help.
38
+ */
39
+ export function divergence(history, current) {
40
+ if (current.passed || !current.signature)
41
+ return undefined;
42
+ const cycle = history.find(entry => !entry.passed && entry.candidateHash === current.candidateHash && entry.planHash === current.planHash);
43
+ if (cycle)
44
+ return `The correction round returned the change to a state that already failed verification (${current.summary}). Running the same checks again cannot pass.`;
45
+ let repeats = 0;
46
+ for (const entry of [...history].reverse()) {
47
+ if (entry.passed || entry.signature !== current.signature)
48
+ break;
49
+ repeats++;
50
+ }
51
+ if (repeats >= 2)
52
+ return `The same verification failure survived ${repeats} correction rounds unchanged (${current.summary}). It is most likely outside what this change can fix: a pre-existing failure, another package, or the environment.`;
53
+ const previous = history.at(-1);
54
+ if (previous?.passed && history.some(entry => !entry.passed && entry.signature === current.signature)) {
55
+ return `A review correction brought back a verification failure that an earlier correction had fixed (${current.summary}). The review request and the checks pull in opposite directions; decide which one applies before resuming.`;
56
+ }
57
+ return undefined;
58
+ }
59
+ /** The same test for a candidate that has not been verified again yet: an unchanged failed candidate is not worth a new run. */
60
+ export function unchangedFailure(history, candidateHash, planHash) {
61
+ return [...history].reverse().find(entry => !entry.passed && entry.candidateHash === candidateHash && entry.planHash === planHash);
62
+ }
@@ -9,6 +9,8 @@ import type { CoreNodeId, CoreStateType, DeveloperRecord, VerificationRecord } f
9
9
  /** Autonomous investigation passes before a low-confidence design asks or proceeds. */
10
10
  export declare const MAX_DEEPEN_PASSES = 1;
11
11
  export interface CoreNodeDeps {
12
+ /** V2 presentation omits framework dispatch frames; original evidence stays complete. */
13
+ focusedCorrectionEvidence?: boolean;
12
14
  archiveApproved?: () => boolean;
13
15
  context: PipelineContext;
14
16
  config: RuntimeConfig;