specrails-core 6.6.5 → 6.7.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 (118) hide show
  1. package/dist/agent-runtime/capabilities.d.ts +5 -2
  2. package/dist/agent-runtime/capabilities.js +8 -2
  3. package/dist/agent-runtime/change-scope.d.ts +2 -0
  4. package/dist/agent-runtime/change-scope.js +5 -0
  5. package/dist/agent-runtime/cli-executor.js +7 -0
  6. package/dist/agent-runtime/cli.d.ts +6 -1
  7. package/dist/agent-runtime/cli.js +15 -4
  8. package/dist/agent-runtime/compact/reviewer.js +3 -1
  9. package/dist/agent-runtime/config.js +16 -5
  10. package/dist/agent-runtime/efficiency-summary.d.ts +7 -0
  11. package/dist/agent-runtime/efficiency-summary.js +7 -1
  12. package/dist/agent-runtime/engine/__fixtures__/implementation-fixture.js +1 -1
  13. package/dist/agent-runtime/engine/checkpoint/database.d.ts +9 -0
  14. package/dist/agent-runtime/engine/checkpoint/database.js +40 -3
  15. package/dist/agent-runtime/engine/checkpoint/ledger.d.ts +1 -0
  16. package/dist/agent-runtime/engine/checkpoint/ledger.js +7 -2
  17. package/dist/agent-runtime/engine/cli.d.ts +12 -0
  18. package/dist/agent-runtime/engine/cli.js +15 -4
  19. package/dist/agent-runtime/engine/definition-schema.d.ts +1 -1
  20. package/dist/agent-runtime/engine/definition-schema.js +1 -0
  21. package/dist/agent-runtime/engine/definition-types.d.ts +1 -1
  22. package/dist/agent-runtime/engine/definition-types.js +1 -1
  23. package/dist/agent-runtime/engine/definition-validator.js +8 -0
  24. package/dist/agent-runtime/engine/efficiency-summary.d.ts +7 -0
  25. package/dist/agent-runtime/engine/graph-description.d.ts +3 -3
  26. package/dist/agent-runtime/engine/invocation-context.js +2 -0
  27. package/dist/agent-runtime/engine/piece-registry.d.ts +1 -1
  28. package/dist/agent-runtime/engine/piece-registry.js +1 -1
  29. package/dist/agent-runtime/engine/pieces/artifact-contract.d.ts +5 -0
  30. package/dist/agent-runtime/engine/pieces/artifact-contract.js +6 -2
  31. package/dist/agent-runtime/engine/pieces/derivation-coverage.d.ts +30 -0
  32. package/dist/agent-runtime/engine/pieces/derivation-coverage.js +95 -0
  33. package/dist/agent-runtime/engine/pieces/derive-change.d.ts +50 -0
  34. package/dist/agent-runtime/engine/pieces/derive-change.js +209 -0
  35. package/dist/agent-runtime/engine/pieces/index.js +2 -1
  36. package/dist/agent-runtime/engine/pieces/openspec.d.ts +16 -1
  37. package/dist/agent-runtime/engine/pieces/openspec.js +39 -30
  38. package/dist/agent-runtime/engine/pieces/role-turn.js +26 -5
  39. package/dist/agent-runtime/engine/preflight.d.ts +2 -1
  40. package/dist/agent-runtime/engine/preflight.js +3 -1
  41. package/dist/agent-runtime/engine/regex.d.ts +12 -2
  42. package/dist/agent-runtime/engine/regex.js +25 -11
  43. package/dist/agent-runtime/engine/run-status.d.ts +34 -0
  44. package/dist/agent-runtime/engine/run-status.js +7 -1
  45. package/dist/agent-runtime/engine/runs.d.ts +56 -0
  46. package/dist/agent-runtime/engine/runs.js +88 -5
  47. package/dist/agent-runtime/engine/steering/inbox.d.ts +15 -0
  48. package/dist/agent-runtime/engine/steering/inbox.js +21 -1
  49. package/dist/agent-runtime/engine/storage/private-path.js +7 -1
  50. package/dist/agent-runtime/evaluation.js +1 -1
  51. package/dist/agent-runtime/executor-types.d.ts +19 -0
  52. package/dist/agent-runtime/executor-types.js +10 -0
  53. package/dist/agent-runtime/failure-classification.d.ts +51 -0
  54. package/dist/agent-runtime/failure-classification.js +110 -0
  55. package/dist/agent-runtime/graph/artifacts.d.ts +3 -1
  56. package/dist/agent-runtime/graph/artifacts.js +5 -1
  57. package/dist/agent-runtime/graph/baseline.d.ts +50 -0
  58. package/dist/agent-runtime/graph/baseline.js +145 -0
  59. package/dist/agent-runtime/graph/capability-map.d.ts +29 -0
  60. package/dist/agent-runtime/graph/capability-map.js +65 -0
  61. package/dist/agent-runtime/graph/clarifications.d.ts +75 -0
  62. package/dist/agent-runtime/graph/clarifications.js +158 -0
  63. package/dist/agent-runtime/graph/nodes.js +178 -19
  64. package/dist/agent-runtime/graph/review-brief-record.d.ts +25 -0
  65. package/dist/agent-runtime/graph/review-brief-record.js +26 -0
  66. package/dist/agent-runtime/graph/review-brief.d.ts +73 -0
  67. package/dist/agent-runtime/graph/review-brief.js +127 -0
  68. package/dist/agent-runtime/graph/roles.js +5 -3
  69. package/dist/agent-runtime/graph/spec-trace-record.d.ts +33 -0
  70. package/dist/agent-runtime/graph/spec-trace-record.js +65 -0
  71. package/dist/agent-runtime/graph/state.d.ts +8 -0
  72. package/dist/agent-runtime/index.d.ts +7 -0
  73. package/dist/agent-runtime/index.js +7 -0
  74. package/dist/agent-runtime/openspec.d.ts +1 -0
  75. package/dist/agent-runtime/openspec.js +1 -1
  76. package/dist/agent-runtime/project-rules.d.ts +139 -0
  77. package/dist/agent-runtime/project-rules.js +683 -0
  78. package/dist/agent-runtime/prompts.d.ts +32 -1
  79. package/dist/agent-runtime/prompts.js +180 -7
  80. package/dist/agent-runtime/role-routing.d.ts +2 -1
  81. package/dist/agent-runtime/session/application/active-session.d.ts +43 -2
  82. package/dist/agent-runtime/session/application/active-session.js +163 -7
  83. package/dist/agent-runtime/session/application/session-service.d.ts +2 -2
  84. package/dist/agent-runtime/session/domain/descriptor.js +5 -0
  85. package/dist/agent-runtime/session/domain/interruption.js +1 -0
  86. package/dist/agent-runtime/session/domain/policy.js +2 -0
  87. package/dist/agent-runtime/session/domain/redaction.d.ts +19 -0
  88. package/dist/agent-runtime/session/domain/redaction.js +71 -0
  89. package/dist/agent-runtime/session/domain/types.d.ts +17 -1
  90. package/dist/agent-runtime/session/drivers/claude/argv.d.ts +14 -0
  91. package/dist/agent-runtime/session/drivers/claude/argv.js +19 -2
  92. package/dist/agent-runtime/session/drivers/claude/driver.js +11 -1
  93. package/dist/agent-runtime/session/drivers/claude/translator.d.ts +4 -0
  94. package/dist/agent-runtime/session/drivers/claude/translator.js +16 -3
  95. package/dist/agent-runtime/session/drivers/codex/driver.js +2 -0
  96. package/dist/agent-runtime/session/drivers/executor/driver.js +2 -0
  97. package/dist/agent-runtime/session/host/protocol.d.ts +12 -0
  98. package/dist/agent-runtime/session/host/protocol.js +10 -2
  99. package/dist/agent-runtime/session/host/server.js +6 -5
  100. package/dist/agent-runtime/session/index.d.ts +1 -1
  101. package/dist/agent-runtime/session/journal/sqlite-journal.js +6 -2
  102. package/dist/agent-runtime/session/ports.d.ts +6 -2
  103. package/dist/agent-runtime/session/testing/driver-conformance.js +16 -2
  104. package/dist/agent-runtime/session/testing/memory-journal.js +4 -2
  105. package/dist/installer/cli.js +10 -1
  106. package/dist/installer/commands/init.js +14 -3
  107. package/dist/installer/phases/scaffold.js +56 -5
  108. package/dist/pipeline/pipeline-state.d.ts +15 -0
  109. package/dist/pipeline/pipeline-state.js +36 -3
  110. package/dist/pipeline/spec-trace.d.ts +134 -0
  111. package/dist/pipeline/spec-trace.js +392 -0
  112. package/dist/shared/secret-redaction.d.ts +42 -0
  113. package/dist/shared/secret-redaction.js +184 -0
  114. package/integration-contract.json +177 -7
  115. package/package.json +7 -7
  116. package/schemas/agent-runtime.schema.json +3 -1
  117. package/schemas/workflow-definition.schema.json +1 -0
  118. package/templates/settings/codex-config.toml +3 -2
@@ -9,12 +9,13 @@ import { admitVerificationProposals } from '../../verification-proposals.js';
9
9
  import { guardrailEnabled } from '../../guardrails.js';
10
10
  import { installEnvironment, prepareEnvironment } from '../../compact/environment.js';
11
11
  import { roleInstructions } from '../../prompts.js';
12
+ import { projectRulesContext } from '../../project-rules.js';
12
13
  import { EngineError } from '../contracts.js';
13
14
  import { boundedText, historyEntry, idSchema, json, paramsSchema, invocationTimers, stringSchema, text } from './shared.js';
14
15
  const ajv = new Ajv2020({ allErrors: true, strict: true, validateFormats: false, ownProperties: true });
15
16
  export function roleTurnPiece(bindings) {
16
17
  return {
17
- descriptor: { kind: 'role-turn', paramsSchema: paramsSchema({ ...invocationTimers, roleId: idSchema, prompt: { ...stringSchema, minLength: 1 }, structuredOutput: { type: 'object' }, sessionContinuity: { enum: ['run', 'none'] }, verificationProposalsFrom: { type: 'string', minLength: 1, maxLength: 128 } }, ['roleId', 'prompt']), outcomes: ['next', 'invalid', 'failed'], effect: 'derived', requiresAI: true, storeAccess: 'write' },
18
+ descriptor: { kind: 'role-turn', paramsSchema: paramsSchema({ ...invocationTimers, roleId: idSchema, prompt: { ...stringSchema, minLength: 1 }, structuredOutput: { type: 'object' }, sessionContinuity: { enum: ['run', 'none'] }, stance: { enum: ['fixer'] }, verificationProposalsFrom: { type: 'string', minLength: 1, maxLength: 128 } }, ['roleId', 'prompt']), outcomes: ['next', 'invalid', 'failed'], effect: 'derived', requiresAI: true, storeAccess: 'write' },
18
19
  getOutcomes: params => params.structuredOutput ? ['next', 'invalid', 'failed'] : ['next', 'failed'],
19
20
  getEffect: (params, roles) => {
20
21
  const role = roles[text(params.roleId)];
@@ -28,6 +29,13 @@ export function roleTurnPiece(bindings) {
28
29
  /** One invocation path for configured roles, including routing and exactly one repair. */
29
30
  export async function executeRoleTurn(deps, params, context, options = {}) {
30
31
  const roleId = text(params.roleId), descriptor = resolveRoleDescriptor(deps.config, roleId);
32
+ // `stance: 'fixer'` makes the turn a correction round: only a write role can
33
+ // correct code. The built-in developer takes the FIXER instructions (and the
34
+ // fixer engine when one is configured); a custom role only labels its events.
35
+ const stance = params.stance === 'fixer' ? 'fixer' : undefined;
36
+ if (stance && descriptor.access !== 'write')
37
+ throw new EngineError('invalid_arguments', `The fixer stance requires a role with write access; ${roleId} is ${descriptor.access}`);
38
+ const fixerEngine = stance && roleId === 'developer' ? deps.config.fixer : undefined;
31
39
  // Agents run sandboxed and cannot install what the repository needs (Codex
32
40
  // cannot even write the Playwright browser cache): the host prepares it
33
41
  // before any writer turn and says so in the task.
@@ -39,7 +47,8 @@ export async function executeRoleTurn(deps, params, context, options = {}) {
39
47
  const schema = params.structuredOutput;
40
48
  const validate = schema ? ajv.compile(schema) : undefined;
41
49
  const roleState = deps.roleState(context);
42
- const previous = params.sessionContinuity !== 'none' ? roleState.read().sessions[roleId]?.sessionId : undefined;
50
+ // Sessions never carry across engines: a turn on the fixer engine always starts fresh.
51
+ const previous = params.sessionContinuity !== 'none' && !fixerEngine ? roleState.read().sessions[roleId]?.sessionId : undefined;
43
52
  const openspec = deps.openspec?.(context);
44
53
  if (descriptor.openspecSkill && !openspec?.[roleId])
45
54
  throw new EngineError('openspec_binding_required', 'Role requires a scoped OpenSpec binding: ' + roleId);
@@ -48,7 +57,7 @@ export async function executeRoleTurn(deps, params, context, options = {}) {
48
57
  const before = deps.executionSnapshot(context).candidate;
49
58
  const memoryIdentity = { roleId, descriptor: json(descriptor), tier: roleState.read().routes[roleId]?.tier ?? 'base',
50
59
  candidateHash: before?.hash ?? null, scopeId: context.frame.scope.id, nodePath: context.frame.nodePath,
51
- repositories: deps.context.repositories.map(repository => ({ id: repository.id, path: repository.path })) };
60
+ repositories: deps.context.repositories.map(repository => ({ id: repository.id, path: repository.path })), ...(stance ? { stance } : {}) };
52
61
  const memoryKey = contentDigest(memoryIdentity);
53
62
  const saved = await advisoryMemory(context, async () => {
54
63
  const memory = deps.memory(context);
@@ -59,8 +68,11 @@ export async function executeRoleTurn(deps, params, context, options = {}) {
59
68
  const task = text(params.prompt) + environment + (priorNote ? '\n\n## Prior project review note\nTreat this bounded note as prior evidence to check against the current task; frozen requirements remain authoritative.\n' + JSON.stringify(priorNote) : '');
60
69
  const verification = descriptor.access === 'write' ? hostVerificationPlan(deps, context, params) : undefined;
61
70
  const adopted = adoptedVerificationOutputs(context.state.$outputs);
62
- const full = roleInstructions(descriptor, deps.context, openspec?.[roleId]?.change, { definition: descriptor.prompt, verification, ...(adopted.length ? { adoptedOutputs: adopted } : {}) }) + '\n## Current workflow task\n' + task;
71
+ const derivation = roleId === 'reviewer' ? committedDerivation(context.state.$outputs) : undefined;
72
+ const projectRules = roleId === 'architect' || roleId === 'reviewer' ? projectRulesContext(deps.context) : undefined;
73
+ const full = roleInstructions(descriptor, deps.context, openspec?.[roleId]?.change, { definition: stance && roleId === 'developer' ? deps.config.rolePrompts?.fixer : descriptor.prompt, ...(stance && roleId === 'developer' ? { stance } : {}), verification, ...(adopted.length ? { adoptedOutputs: adopted } : {}), ...(derivation ? { derivation } : {}), ...(projectRules ? { projectRules } : {}) }) + '\n## Current workflow task\n' + task;
63
74
  const result = await invoke(roleId, deps.stepContext(context), { prompt: previous ? task : full, ...(previous ? { resumeSessionId: previous, fallbackPrompt: full } : {}),
75
+ ...(stance ? { stance } : {}), ...(fixerEngine ? { agentOverride: fixerEngine } : {}),
64
76
  structured: schema !== undefined, lenient: options.normalizeStructuredOutput !== undefined, outputSchema: schema, timeoutMs: params.timeoutMs, idleTimeoutMs: params.idleTimeoutMs }, (output, responseText) => {
65
77
  const normalized = options.normalizeStructuredOutput ? options.normalizeStructuredOutput(output, responseText) : output;
66
78
  if (validate && !validate(normalized))
@@ -96,7 +108,16 @@ export async function executeRoleTurn(deps, params, context, options = {}) {
96
108
  text: boundedText(result.text, 4000), lastAttemptId: context.frame.attemptId });
97
109
  });
98
110
  return { outcome: 'next', output: { candidateHash: deps.executionSnapshot(context).candidate?.hash ?? null, text: boundedText(result.text), ...(result.value ? { structured: json(result.value) } : {}) },
99
- history: [historyEntry(context, result.text)], ...(params.sessionContinuity !== 'none' && session ? { session: { sessionId: session.sessionId, identity: session.identity } } : {}) };
111
+ history: [historyEntry(context, result.text)], ...(params.sessionContinuity !== 'none' && !fixerEngine && session ? { session: { sessionId: session.sessionId, identity: session.identity } } : {}) };
112
+ }
113
+ /** The coverage report a committed `derive-change` output carries (its derivation.json), for the reviewer. */
114
+ function committedDerivation(outputs) {
115
+ for (const output of Object.values(outputs)) {
116
+ const value = output && typeof output === 'object' && !Array.isArray(output) ? output.derivation : undefined;
117
+ if (value && typeof value === 'object' && !Array.isArray(value) && Array.isArray(value.unexplainedFiles) && typeof value.files === 'number' && typeof value.explained === 'number')
118
+ return { ...value, unexplainedFiles: value.unexplainedFiles.filter((file) => typeof file === 'string') };
119
+ }
120
+ return undefined;
100
121
  }
101
122
  /** Files any committed `verify` output reports as host-adopted verification output (sticky for the run), so no role is told to revert them. */
102
123
  function adoptedVerificationOutputs(outputs) {
@@ -1,6 +1,6 @@
1
1
  import { type PipelineContext } from '../../pipeline/pipeline-state.js';
2
2
  import { type ExecutorRegistry } from '../executors.js';
3
- import type { RuntimeConfig } from '../executor-types.js';
3
+ import { type RuntimeConfig } from '../executor-types.js';
4
4
  import type { RoleCatalog, WorkflowDefinition } from './definition-types.js';
5
5
  export interface DefinitionRunRequest {
6
6
  context: PipelineContext;
@@ -14,6 +14,7 @@ export interface DefinitionRunInput {
14
14
  change?: string;
15
15
  registry?: ExecutorRegistry;
16
16
  }
17
+ /** Configured roles plus Core's engine roles (deriver), which definitions may declare without configuring them. */
17
18
  export declare function configuredRoles(config: RuntimeConfig): RoleCatalog;
18
19
  /** Complete admission is read-only; no run, journal, provider turn or skill is created. */
19
20
  export declare function preflightDefinition(input: DefinitionRunInput): Promise<{
@@ -2,12 +2,14 @@ import { validatePipelineContext, validateVerificationRequest } from '../../pipe
2
2
  import { normalizeRuntimeConfig, resolveRoleDescriptor, roleIds } from '../config.js';
3
3
  import { assertEffortSupported } from '../capabilities.js';
4
4
  import { createExecutorRegistry } from '../executors.js';
5
+ import { ENGINE_ROLES } from '../executor-types.js';
5
6
  import { EngineError } from './contracts.js';
6
7
  import { intersectBudgets } from './budget.js';
7
8
  import { validateWorkflowDefinition } from './definition-validator.js';
8
9
  import { validationPieceRegistry } from './pieces/index.js';
10
+ /** Configured roles plus Core's engine roles (deriver), which definitions may declare without configuring them. */
9
11
  export function configuredRoles(config) {
10
- return Object.fromEntries(roleIds(config).map(id => [id, { access: resolveRoleDescriptor(config, id).access }]));
12
+ return Object.fromEntries([...roleIds(config), ...ENGINE_ROLES].map(id => [id, { access: resolveRoleDescriptor(config, id).access }]));
11
13
  }
12
14
  /** Complete admission is read-only; no run, journal, provider turn or skill is created. */
13
15
  export async function preflightDefinition(input) {
@@ -1,3 +1,13 @@
1
- /** Regex is data in a constant script. V8 interrupts even catastrophic matching. */
2
- export declare function capturePattern(pattern: string, text: string): Array<string | undefined> | null;
1
+ export declare const PATTERN_TIMEOUT_MS = 50;
2
+ /**
3
+ * Attempts per match. The VM timeout is wall-clock: a garbage-collection pause or
4
+ * a descheduled thread on a loaded machine can exceed it while a simple pattern
5
+ * runs. A catastrophic pattern times out on every attempt, so one retry keeps the
6
+ * bound (at most 2 × 50 ms) without failing a cheap match on scheduler noise.
7
+ */
8
+ export declare const PATTERN_ATTEMPTS = 2;
9
+ type Run = (pattern: string, text: string) => RegExpExecArray | null;
10
+ /** Regex is data in a constant script. V8 interrupts even catastrophic matching. `run` is a test seam. */
11
+ export declare function capturePattern(pattern: string, text: string, run?: Run): Array<string | undefined> | null;
3
12
  export declare function matchesPattern(pattern: string, text: string): boolean;
13
+ export {};
@@ -1,19 +1,33 @@
1
1
  import { Script } from 'node:vm';
2
2
  import { EngineError } from './contracts.js';
3
3
  const matchScript = new Script('new RegExp(pattern).exec(text)', { filename: 'core-bounded-pattern' });
4
- /** Regex is data in a constant script. V8 interrupts even catastrophic matching. */
5
- export function capturePattern(pattern, text) {
4
+ export const PATTERN_TIMEOUT_MS = 50;
5
+ /**
6
+ * Attempts per match. The VM timeout is wall-clock: a garbage-collection pause or
7
+ * a descheduled thread on a loaded machine can exceed it while a simple pattern
8
+ * runs. A catastrophic pattern times out on every attempt, so one retry keeps the
9
+ * bound (at most 2 × 50 ms) without failing a cheap match on scheduler noise.
10
+ */
11
+ export const PATTERN_ATTEMPTS = 2;
12
+ const runInVm = (pattern, text) => matchScript.runInNewContext({ pattern, text }, {
13
+ timeout: PATTERN_TIMEOUT_MS, contextCodeGeneration: { strings: false, wasm: false },
14
+ });
15
+ const timedOut = (error) => error !== null && typeof error === 'object' && 'code' in error && error.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT';
16
+ /** Regex is data in a constant script. V8 interrupts even catastrophic matching. `run` is a test seam. */
17
+ export function capturePattern(pattern, text, run = runInVm) {
6
18
  if (pattern.length > 200)
7
19
  throw new EngineError('invalid_expression', 'Pattern exceeds 200 characters');
8
- try {
9
- const match = matchScript.runInNewContext({ pattern, text: text.slice(0, 32_000) }, {
10
- timeout: 50, contextCodeGeneration: { strings: false, wasm: false },
11
- });
12
- return match ? Array.from(match) : null;
13
- }
14
- catch (error) {
15
- throw new EngineError('invalid_expression', error !== null && typeof error === 'object' && 'code' in error && error.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT'
16
- ? 'Regular expression exceeded 50 ms' : 'Invalid regular expression');
20
+ const input = text.slice(0, 32_000);
21
+ for (let attempt = 1;; attempt++) {
22
+ try {
23
+ const match = run(pattern, input);
24
+ return match ? Array.from(match) : null;
25
+ }
26
+ catch (error) {
27
+ if (timedOut(error) && attempt < PATTERN_ATTEMPTS)
28
+ continue;
29
+ throw new EngineError('invalid_expression', timedOut(error) ? `Regular expression exceeded ${PATTERN_TIMEOUT_MS} ms` : 'Invalid regular expression');
30
+ }
17
31
  }
18
32
  }
19
33
  export function matchesPattern(pattern, text) { return capturePattern(pattern, text) !== null; }
@@ -46,6 +46,11 @@ export declare function projectRunStatus(ledger: RunLedger, compact?: boolean):
46
46
  error?: string;
47
47
  nextNodePath: string;
48
48
  nextStep: string;
49
+ pause?: {
50
+ requestId: string;
51
+ reason: string;
52
+ at: string;
53
+ } | undefined;
49
54
  steps: {
50
55
  [k: string]: {
51
56
  kind: string;
@@ -142,6 +147,11 @@ export declare function projectRunStatus(ledger: RunLedger, compact?: boolean):
142
147
  } | undefined;
143
148
  nextNodePath: string;
144
149
  nextStep: string;
150
+ pause?: {
151
+ requestId: string;
152
+ reason: string;
153
+ at: string;
154
+ } | undefined;
145
155
  steps: {
146
156
  [k: string]: {
147
157
  kind: string;
@@ -251,6 +261,13 @@ export declare function projectRunStatus(ledger: RunLedger, compact?: boolean):
251
261
  fullContexts: number;
252
262
  incrementalContexts: number;
253
263
  };
264
+ baseline: {
265
+ invocations: number;
266
+ roles: string[];
267
+ costUsd: number | null;
268
+ inputTokens: number | null;
269
+ outputTokens: number | null;
270
+ };
254
271
  escalations: {
255
272
  role: string;
256
273
  attemptId: string;
@@ -326,6 +343,11 @@ export declare function statusRun(directory: string, compact?: boolean): Promise
326
343
  error?: string;
327
344
  nextNodePath: string;
328
345
  nextStep: string;
346
+ pause?: {
347
+ requestId: string;
348
+ reason: string;
349
+ at: string;
350
+ } | undefined;
329
351
  steps: {
330
352
  [k: string]: {
331
353
  kind: string;
@@ -422,6 +444,11 @@ export declare function statusRun(directory: string, compact?: boolean): Promise
422
444
  } | undefined;
423
445
  nextNodePath: string;
424
446
  nextStep: string;
447
+ pause?: {
448
+ requestId: string;
449
+ reason: string;
450
+ at: string;
451
+ } | undefined;
425
452
  steps: {
426
453
  [k: string]: {
427
454
  kind: string;
@@ -531,6 +558,13 @@ export declare function statusRun(directory: string, compact?: boolean): Promise
531
558
  fullContexts: number;
532
559
  incrementalContexts: number;
533
560
  };
561
+ baseline: {
562
+ invocations: number;
563
+ roles: string[];
564
+ costUsd: number | null;
565
+ inputTokens: number | null;
566
+ outputTokens: number | null;
567
+ };
534
568
  escalations: {
535
569
  role: string;
536
570
  attemptId: string;
@@ -23,10 +23,16 @@ export function projectRunStatus(ledger, compact = true) {
23
23
  const failures = ledger.db.sqlite.prepare("SELECT node_path,scope_id,branch,status,attempt,visit,ended_at,error_code,error_message FROM attempts WHERE run_id=? AND status IN ('failed','interrupted','blocked') ORDER BY started_at DESC LIMIT 8").all(ledger.runId);
24
24
  const lease = ledger.lease.current();
25
25
  const recoverable = ledger.db.sqlite.prepare("SELECT a.node_path,a.scope_id,a.attempt_id,a.status,v.effect FROM attempts a JOIN visits v ON a.visit_id=v.visit_id JOIN steps s ON s.last_attempt_id=a.attempt_id WHERE a.run_id=? AND a.terminal_digest IS NULL AND a.status IN ('running','interrupted') AND v.effect='write'").all(ledger.runId);
26
+ // A requested pause (runtime pause) records its reason on the workflow_paused event.
27
+ const pauseEvent = row.status === 'paused' && !pending.length
28
+ ? ledger.db.sqlite.prepare("SELECT at,payload_json FROM events WHERE run_id=? AND type='workflow_paused' ORDER BY sequence DESC LIMIT 1").get(ledger.runId) : undefined;
29
+ const pausePayload = pauseEvent ? JSON.parse(String(pauseEvent.payload_json)).payload : undefined;
30
+ const pause = pausePayload && typeof pausePayload.reason === 'string' && typeof pausePayload.requestId === 'string'
31
+ ? { requestId: pausePayload.requestId, reason: pausePayload.reason, at: String(pauseEvent.at) } : undefined;
26
32
  const next = pending[0]?.nodePath ?? (row.status === 'succeeded' || row.status === 'cancelled' ? null : base.nextStep);
27
33
  const state = { ...(compact ? { runId: base.runId, traceId: base.traceId, status: base.status, updatedAt: base.updatedAt, usage: base.usage,
28
34
  ...(base.pendingApproval ? { pendingApproval: base.pendingApproval } : {}), ...(base.pendingQuestion ? { pendingQuestion: base.pendingQuestion } : {}) } : base),
29
- nextNodePath: next, nextStep: next,
35
+ nextNodePath: next, nextStep: next, ...(pause ? { pause } : {}),
30
36
  steps: Object.fromEntries(steps.map(step => [String(step.node_path), { kind: String(step.kind), status: String(step.status), visits: Number(step.visits) }])),
31
37
  scopes: steps.map(step => {
32
38
  // Read only the current committed attempt. A previous successful visit is
@@ -80,6 +80,11 @@ export declare function createRun(input: DefinitionRunInput & RunObservers): Pro
80
80
  error?: string;
81
81
  nextNodePath: string;
82
82
  nextStep: string;
83
+ pause?: {
84
+ requestId: string;
85
+ reason: string;
86
+ at: string;
87
+ } | undefined;
83
88
  steps: {
84
89
  [k: string]: {
85
90
  kind: string;
@@ -176,6 +181,11 @@ export declare function createRun(input: DefinitionRunInput & RunObservers): Pro
176
181
  } | undefined;
177
182
  nextNodePath: string;
178
183
  nextStep: string;
184
+ pause?: {
185
+ requestId: string;
186
+ reason: string;
187
+ at: string;
188
+ } | undefined;
179
189
  steps: {
180
190
  [k: string]: {
181
191
  kind: string;
@@ -285,6 +295,13 @@ export declare function createRun(input: DefinitionRunInput & RunObservers): Pro
285
295
  fullContexts: number;
286
296
  incrementalContexts: number;
287
297
  };
298
+ baseline: {
299
+ invocations: number;
300
+ roles: string[];
301
+ costUsd: number | null;
302
+ inputTokens: number | null;
303
+ outputTokens: number | null;
304
+ };
288
305
  escalations: {
289
306
  role: string;
290
307
  attemptId: string;
@@ -363,6 +380,11 @@ export declare function resumeRun(directory: string, options?: ResumeDefinitionO
363
380
  error?: string;
364
381
  nextNodePath: string;
365
382
  nextStep: string;
383
+ pause?: {
384
+ requestId: string;
385
+ reason: string;
386
+ at: string;
387
+ } | undefined;
366
388
  steps: {
367
389
  [k: string]: {
368
390
  kind: string;
@@ -459,6 +481,11 @@ export declare function resumeRun(directory: string, options?: ResumeDefinitionO
459
481
  } | undefined;
460
482
  nextNodePath: string;
461
483
  nextStep: string;
484
+ pause?: {
485
+ requestId: string;
486
+ reason: string;
487
+ at: string;
488
+ } | undefined;
462
489
  steps: {
463
490
  [k: string]: {
464
491
  kind: string;
@@ -568,6 +595,13 @@ export declare function resumeRun(directory: string, options?: ResumeDefinitionO
568
595
  fullContexts: number;
569
596
  incrementalContexts: number;
570
597
  };
598
+ baseline: {
599
+ invocations: number;
600
+ roles: string[];
601
+ costUsd: number | null;
602
+ inputTokens: number | null;
603
+ outputTokens: number | null;
604
+ };
571
605
  escalations: {
572
606
  role: string;
573
607
  attemptId: string;
@@ -603,3 +637,25 @@ export declare function cancelRun(directory: string, requestId?: string): Promis
603
637
  requestId: string;
604
638
  createdAt: string;
605
639
  }>;
640
+ export declare const PAUSE_OUTCOMES: readonly ['requested', 'paused', 'already-paused', 'not-running', 'terminal'];
641
+ export type PauseOutcome = typeof PAUSE_OUTCOMES[number];
642
+ export interface PauseResult {
643
+ runId: string;
644
+ requestId: string;
645
+ reason: string;
646
+ outcome: PauseOutcome;
647
+ status: string;
648
+ createdAt?: string;
649
+ }
650
+ /**
651
+ * Cooperative pause. A run executing under a live lease receives an idempotent
652
+ * inbox request: its in-flight attempts finish and commit, the next admission is
653
+ * refused, and the owner settles `paused` with the reason and releases its lease.
654
+ * A run whose owner is gone while still marked running is settled `paused`
655
+ * directly. Terminal, already paused or otherwise stopped runs are left
656
+ * unchanged and reported as such. `runtime resume` continues every paused run.
657
+ */
658
+ export declare function pauseRun(directory: string, options?: {
659
+ requestId?: string;
660
+ reason?: string;
661
+ }): Promise<PauseResult>;
@@ -13,7 +13,7 @@ import { EngineEventStream } from './events.js';
13
13
  import { initialDefinitionState } from './state.js';
14
14
  import { preflightDefinition } from './preflight.js';
15
15
  import { observeLedger, projectRunStatus } from './run-status.js';
16
- import { ControlInbox } from './steering/inbox.js';
16
+ import { ControlInbox, PAUSE_REASON } from './steering/inbox.js';
17
17
  import { ledgerBudget } from './invocation-context.js';
18
18
  import { composeDefinitionRuntime } from './composition.js';
19
19
  import { createEngineTelemetry } from './otel.js';
@@ -145,6 +145,8 @@ async function execute(database, admitted, options, resume) {
145
145
  if (resume)
146
146
  database.transaction('workflow-resumed', () => {
147
147
  lease.assert(token);
148
+ // An explicit resume supersedes pause requests that arrived before it.
149
+ inbox.consumePauses();
148
150
  appendRunEvent(database, runId, 'workflow_resumed', {});
149
151
  });
150
152
  stream.flush();
@@ -168,21 +170,30 @@ async function execute(database, admitted, options, resume) {
168
170
  if (controller.signal.reason instanceof EngineError && controller.signal.reason.code === 'lease_lost')
169
171
  throw controller.signal.reason;
170
172
  const reason = controller.signal.aborted ? controller.signal.reason : error;
171
- const code = reason instanceof EngineError || reason && typeof reason === 'object' && 'code' in reason ? String(reason.code) : 'execution_failed';
173
+ const pause = controller.signal.aborted ? undefined : pauseOf(error);
174
+ const code = pause ? 'paused' : reason instanceof EngineError || reason && typeof reason === 'object' && 'code' in reason ? String(reason.code) : 'execution_failed';
172
175
  const message = reason instanceof Error ? reason.message : String(reason);
176
+ let settled;
173
177
  database.transaction('execution-stopped', () => {
174
178
  lease.assert(token);
175
179
  const row = ledger.run();
176
180
  if (row.completion_json)
177
181
  return;
178
- const status = code === 'aborted' ? 'cancelled' : row.status === 'interrupted' || code === 'recover_required' ? 'interrupted' : 'failed';
182
+ const status = code === 'aborted' ? 'cancelled' : pause ? 'paused' : row.status === 'interrupted' || code === 'recover_required' ? 'interrupted' : 'failed';
183
+ settled = status;
179
184
  database.put('runs', { ...row, status, active_duration_ms: ledger.activeDurationMs(), active_started_at: null, updated_at: new Date().toISOString() });
180
- appendRunEvent(database, runId, status === 'cancelled' ? 'workflow_cancelled' : 'workflow_failed', { error: { code, message } });
185
+ if (pause) {
186
+ inbox.consumePauses();
187
+ appendRunEvent(database, runId, 'workflow_paused', { requestId: pause.requestId, reason: pause.reason });
188
+ }
189
+ else
190
+ appendRunEvent(database, runId, status === 'cancelled' ? 'workflow_cancelled' : 'workflow_failed', { error: { code, message } });
181
191
  });
182
192
  if (inbox.cancellationRequested())
183
193
  inbox.acknowledgeCancellation(token);
184
194
  const status = projectRunStatus(ledger);
185
- return { ...status, error: { code, message } };
195
+ // A requested pause is a resumable stop, not a failure.
196
+ return settled === 'paused' ? status : { ...status, error: { code, message } };
186
197
  }
187
198
  finally {
188
199
  clearInterval(controlTimer);
@@ -194,6 +205,18 @@ async function execute(database, admitted, options, resume) {
194
205
  await telemetry?.close();
195
206
  }
196
207
  }
208
+ /** The pause request carried by an admission refusal, also when several branches refused together. */
209
+ function pauseOf(error) {
210
+ const errors = error instanceof AggregateError ? error.errors : [error];
211
+ for (const value of errors) {
212
+ if (value instanceof EngineError && value.code === 'paused' && value.details && typeof value.details === 'object' && !Array.isArray(value.details)) {
213
+ const details = value.details;
214
+ if (typeof details.requestId === 'string' && typeof details.reason === 'string')
215
+ return { requestId: details.requestId, reason: details.reason };
216
+ }
217
+ }
218
+ return undefined;
219
+ }
197
220
  function validateAnswers(ledger, answers) {
198
221
  const known = new Map(ledger.interrupts().map(value => [value.id, value]));
199
222
  for (const [id, value] of Object.entries(answers)) {
@@ -245,3 +268,63 @@ export async function cancelRun(directory, requestId) {
245
268
  database.close();
246
269
  }
247
270
  }
271
+ export const PAUSE_OUTCOMES = ['requested', 'paused', 'already-paused', 'not-running', 'terminal'];
272
+ /**
273
+ * Cooperative pause. A run executing under a live lease receives an idempotent
274
+ * inbox request: its in-flight attempts finish and commit, the next admission is
275
+ * refused, and the owner settles `paused` with the reason and releases its lease.
276
+ * A run whose owner is gone while still marked running is settled `paused`
277
+ * directly. Terminal, already paused or otherwise stopped runs are left
278
+ * unchanged and reported as such. `runtime resume` continues every paused run.
279
+ */
280
+ export async function pauseRun(directory, options = {}) {
281
+ const reason = options.reason ?? 'requested', requestId = options.requestId ?? randomUUID();
282
+ if (!PAUSE_REASON.test(reason))
283
+ throw new EngineError('invalid_arguments', 'Pause reason must be a lowercase token of 1–64 characters (for example requested or budget)');
284
+ if (!requestId || requestId.length > 256)
285
+ throw new EngineError('invalid_arguments', 'Control request ID must contain 1–256 characters');
286
+ const database = await RunDatabase.open(path.join(directory, 'run.sqlite'));
287
+ try {
288
+ const view = observeLedger(database), runId = view.runId;
289
+ const result = (outcome, createdAt) => ({ runId, requestId, reason, outcome, status: String(view.run().status), ...(createdAt ? { createdAt } : {}) });
290
+ const terminal = () => { const row = view.run(); return !!row.completion_json || row.status === 'succeeded' || row.status === 'cancelled'; };
291
+ if (terminal())
292
+ return result('terminal');
293
+ // Only this exact package can resume the run, so only it may pause it.
294
+ if (!sameRuntimeIdentity(JSON.parse(String(view.run().runtime_identity_json)), coreRuntimeIdentity()))
295
+ throw new EngineError('resume_incompatible', 'Pause requires the retained original Core package');
296
+ const lease = new RunLease(database, runId);
297
+ if ((lease.current()?.expiresAt ?? 0) > Date.now()) {
298
+ database.upgradeSchema();
299
+ const accepted = new ControlInbox(database, runId).pause(reason, requestId);
300
+ return result('requested', accepted.createdAt);
301
+ }
302
+ if (view.run().status === 'paused')
303
+ return result('already-paused');
304
+ if (view.run().status !== 'running')
305
+ return result('not-running');
306
+ const token = lease.acquire(randomUUID());
307
+ let outcome = 'paused';
308
+ try {
309
+ const ledger = new RunLedger(database, token, { maxTransitions: 1 });
310
+ database.transaction('workflow-paused', () => {
311
+ lease.assert(token);
312
+ const run = ledger.run();
313
+ // The owner may have settled between the observation and the lease.
314
+ if (run.completion_json || run.status !== 'running') {
315
+ outcome = run.status === 'paused' ? 'already-paused' : run.completion_json || run.status === 'succeeded' || run.status === 'cancelled' ? 'terminal' : 'not-running';
316
+ return;
317
+ }
318
+ database.put('runs', { ...run, status: 'paused', active_duration_ms: ledger.activeDurationMs(), active_started_at: null, updated_at: new Date().toISOString() });
319
+ appendRunEvent(database, runId, 'workflow_paused', { requestId, reason });
320
+ });
321
+ }
322
+ finally {
323
+ lease.release(token);
324
+ }
325
+ return result(outcome);
326
+ }
327
+ finally {
328
+ database.close();
329
+ }
330
+ }
@@ -4,6 +4,7 @@ export declare const MAX_STEERING_CHARACTERS = 20000;
4
4
  export declare const MAX_STEERING_BYTES = 80000;
5
5
  export declare const MAX_PENDING_STEERING = 128;
6
6
  export declare const MAX_PENDING_STEERING_BYTES: number;
7
+ export declare const PAUSE_REASON: RegExp;
7
8
  export interface SteeringMessage {
8
9
  id: string;
9
10
  text: string;
@@ -34,6 +35,12 @@ export declare class ControlInbox {
34
35
  requestId: string;
35
36
  createdAt: string;
36
37
  };
38
+ /** Cooperative pause: the owner stops before admitting its next attempt and settles `paused`. */
39
+ pause(reason: string, requestId?: string): {
40
+ requestId: string;
41
+ createdAt: string;
42
+ reason: string;
43
+ };
37
44
  private appendControl;
38
45
  pending(): SteeringMessage[];
39
46
  /** Bounded read-only projection. Counts cover the whole inbox, previews never expose full prompts. */
@@ -48,6 +55,14 @@ export declare class ControlInbox {
48
55
  /** Called inside attempt admission, after the attempt row exists and the lease was fenced. */
49
56
  claimForAttempt(frame: AttemptFrame): SteeringMessage[];
50
57
  cancellationRequested(): boolean;
58
+ /** The oldest unconsumed pause request, if any. */
59
+ pauseRequested(): {
60
+ requestId: string;
61
+ reason: string;
62
+ createdAt: string;
63
+ } | undefined;
64
+ /** Marks every pending pause request consumed. Callers hold the lease inside their own transaction. */
65
+ consumePauses(): void;
51
66
  acknowledgeCancellation(token: ExecutionLease): void;
52
67
  }
53
68
  /** Operator text is delimited and cannot replace frozen acceptance obligations/instructions. */
@@ -7,6 +7,7 @@ export const MAX_STEERING_CHARACTERS = 20_000;
7
7
  export const MAX_STEERING_BYTES = 80_000;
8
8
  export const MAX_PENDING_STEERING = 128;
9
9
  export const MAX_PENDING_STEERING_BYTES = 512 * 1024;
10
+ export const PAUSE_REASON = /^[a-z][a-z0-9-]{0,63}$/;
10
11
  const project = (row) => {
11
12
  const value = JSON.parse(String(row.payload_json));
12
13
  return { id: String(row.request_id), ...value, createdAt: String(row.created_at),
@@ -33,6 +34,13 @@ export class ControlInbox {
33
34
  const row = this.appendControl('cancel', {}, requestId);
34
35
  return { requestId: String(row.request_id), createdAt: String(row.created_at) };
35
36
  }
37
+ /** Cooperative pause: the owner stops before admitting its next attempt and settles `paused`. */
38
+ pause(reason, requestId = randomUUID()) {
39
+ if (!PAUSE_REASON.test(reason))
40
+ throw new EngineError('invalid_arguments', 'Pause reason must be a lowercase token of 1–64 characters (for example requested or budget)');
41
+ const row = this.appendControl('pause', { reason }, requestId);
42
+ return { requestId: String(row.request_id), createdAt: String(row.created_at), reason: JSON.parse(String(row.payload_json)).reason };
43
+ }
36
44
  appendControl(kind, payload, requestId) {
37
45
  if (!requestId || requestId.length > 256)
38
46
  throw new EngineError('invalid_arguments', 'Control request ID must contain 1–256 characters');
@@ -56,7 +64,7 @@ export class ControlInbox {
56
64
  }
57
65
  const at = new Date().toISOString();
58
66
  this.database.put('control_inbox', { request_id: requestId, run_id: this.runId, kind, payload_json: encoded, created_at: at, received_sequence: run.next_event_sequence });
59
- appendRunEvent(this.database, this.runId, kind === 'steer' ? 'steering_received' : 'cancellation_requested', { requestId }, {}, at);
67
+ appendRunEvent(this.database, this.runId, kind === 'steer' ? 'steering_received' : kind === 'pause' ? 'pause_requested' : 'cancellation_requested', kind === 'pause' ? { requestId, reason: payload.reason } : { requestId }, {}, at);
60
68
  return this.database.get('control_inbox', { request_id: requestId });
61
69
  });
62
70
  }
@@ -95,6 +103,18 @@ export class ControlInbox {
95
103
  cancellationRequested() {
96
104
  return !!this.database.sqlite.prepare("SELECT 1 FROM control_inbox WHERE run_id=? AND kind='cancel' AND consumed_at IS NULL").get(this.runId);
97
105
  }
106
+ /** The oldest unconsumed pause request, if any. */
107
+ pauseRequested() {
108
+ const row = this.database.sqlite.prepare("SELECT * FROM control_inbox WHERE run_id=? AND kind='pause' AND consumed_at IS NULL ORDER BY received_sequence,request_id LIMIT 1").get(this.runId);
109
+ return row ? { requestId: String(row.request_id), reason: JSON.parse(String(row.payload_json)).reason, createdAt: String(row.created_at) } : undefined;
110
+ }
111
+ /** Marks every pending pause request consumed. Callers hold the lease inside their own transaction. */
112
+ consumePauses() {
113
+ const at = new Date().toISOString();
114
+ for (const row of this.database.sqlite.prepare("SELECT * FROM control_inbox WHERE run_id=? AND kind='pause' AND consumed_at IS NULL").all(this.runId)) {
115
+ this.database.put('control_inbox', { ...row, consumed_at: at });
116
+ }
117
+ }
98
118
  acknowledgeCancellation(token) {
99
119
  this.database.transaction('cancellation-consumed', () => {
100
120
  new RunLease(this.database, this.runId).assert(token);
@@ -19,12 +19,18 @@ if($actual.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $i
19
19
  foreach($entry in $actual.GetAccessRules($true,$true,[System.Security.Principal.SecurityIdentifier])) {
20
20
  if($entry.IdentityReference.Value -notin @($identity.User.Value,'S-1-5-18') -or $entry.AccessControlType -ne 'Allow'){throw 'Private directory grants broad access'}
21
21
  }`;
22
+ // A cold `powershell.exe` that loads the .NET AccessControl types has taken
23
+ // more than 30 s on a loaded windows-latest runner (run 39675761584: the first
24
+ // RunDatabase.open of a file failed with the total execution limit). The bound
25
+ // only protects against a hung PowerShell; a slow one must not fail closed
26
+ // storage creation, so it gets the same headroom as other Windows cold starts.
27
+ const WINDOWS_ACL_TIMEOUT_MS = 120_000;
22
28
  async function setPrivateWindowsAcl(target, cwd) {
23
29
  const env = { ...process.env, SPECRAILS_PRIVATE_PATH: target };
24
30
  for (const key of Object.keys(env))
25
31
  if (key.toLowerCase() === 'psmodulepath')
26
32
  delete env[key];
27
- const result = await runCliProcess({ command: 'powershell.exe', args: ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(aclScript, 'utf16le').toString('base64')] }, { cwd, timeoutMs: 30_000, env });
33
+ const result = await runCliProcess({ command: 'powershell.exe', args: ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(aclScript, 'utf16le').toString('base64')] }, { cwd, timeoutMs: WINDOWS_ACL_TIMEOUT_MS, env });
28
34
  if (result.exitCode !== 0)
29
35
  throw new PersistenceError('private_storage_unavailable', `Cannot protect engine storage: ${result.stderr.slice(0, 2000)}`);
30
36
  }
@@ -53,7 +53,7 @@ function fixtureRegistry(test, context, onPrompt) {
53
53
  await tools.execute({ action: 'new' });
54
54
  const artifacts = [
55
55
  ['proposal', 'proposal.md', `## Why\n${test.description}\n## What Changes\n- Implement the requested behavior.\n## Capabilities\n### New Capabilities\n- \`fixture-behavior\`: ${test.description}\n### Modified Capabilities\nNone.\n## Impact\nSelected repositories.\n`],
56
- ['design', 'design.md', `## Context\n${test.description}\n## Decisions\nUse the existing implementation.cjs pattern and keep the public function boundary.`],
56
+ ['design', 'design.md', `## Context\n${test.description}\n## Decisions\nUse the existing implementation.cjs pattern and keep the public function boundary.\n## Non-Goals\n- No change outside implementation.cjs.`],
57
57
  ['specs', 'specs/fixture-behavior/spec.md', `## ADDED Requirements\n### Requirement: Requested behavior\nThe implementation SHALL satisfy the frozen behavior.\n#### Scenario: Supported input\n- **WHEN** a supported input is supplied\n- **THEN** the expected value is returned without losing required ordering or boundary behavior\n`],
58
58
  ['tasks', 'tasks.md', '## 1. Implementation\n- [ ] 1.1 Implement and test the requested behavior\n'],
59
59
  ];