ai-foreman 1.7.12 → 1.7.14

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 (91) hide show
  1. package/README.md +47 -2
  2. package/dist/activity.d.ts +5 -1
  3. package/dist/activity.js +1 -1
  4. package/dist/adapters/claude.d.ts +12 -0
  5. package/dist/adapters/claude.js +255 -23
  6. package/dist/adapters/codex.d.ts +7 -0
  7. package/dist/adapters/codex.js +149 -17
  8. package/dist/adapters/recovering.d.ts +4 -0
  9. package/dist/adapters/recovering.js +17 -1
  10. package/dist/adapters/types.d.ts +69 -1
  11. package/dist/agentRun.d.ts +2 -0
  12. package/dist/agentRun.js +28 -4
  13. package/dist/branch/currentGuard.d.ts +2 -0
  14. package/dist/branch/currentGuard.js +9 -0
  15. package/dist/branch/finalization.d.ts +22 -0
  16. package/dist/branch/finalization.js +120 -0
  17. package/dist/branch/git.js +7 -2
  18. package/dist/branch/resume.js +11 -1
  19. package/dist/branch/runner.d.ts +12 -3
  20. package/dist/branch/runner.js +381 -125
  21. package/dist/buildRuns.d.ts +7 -2
  22. package/dist/buildRuns.js +109 -35
  23. package/dist/cli/doctor.d.ts +7 -0
  24. package/dist/cli/doctor.js +84 -1
  25. package/dist/cli/manager.d.ts +17 -0
  26. package/dist/cli/manager.js +135 -0
  27. package/dist/cli/recovery.d.ts +4 -0
  28. package/dist/cli/recovery.js +80 -0
  29. package/dist/cli/start.js +543 -79
  30. package/dist/cli/state.d.ts +4 -0
  31. package/dist/cli/state.js +69 -0
  32. package/dist/cli/status.js +13 -2
  33. package/dist/cli/tickets.js +26 -8
  34. package/dist/config.d.ts +2 -0
  35. package/dist/config.js +23 -0
  36. package/dist/continuity.d.ts +4 -0
  37. package/dist/continuity.js +38 -10
  38. package/dist/diagnostics.d.ts +25 -0
  39. package/dist/diagnostics.js +428 -0
  40. package/dist/foreman.d.ts +34 -6
  41. package/dist/foreman.js +164 -90
  42. package/dist/handoffs.d.ts +10 -3
  43. package/dist/handoffs.js +94 -10
  44. package/dist/index.js +8 -0
  45. package/dist/log.d.ts +2 -2
  46. package/dist/log.js +4 -1
  47. package/dist/managerPacket.d.ts +25 -0
  48. package/dist/managerPacket.js +83 -0
  49. package/dist/markers.d.ts +1 -1
  50. package/dist/markers.js +4 -1
  51. package/dist/observability.d.ts +248 -0
  52. package/dist/observability.js +1018 -0
  53. package/dist/planChanges.d.ts +4 -0
  54. package/dist/planChanges.js +16 -0
  55. package/dist/projectDiagnostics.d.ts +27 -0
  56. package/dist/projectDiagnostics.js +407 -0
  57. package/dist/qaFailureDelivery.d.ts +100 -0
  58. package/dist/qaFailureDelivery.js +570 -0
  59. package/dist/qaProtocolV2.d.ts +233 -0
  60. package/dist/qaProtocolV2.js +114 -0
  61. package/dist/qaRecovery.d.ts +152 -0
  62. package/dist/qaRecovery.js +989 -0
  63. package/dist/qaReview.d.ts +153 -14
  64. package/dist/qaReview.js +1747 -71
  65. package/dist/qaRuntime.d.ts +14 -0
  66. package/dist/qaRuntime.js +44 -0
  67. package/dist/qaSnapshot.d.ts +65 -5
  68. package/dist/qaSnapshot.js +473 -130
  69. package/dist/recoveryPolicy.d.ts +37 -0
  70. package/dist/recoveryPolicy.js +251 -0
  71. package/dist/roles.js +1 -0
  72. package/dist/stateTransfer.d.ts +57 -0
  73. package/dist/stateTransfer.js +644 -0
  74. package/dist/statusReporter.d.ts +2 -0
  75. package/dist/statusReporter.js +6 -0
  76. package/dist/supervisor.d.ts +40 -0
  77. package/dist/supervisor.js +102 -0
  78. package/dist/ticketHistoryReader.d.ts +19 -0
  79. package/dist/ticketHistoryReader.js +58 -0
  80. package/dist/tickets/details.d.ts +8 -0
  81. package/dist/tickets/details.js +59 -11
  82. package/dist/tickets/output.d.ts +5 -0
  83. package/dist/tickets/output.js +73 -0
  84. package/dist/tickets/stateDb.d.ts +1 -0
  85. package/dist/tickets/stateDb.js +5 -0
  86. package/dist/workflowDb.d.ts +275 -3
  87. package/dist/workflowDb.js +839 -29
  88. package/dist/workflowReader.d.ts +42 -0
  89. package/dist/workflowReader.js +171 -0
  90. package/foreman.yaml +9 -0
  91. package/package.json +63 -3
package/README.md CHANGED
@@ -70,6 +70,7 @@ ai-foreman doctor ./my-project
70
70
  ai-foreman tickets init --project ./my-project --app-name "My App"
71
71
  ai-foreman tickets populate --project ./my-project --agent codex --model gpt-5.5 --effort xhigh
72
72
  ai-foreman start ./my-project --agent codex --model gpt-5.5 --effort xhigh --steps 5
73
+ ai-foreman state export ./my-project --output rafi-state.rafi.gz
73
74
  ```
74
75
 
75
76
  What each part does:
@@ -89,6 +90,20 @@ What each part does:
89
90
  - ticket files
90
91
  - folders or globs containing planning notes
91
92
 
93
+ ## Move Local State Between Machines
94
+
95
+ Use `ai-foreman state export` for a sequential handoff from one checkout to another:
96
+
97
+ ```bash
98
+ ai-foreman state export ./my-project --output rafi-state.rafi.gz
99
+ ai-foreman state inspect rafi-state.rafi.gz
100
+ ai-foreman state import ./matching-checkout rafi-state.rafi.gz
101
+ ```
102
+
103
+ The bundle includes the local tracker database, delivery state, workflow/recovery databases, compiled role bundles, source snapshots, interview recovery records, and diagnostic run/recovery files. It does not include source code, provider sessions, or live worktrees. Export/import refuse dirty Git checkouts, active workflow leases, incompatible branch/HEAD state, and divergent transfer lineage. Import asks before replacing existing local state unless `--yes` is supplied, keeps a backup under `.rafi/state-transfer-backups/`, and rolls back failed imports.
104
+
105
+ Provider sessions are historical after import. Resume with `rafi build:resume <project> --fresh-with-handoff` or the equivalent guided recovery path when exact session recovery is unavailable.
106
+
92
107
  ## Primary Options
93
108
 
94
109
  ### `ai-foreman start`
@@ -148,6 +163,23 @@ With the saved `current` strategy, Foreman works in the active branch while the
148
163
 
149
164
  The current terminal status includes the role/provider, activity, truthful context state, successful compaction count, and handoff generation. At the Builder threshold, Foreman settles the in-flight action, verifies native compaction plus a fresh provider usage sample, and resumes the frozen action. The configured maximum defaults to ten successful compactions per location-scoped provider session; the next crossing uses a validated fresh handoff instead of exceeding it. Disposable QA snapshots never reuse a prior QA conversation in a new `/tmp/rafi-qa-*` worktree; they transfer cumulative state through an accepted handoff.
150
165
 
166
+ Failed QA reviews use the versioned `RAFI_QA_FAILURE_REPORT_START` / `RAFI_QA_FAILURE_REPORT_END` JSON contract. Foreman validates the report before Builder remediation, namespaces finding IDs by run, ticket, and review, and durably indexes the exact report, remediation response, and bounded fix summary by digest. A malformed failing report receives two correction-only turns in its original session, then a validated fresh successor performs one complete source-bound review followed by up to two correction-only turns. Any later operator-requested fresh successor performs one complete source-bound review followed by five usable correction-only turns.
167
+
168
+ Owner-only recovery packets under `.foreman/qa-report-recovery/` bind the review to a full frozen source digest: commit, porcelain status, staged and unstaged bytes, and untracked content. Every fresh attempt captures and compares that complete state. Drift appends the new state and forces a complete review; correction-only reconstruction is never applied to changed source. Packet revisions and the authoritative WorkflowDb recovery head advance together. A successor must have a location-scoped identity, accept a handoff containing the complete resource inventory (purpose, byte count, digest, and recovery requirement), and acknowledge the copied packet before reporting. Paused recovery resumes with `rafi build:resume <project> --run <run-id> --ticket <ticket-id> --fresh-with-handoff`.
169
+
170
+ ### `ai-foreman manager`
171
+
172
+ Manager is a project-wide, read-only diagnostic conversation over every retained build run. It starts with a verified live run when one exists, otherwise the most recently updated run; `--run <id>` changes only that initial focus. Each question refreshes the catalog, active state, deterministic aggregates, and relevant run details.
173
+
174
+ Permanent sanitized run summaries keep timing, counts, provider/model configuration, usage coverage, failure category, and terminal Git facts useful after detailed spans expire. Detailed spans retain the configured 30-day policy. Missing historical facts are reported as partial rather than reconstructed or treated as zero.
175
+
176
+ When the initial bounded packet omits evidence needed for a question, Manager can request catalog rows, aggregates, comparisons, or details through a host-validated read-only lookup. Each question is limited to two rounds, six operations per round, five detailed runs per operation, and fifty catalog rows per list operation. Questions, answers, and lookup envelopes are not stored as project evidence.
177
+
178
+ ```bash
179
+ ai-foreman manager ./my-project
180
+ ai-foreman manager ./my-project --run <run-id> --ask "Compare this run with the last five successful runs"
181
+ ```
182
+
151
183
  ### GitHub PR Failure Recovery
152
184
 
153
185
  When `--create-pr` cannot prepare GitHub, push a branch, or create a PR, Foreman records a structured failure and blocks the affected ticket cleanly. Blocked worktrees are retained automatically so you can repair the environment and retry without losing the ticket session.
@@ -482,6 +514,11 @@ ai-foreman start ./my-project --steps 5 --no-qa
482
514
  # Resume the latest session / a specific session
483
515
  ai-foreman start ./my-project --steps 5 --continue
484
516
  ai-foreman start ./my-project --steps 5 --resume <session-id>
517
+
518
+ # Export, inspect, and import local Rafi state
519
+ ai-foreman state export ./my-project --output rafi-state.rafi.gz
520
+ ai-foreman state inspect rafi-state.rafi.gz
521
+ ai-foreman state import ./matching-checkout rafi-state.rafi.gz
485
522
  ```
486
523
 
487
524
  ## Ticket Lifecycle Commands
@@ -543,16 +580,24 @@ STEP_STATUS: needs_input | question="Which storage backend?" choices="SQLite|Pos
543
580
 
544
581
  Provider-native `AskUserQuestion` prompts preserve their existing input/output behavior. Hosts may also observe a question only after the user successfully supplies a non-empty answer; cancelled, denied, malformed, and empty prompts do not emit answered-question telemetry. Rafi uses this boundary to recognize machine-shaped exhaustive-planning exchanges without treating generic questions as grill-me coverage.
545
582
 
546
- When QA is enabled, Foreman asks the builder to review its own work:
583
+ When QA is enabled, Foreman creates an independent read-only review snapshot. A pass ends with:
547
584
 
548
585
  ```txt
549
586
  STEP_STATUS: qa_pass | summary="tests pass and acceptance criteria are met"
587
+ ```
588
+
589
+ Failure uses a structured report immediately before the status marker (the short `issues` field is only a human synopsis and fallback input):
590
+
591
+ ```txt
592
+ RAFI_QA_FAILURE_REPORT_START
593
+ {"version":1,"summary":"missing empty-config coverage","checks_run":[{"check":"tests","command":"pnpm test","outcome":"failed","evidence":"empty-config case is absent"}],"findings":[{"id":"F1","requirement":"empty config is handled","locations":["src/config.ts","test/config.test.ts"],"problem":"no regression case exists","evidence":"test search found no empty-config case","expected":"the suite covers empty config","fix_direction":"add the missing case and repair behavior if needed","verification":["pnpm test"]}],"observations":[]}
594
+ RAFI_QA_FAILURE_REPORT_END
550
595
  STEP_STATUS: qa_fail | issues="missing test for empty config"
551
596
  ```
552
597
 
553
598
  If QA fails:
554
599
 
555
- - Foreman sends a fix instruction.
600
+ - Foreman sends the Builder the complete validated report, ticket requirements, current source digest, and prior report/remediation history.
556
601
  - Foreman reruns QA.
557
602
  - QA turns do not count against `--steps`.
558
603
 
@@ -63,7 +63,11 @@ export declare class ActivityReporter {
63
63
  export declare function currentActivity(): ActivityReporter | undefined;
64
64
  export declare function withActivityContext<T>(command: string, operation: () => Promise<T>, options?: ActivityReporterOptions): Promise<T>;
65
65
  export declare function withActivityPhase<T>(label: string, operation: () => Promise<T>): Promise<T>;
66
- export declare function pauseActivityForInput<T>(operation: () => Promise<T>): Promise<T>;
66
+ export interface ActivityInputPauseMetadata {
67
+ label?: string;
68
+ context?: Record<string, unknown>;
69
+ }
70
+ export declare function pauseActivityForInput<T>(operation: () => Promise<T>, _metadata?: ActivityInputPauseMetadata): Promise<T>;
67
71
  export declare function reportBuilderEvent(event: BuilderEvent): void;
68
72
  /** Async event queue that also feeds the active CLI reporter without consuming the stream. */
69
73
  export declare class BuilderEventQueue extends AsyncQueue<BuilderEvent> {
package/dist/activity.js CHANGED
@@ -225,7 +225,7 @@ export async function withActivityPhase(label, operation) {
225
225
  end?.();
226
226
  }
227
227
  }
228
- export async function pauseActivityForInput(operation) {
228
+ export async function pauseActivityForInput(operation, _metadata = {}) {
229
229
  const resume = currentActivity()?.pause();
230
230
  try {
231
231
  return await operation();
@@ -50,6 +50,9 @@ export declare class ClaudeAdapter implements BuilderAdapter {
50
50
  private readonly pumpDone;
51
51
  private _sessionId?;
52
52
  private _sessionRef?;
53
+ private readonly sessionIdentityReady;
54
+ private resolveSessionIdentity;
55
+ private rejectSessionIdentity;
53
56
  private readonly stderrChunks;
54
57
  private turnSignals;
55
58
  private structuredError?;
@@ -62,6 +65,10 @@ export declare class ClaudeAdapter implements BuilderAdapter {
62
65
  private nativeCompactions;
63
66
  private nativeCompactionSequence;
64
67
  private cumulativeUsage;
68
+ private priorTurnUsage;
69
+ private activeProviderTurnId?;
70
+ private activeProviderTurnSpanId?;
71
+ private readonly toolSpans;
65
72
  private pending?;
66
73
  private terminalResult?;
67
74
  private streamEnded;
@@ -74,11 +81,15 @@ export declare class ClaudeAdapter implements BuilderAdapter {
74
81
  sendTurn(text: string): Promise<TurnResult>;
75
82
  private sendTurnInternal;
76
83
  private captureStderr;
84
+ private observationContext;
85
+ private observeToolResults;
77
86
  sessionId(): string | undefined;
78
87
  sessionRef(): ProviderSessionRefV1 | undefined;
88
+ prepareSession(timeoutMs?: number): Promise<ProviderSessionRefV1>;
79
89
  adoptSessionRef(ref: ProviderSessionRefV1): void;
80
90
  validateSession(): Promise<SessionAvailabilityV1>;
81
91
  compact(): Promise<CompactResult>;
92
+ private compactInternal;
82
93
  prepareAutoCompaction(thresholdPercent?: number | undefined): Promise<NativeAutoCompactionPolicy | void>;
83
94
  autoCompactionPolicy(): NativeAutoCompactionPolicy | undefined;
84
95
  drainNativeCompactions(): NativeCompaction[];
@@ -88,6 +99,7 @@ export declare class ClaudeAdapter implements BuilderAdapter {
88
99
  switchSettings(settings: ProviderSettingSwitch): Promise<CompactResult>;
89
100
  events(): AsyncIterable<BuilderEvent>;
90
101
  close(): Promise<void>;
102
+ private failSessionIdentity;
91
103
  private observeSession;
92
104
  private settlePending;
93
105
  private streamFailureResult;
@@ -1,3 +1,5 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { dirname, join } from "node:path";
1
3
  /** Lazy-load the Claude Agent SDK. Throws an actionable error if not installed. */
2
4
  export async function requireClaudeSDK() {
3
5
  try {
@@ -21,25 +23,42 @@ import { SessionUnavailableError, sessionUnavailableResult } from "./sessionFail
21
23
  * `canUseTool` is omitted here — it's a closure that the constructor adds.
22
24
  */
23
25
  export function buildClaudeQueryOptions(opts) {
26
+ const qaReadOnly = opts.sessionRole === "qa" || opts.sandboxMode === "read-only";
24
27
  const base = {
25
28
  cwd: opts.cwd,
26
29
  pathToClaudeCodeExecutable: opts.runtimeExecutable,
27
- env: { ...process.env },
30
+ env: qaReadOnly ? qaEnvironment(process.env, opts.cwd) : { ...process.env },
28
31
  model: opts.model,
29
32
  resume: opts.resumeSessionRef?.sessionId ?? opts.resumeSessionId,
30
- permissionMode: "acceptEdits",
33
+ permissionMode: qaReadOnly ? "default" : "acceptEdits",
31
34
  effort: opts.effort,
32
35
  extraArgs: opts.fast ? { fast: null } : undefined,
33
- settingSources: ["user", "project", "local"],
36
+ settingSources: qaReadOnly ? [] : ["user", "project", "local"],
37
+ ...(qaReadOnly ? { disallowedTools: ["Write", "Edit", "NotebookEdit"] } : {}),
38
+ ...(qaReadOnly ? { sandbox: {
39
+ enabled: true,
40
+ failIfUnavailable: true,
41
+ autoAllowBashIfSandboxed: true,
42
+ allowUnsandboxedCommands: false,
43
+ filesystem: { allowWrite: [join(dirname(opts.cwd), "scratch")], denyWrite: [opts.cwd, ...(opts.configRoot ? [opts.configRoot] : [])] },
44
+ } } : {}),
34
45
  };
35
- if (opts.systemPromptAppend) {
36
- base.systemPrompt = { type: "preset", preset: "claude_code", append: opts.systemPromptAppend };
46
+ const exactSkills = opts.preloadedSkillContent?.length
47
+ ? ["# Preloaded Skills", "Use the following skills for this run.", ...opts.preloadedSkillContent.map((skill) => skill.content)].join("\n\n")
48
+ : undefined;
49
+ const systemAppend = [opts.systemPromptAppend, exactSkills].filter((part) => Boolean(part)).join("\n\n");
50
+ if (systemAppend) {
51
+ base.systemPrompt = { type: "preset", preset: "claude_code", append: systemAppend };
37
52
  }
38
53
  if (opts.skills !== undefined) {
39
54
  base.skills = opts.skills;
40
55
  }
41
56
  return base;
42
57
  }
58
+ function qaEnvironment(source, cwd) {
59
+ const allowed = ["PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "LANG", "LC_ALL", "TERM", "NO_COLOR", "FORCE_COLOR", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS"];
60
+ return { ...Object.fromEntries(allowed.flatMap((key) => source[key] === undefined ? [] : [[key, source[key]]])), TMPDIR: join(dirname(cwd), "scratch") };
61
+ }
43
62
  export async function probeClaudeSession(ref, input) {
44
63
  const now = input.now ?? new Date();
45
64
  const local = validateProviderSessionScope(ref, {
@@ -147,6 +166,9 @@ export class ClaudeAdapter {
147
166
  pumpDone;
148
167
  _sessionId;
149
168
  _sessionRef;
169
+ sessionIdentityReady;
170
+ resolveSessionIdentity;
171
+ rejectSessionIdentity;
150
172
  stderrChunks = [];
151
173
  turnSignals = [];
152
174
  structuredError;
@@ -159,6 +181,10 @@ export class ClaudeAdapter {
159
181
  nativeCompactions = [];
160
182
  nativeCompactionSequence = 0;
161
183
  cumulativeUsage = { observedAt: new Date(0).toISOString(), source: "provider" };
184
+ priorTurnUsage = {};
185
+ activeProviderTurnId;
186
+ activeProviderTurnSpanId;
187
+ toolSpans = new Map();
162
188
  pending;
163
189
  terminalResult;
164
190
  streamEnded = false;
@@ -205,11 +231,28 @@ export class ClaudeAdapter {
205
231
  this.opts = opts;
206
232
  this._sessionId = opts.resumeSessionRef?.sessionId ?? opts.resumeSessionId;
207
233
  this._sessionRef = opts.resumeSessionRef;
234
+ this.sessionIdentityReady = new Promise((resolve, reject) => {
235
+ this.resolveSessionIdentity = resolve;
236
+ this.rejectSessionIdentity = reject;
237
+ });
238
+ void this.sessionIdentityReady.catch(() => { });
208
239
  this.query = query({
209
240
  prompt: this.inbox,
210
241
  options: {
211
242
  ...buildClaudeQueryOptions(opts),
212
243
  abortController: this.abort,
244
+ hooks: { SessionStart: [{ hooks: [async (input) => {
245
+ if (input.hook_event_name !== "SessionStart" || input.agent_id)
246
+ return {};
247
+ try {
248
+ this.observeSession(input.session_id, input.cwd);
249
+ }
250
+ catch (error) {
251
+ this.failSessionIdentity(error instanceof Error ? error : new Error(String(error)));
252
+ throw error;
253
+ }
254
+ return {};
255
+ }] }] },
213
256
  stderr: (data) => this.captureStderr(data),
214
257
  canUseTool: async (toolName, input, requestOptions = {}) => {
215
258
  const decision = await opts.permission({
@@ -246,6 +289,7 @@ export class ClaudeAdapter {
246
289
  const message = normalizeRuntimeErrorText("claude", rawMessage, null, "builder stream");
247
290
  this.eventQueue.push({ kind: "error", message });
248
291
  const result = this.streamFailureResult(message, isMissingClaudeSession(rawMessage));
292
+ this.rejectSessionIdentity(new Error(message));
249
293
  this.terminalResult = result;
250
294
  this.settlePending(result);
251
295
  }
@@ -260,13 +304,14 @@ export class ClaudeAdapter {
260
304
  }
261
305
  if (!this.closed && !this.terminalResult)
262
306
  this.terminalResult = this.streamFailureResult("Claude stream ended without a result", Boolean(this.opts.resumeSessionRef ?? this.opts.resumeSessionId));
307
+ if (!this._sessionRef)
308
+ this.rejectSessionIdentity(new Error("Claude stream ended before exposing a scoped session identity"));
263
309
  this.eventQueue.close();
264
310
  }
265
311
  }
266
312
  handle(msg) {
267
313
  if ("session_id" in msg && typeof msg.session_id === "string") {
268
- this._sessionId = msg.session_id;
269
- this.observeSession(msg.session_id);
314
+ this.observeSession(msg.session_id, "cwd" in msg && typeof msg.cwd === "string" ? msg.cwd : undefined);
270
315
  }
271
316
  if (msg.type === "assistant") {
272
317
  if (msg.error) {
@@ -274,15 +319,30 @@ export class ClaudeAdapter {
274
319
  this.turnSignals.push(`assistant error: ${msg.error}`);
275
320
  }
276
321
  for (const block of msg.message.content) {
322
+ this.eventQueue.push({ kind: "provider-item", provider: "claude", lifecycle: "completed", itemType: String(block.type ?? "unknown"), payload: structuredClone(block), payloadCompleteness: "complete", providerTurnId: this.activeProviderTurnId, eventId: randomUUID(), observedAt: new Date().toISOString() });
277
323
  if (block.type === "text" && block.text) {
278
- this.eventQueue.push({ kind: "text", text: block.text });
324
+ this.eventQueue.push({ kind: "text", text: block.text, byteCount: Buffer.byteLength(block.text), digest: createHash("sha256").update(block.text).digest("hex"), eventId: randomUUID(), observedAt: new Date().toISOString() });
325
+ this.opts.observer?.signal(true);
279
326
  }
280
327
  else if (block.type === "tool_use") {
328
+ const callId = block.id;
281
329
  this.eventQueue.push({
282
330
  kind: "tool",
283
331
  name: block.name,
284
332
  input: block.input,
333
+ inputCompleteness: "complete",
334
+ lifecycle: "started",
335
+ callId,
336
+ completionKnown: false,
337
+ providerTurnId: this.activeProviderTurnId,
338
+ eventId: randomUUID(),
339
+ observedAt: new Date().toISOString(),
285
340
  });
341
+ if (this.opts.observer) {
342
+ const spanId = this.opts.observer.store.startSpan(this.observationContext(), { kind: "tool", name: block.name, providerTurnId: this.activeProviderTurnId, attributes: { callId, input: block.input } });
343
+ this.toolSpans.set(callId, spanId);
344
+ this.opts.observer.signal(true);
345
+ }
286
346
  }
287
347
  }
288
348
  }
@@ -309,10 +369,24 @@ export class ClaudeAdapter {
309
369
  this.structuredError = msg.error;
310
370
  this.apiErrorStatus = msg.error_status;
311
371
  this.turnSignals.push(`API retry ${msg.attempt}/${msg.max_retries}: ${msg.error}${msg.error_status === null ? "" : ` (HTTP ${msg.error_status})`}`);
312
- this.eventQueue.push(claudeApiRetryEvent(msg));
372
+ const retryEvent = { ...claudeApiRetryEvent(msg), retryId: randomUUID(), providerTurnId: this.activeProviderTurnId, observedAt: new Date().toISOString() };
373
+ this.eventQueue.push(retryEvent);
374
+ if (this.opts.observer) {
375
+ const attributes = { provider: "claude", attempt: msg.attempt, maximum: msg.max_retries, reportedDelayMs: msg.retry_delay_ms, reasonDigest: createHash("sha256").update(msg.error).digest("hex") };
376
+ const retrySpanId = this.opts.observer.store.startSpan(this.observationContext(), { spanId: retryEvent.retryId, kind: "retry", name: "Claude provider retry", providerTurnId: this.activeProviderTurnId, attributes });
377
+ this.opts.observer.store.recordEvent(this.observationContext(), "retry", { eventId: retryEvent.retryId, spanId: retrySpanId, severity: "warning", attributes });
378
+ this.opts.observer.store.finishSpan(retrySpanId, { outcome: "reported" });
379
+ }
313
380
  }
314
381
  else if (msg.type === "tool_progress") {
315
382
  this.eventQueue.push({ kind: "activity", provider: "claude", state: "running tool", detail: `${msg.tool_name} (${Math.floor(msg.elapsed_time_seconds)}s)`, transient: true });
383
+ const progress = msg;
384
+ const callId = typeof progress.tool_use_id === "string" ? progress.tool_use_id : typeof progress.toolUseID === "string" ? progress.toolUseID : undefined;
385
+ this.eventQueue.push({ kind: "tool", name: msg.tool_name, input: {}, lifecycle: "progress", callId, durationMs: Math.max(0, msg.elapsed_time_seconds * 1000), completionKnown: false, eventId: randomUUID(), observedAt: new Date().toISOString() });
386
+ this.opts.observer?.signal(false);
387
+ }
388
+ else if (msg.type === "user") {
389
+ this.observeToolResults(msg);
316
390
  }
317
391
  else if (msg.type === "system") {
318
392
  const system = msg;
@@ -364,13 +438,20 @@ export class ClaudeAdapter {
364
438
  costUsd: msg.total_cost_usd,
365
439
  costAuthoritative: Number.isFinite(msg.total_cost_usd),
366
440
  failure,
441
+ turnId: this.pending?.turnId ?? this.activeProviderTurnId,
442
+ hostInstruction: this.pending?.instruction,
443
+ providerInstruction: this.pending?.instruction,
444
+ rawResponse: text,
445
+ cleanedResponse: failure ? formatClaudeFailure(failure) : text,
446
+ providerMetadata: { provider: "claude", sessionId: this._sessionId, sessionRef: this._sessionRef },
367
447
  };
368
448
  const rawResult = msg;
369
449
  const mergedUsage = mergeClaudeProviderSessionUsage(this.cumulativeUsage, rawResult);
370
450
  result.inputTokens = mergedUsage.inputTokens;
371
451
  result.outputTokens = mergedUsage.outputTokens;
452
+ result.usage = { scope: "session-cumulative", inputTokens: mergedUsage.sample.inputTokens, outputTokens: mergedUsage.sample.outputTokens, totalTokens: mergedUsage.sample.totalTokens, costUsd: mergedUsage.sample.authoritativeCostUsd };
372
453
  this.cumulativeUsage = mergedUsage.sample;
373
- this.eventQueue.push({ kind: "turn-complete", result });
454
+ this.eventQueue.push({ kind: "turn-complete", result, turnId: result.turnId });
374
455
  this.settlePending(result, false);
375
456
  this.turnSignals = [];
376
457
  this.structuredError = undefined;
@@ -378,8 +459,43 @@ export class ClaudeAdapter {
378
459
  this.stderrChunks.length = 0;
379
460
  }
380
461
  }
381
- sendTurn(text) {
382
- return withActivityPhase(`Claude ${activityPhase(this.opts.runtimePhase)}`, () => this.sendTurnInternal(text));
462
+ async sendTurn(text) {
463
+ const turnId = randomUUID();
464
+ this.activeProviderTurnId = turnId;
465
+ const observer = this.opts.observer;
466
+ if (!observer) {
467
+ try {
468
+ return await withActivityPhase(`Claude ${activityPhase(this.opts.runtimePhase)}`, () => this.sendTurnInternal(text));
469
+ }
470
+ finally {
471
+ this.activeProviderTurnId = undefined;
472
+ }
473
+ }
474
+ const context = this.observationContext();
475
+ const spanId = observer.store.startSpan(context, { spanId: turnId, kind: "provider_turn", name: `Claude ${activityPhase(this.opts.runtimePhase)}`, providerTurnId: turnId, attributes: { provider: "claude" } });
476
+ this.activeProviderTurnSpanId = spanId;
477
+ observer.store.updateCurrentState({ runId: observer.runId, role: context.role ?? "host", stream: context.stream ?? "claude", executionId: observer.executionId, ticketId: context.ticketId, deliveryUnitId: context.deliveryUnitId, providerSessionId: context.providerSessionId, phase: "provider turn", activeSpanId: spanId, activeSpanKind: "provider_turn", lastSemanticProgressAt: new Date().toISOString() });
478
+ try {
479
+ const result = await withActivityPhase(`Claude ${activityPhase(this.opts.runtimePhase)}`, () => this.sendTurnInternal(text));
480
+ const deltaInput = nonNegativeDelta(result.usage?.inputTokens, this.priorTurnUsage.inputTokens);
481
+ const deltaOutput = nonNegativeDelta(result.usage?.outputTokens, this.priorTurnUsage.outputTokens);
482
+ const deltaCost = nonNegativeDelta(result.usage?.costUsd, this.priorTurnUsage.costUsd);
483
+ this.priorTurnUsage = { inputTokens: result.usage?.inputTokens, outputTokens: result.usage?.outputTokens, costUsd: result.usage?.costUsd };
484
+ observer.store.finishSpan(spanId, { outcome: result.isError ? "failed" : "completed", attributes: { cumulativeUsage: result.usage, turnDelta: { inputTokens: deltaInput, outputTokens: deltaOutput, costUsd: deltaCost } } });
485
+ return result;
486
+ }
487
+ catch (error) {
488
+ observer.store.finishSpan(spanId, { outcome: "failed", attributes: { error: String(error).slice(0, 500) } });
489
+ throw error;
490
+ }
491
+ finally {
492
+ for (const [callId, toolSpanId] of this.toolSpans) {
493
+ observer.store.finishSpan(toolSpanId, { outcome: "unknown", completionKnown: false, attributes: { callId } });
494
+ this.toolSpans.delete(callId);
495
+ }
496
+ this.activeProviderTurnId = undefined;
497
+ this.activeProviderTurnSpanId = undefined;
498
+ }
383
499
  }
384
500
  sendTurnInternal(text) {
385
501
  if (this.closed)
@@ -394,7 +510,7 @@ export class ClaudeAdapter {
394
510
  this.turnSignals = [];
395
511
  this.structuredError = undefined;
396
512
  this.apiErrorStatus = undefined;
397
- this.pending = { resolve, reject };
513
+ this.pending = { resolve, reject, instruction: text, turnId: this.activeProviderTurnId ?? randomUUID() };
398
514
  this.inbox.push({
399
515
  type: "user",
400
516
  message: { role: "user", content: text },
@@ -407,10 +523,73 @@ export class ClaudeAdapter {
407
523
  while (this.stderrChunks.join("").length > 8 * 1024)
408
524
  this.stderrChunks.shift();
409
525
  }
526
+ observationContext() {
527
+ const observer = this.opts.observer;
528
+ const inherited = observer.context();
529
+ return { ...inherited, runId: observer.runId, executionId: observer.executionId, role: this.opts.sessionRole ?? inherited.role ?? "builder",
530
+ stream: this.opts.sessionStream ?? inherited.stream ?? this.opts.sessionRole ?? "builder", providerSessionId: this._sessionId,
531
+ ticketId: this.opts.ticketId ?? inherited.ticketId, deliveryUnitId: this.opts.deliveryUnitId ?? inherited.deliveryUnitId,
532
+ parentSpanId: this.activeProviderTurnSpanId ?? inherited.parentSpanId, providerTurnId: this.activeProviderTurnId ?? inherited.providerTurnId };
533
+ }
534
+ observeToolResults(message) {
535
+ const body = message.message;
536
+ if (!Array.isArray(body?.content))
537
+ return;
538
+ for (const block of body.content) {
539
+ if (block.type !== "tool_result")
540
+ continue;
541
+ this.eventQueue.push({ kind: "provider-item", provider: "claude", lifecycle: "completed", itemType: "tool_result", payload: structuredClone(block), payloadCompleteness: "complete", providerTurnId: this.activeProviderTurnId, eventId: randomUUID(), observedAt: new Date().toISOString() });
542
+ const callId = typeof block.tool_use_id === "string" ? block.tool_use_id : undefined;
543
+ if (!callId)
544
+ continue;
545
+ const spanId = this.toolSpans.get(callId);
546
+ const text = typeof block.content === "string" ? block.content : JSON.stringify(block.content ?? "");
547
+ const sanitized = text.replace(/\b(?:token|password|secret|api[_-]?key)\s*[:=]\s*\S+/gi, "$1=[REDACTED]");
548
+ const failed = block.is_error === true;
549
+ const outputDigest = createHash("sha256").update(text).digest("hex");
550
+ this.eventQueue.push({ kind: "tool", name: "tool_result", input: {}, inputCompleteness: "unavailable", lifecycle: "completed", callId, status: failed ? "failed" : "completed", outputSummary: sanitized.slice(0, 1000), outputDigest, outputCompleteness: block.is_truncated === true ? "truncated" : "complete", rawOutputBytes: Buffer.byteLength(text), completionKnown: true, providerTurnId: this.activeProviderTurnId, eventId: randomUUID(), observedAt: new Date().toISOString() });
551
+ if (spanId && this.opts.observer) {
552
+ this.opts.observer.store.finishSpan(spanId, { outcome: failed ? "failed" : "completed", completionKnown: true, attributes: { callId, outputSummary: sanitized.slice(0, 1000), outputDigest } });
553
+ this.toolSpans.delete(callId);
554
+ }
555
+ this.opts.observer?.signal(true);
556
+ }
557
+ }
410
558
  sessionId() {
411
559
  return this._sessionId;
412
560
  }
413
561
  sessionRef() { return this._sessionRef; }
562
+ async prepareSession(timeoutMs = 30_000) {
563
+ if (this.closed || this.streamEnded || this.terminalResult)
564
+ throw new Error(this.terminalResult?.text ?? "Claude session is closed");
565
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
566
+ throw new Error("Claude session initialization timeout must be positive");
567
+ let timer;
568
+ let aborted;
569
+ try {
570
+ const interrupted = new Promise((_resolve, reject) => {
571
+ aborted = () => reject(new Error(this.terminalResult?.text ?? "Claude session closed during initialization"));
572
+ this.abort.signal.addEventListener("abort", aborted, { once: true });
573
+ if (this.abort.signal.aborted)
574
+ aborted();
575
+ timer = setTimeout(() => reject(new Error(`Claude did not expose a scoped session identity within ${timeoutMs}ms`)), timeoutMs);
576
+ });
577
+ const [, ref] = await Promise.race([Promise.all([this.query.initializationResult(), this.sessionIdentityReady]), interrupted]);
578
+ if (!ref.validatedAt)
579
+ throw new Error("Claude session identity was not provider-validated during initialization");
580
+ return ref;
581
+ }
582
+ catch (error) {
583
+ this.failSessionIdentity(error instanceof Error ? error : new Error(String(error)));
584
+ throw error;
585
+ }
586
+ finally {
587
+ if (timer)
588
+ clearTimeout(timer);
589
+ if (aborted)
590
+ this.abort.signal.removeEventListener("abort", aborted);
591
+ }
592
+ }
414
593
  adoptSessionRef(ref) {
415
594
  if (ref.provider !== "claude" || ref.sessionId !== this._sessionId)
416
595
  throw new Error("cannot adopt a session reference for a different Claude conversation");
@@ -430,6 +609,11 @@ export class ClaudeAdapter {
430
609
  });
431
610
  }
432
611
  async compact() {
612
+ return this.opts.observer
613
+ ? this.opts.observer.span("compaction", "Claude context compaction", () => this.compactInternal())
614
+ : this.compactInternal();
615
+ }
616
+ async compactInternal() {
433
617
  this.compactResult = undefined;
434
618
  this.manualCompactionInFlight = true;
435
619
  let result;
@@ -528,6 +712,8 @@ export class ClaudeAdapter {
528
712
  if (this.closed)
529
713
  return;
530
714
  this.closed = true;
715
+ this.rejectSessionIdentity(new Error("Claude session closed during initialization"));
716
+ this.abort.abort();
531
717
  this.inbox.close();
532
718
  try {
533
719
  await this.query.interrupt();
@@ -535,26 +721,65 @@ export class ClaudeAdapter {
535
721
  catch {
536
722
  // interrupt is best-effort — ignore if no turn is active
537
723
  }
538
- this.abort.abort();
539
724
  await this.pumpDone.catch(() => { });
540
725
  }
541
- observeSession(sessionId) {
542
- if (this._sessionRef?.sessionId === sessionId)
726
+ failSessionIdentity(error) {
727
+ this.rejectSessionIdentity(error);
728
+ const result = sessionUnavailableResult(new SessionUnavailableError({
729
+ runtime: "claude", phase: this.pending ? "turn" : "preflight", dispatchState: this.pending ? "unknown" : "not-sent",
730
+ executable: this.opts.runtimeExecutable ?? "claude", cwd: this.opts.cwd, diagnostics: error.message,
731
+ availability: { version: 1, status: "unavailable", checkedAt: new Date().toISOString(), reason: "attach-failed", detail: error.message, ...(this._sessionRef ? { sessionRef: this._sessionRef } : {}) },
732
+ }));
733
+ this.terminalResult = result;
734
+ this.settlePending(result);
735
+ this.inbox.close();
736
+ this.abort.abort();
737
+ }
738
+ observeSession(sessionId, observedCwd) {
739
+ const expectedId = this._sessionRef?.sessionId ?? this._sessionId;
740
+ if (!sessionId.trim() || (expectedId && expectedId !== sessionId)) {
741
+ const error = new Error(`Claude initialized session ${sessionId || "without an ID"} instead of requested session ${expectedId ?? "a nonempty ID"}`);
742
+ this.failSessionIdentity(error);
743
+ throw error;
744
+ }
745
+ if (observedCwd !== undefined && canonicalSessionPath(observedCwd) !== canonicalSessionPath(this.opts.cwd)) {
746
+ const error = new Error(`Claude session cwd ${observedCwd} does not match ${this.opts.cwd}`);
747
+ this.failSessionIdentity(error);
748
+ throw error;
749
+ }
750
+ this._sessionId = sessionId;
751
+ if (this._sessionRef?.validatedAt) {
752
+ this.resolveSessionIdentity(this._sessionRef);
543
753
  return;
544
- this._sessionRef = createProviderSessionRef({
545
- provider: "claude", sessionId, cwd: this.opts.cwd, configRoot: this.opts.configRoot ?? this.opts.cwd,
546
- role: this.opts.sessionRole, stream: this.opts.sessionStream, generation: this.opts.sessionGeneration,
547
- workspaceIdentity: this.opts.workspaceIdentity, ticketId: this.opts.ticketId, deliveryUnitId: this.opts.deliveryUnitId,
548
- source: "observed",
549
- });
754
+ }
755
+ const now = new Date().toISOString();
756
+ const validatedAt = observedCwd !== undefined || this.opts.sessionRole !== "qa" ? now : undefined;
757
+ this._sessionRef = this._sessionRef
758
+ ? { ...this._sessionRef, source: "observed", ...(validatedAt ? { validatedAt } : {}) }
759
+ : createProviderSessionRef({
760
+ provider: "claude", sessionId, cwd: this.opts.cwd, configRoot: this.opts.configRoot ?? this.opts.cwd,
761
+ role: this.opts.sessionRole, stream: this.opts.sessionStream, generation: this.opts.sessionGeneration,
762
+ workspaceIdentity: this.opts.workspaceIdentity, ticketId: this.opts.ticketId, deliveryUnitId: this.opts.deliveryUnitId,
763
+ source: "observed", validatedAt,
764
+ });
765
+ if (this._sessionRef.validatedAt)
766
+ this.resolveSessionIdentity(this._sessionRef);
550
767
  }
551
768
  settlePending(result, emit = true) {
552
769
  const pending = this.pending;
553
770
  if (!pending)
554
771
  return;
555
772
  this.pending = undefined;
773
+ if (!result.turnId) {
774
+ result.turnId = pending.turnId;
775
+ result.hostInstruction = pending.instruction;
776
+ result.providerInstruction = pending.instruction;
777
+ result.rawResponse = result.text;
778
+ result.cleanedResponse = result.text;
779
+ result.providerMetadata = { provider: "claude", sessionId: this._sessionId, sessionRef: this._sessionRef };
780
+ }
556
781
  if (emit)
557
- this.eventQueue.push({ kind: "turn-complete", result });
782
+ this.eventQueue.push({ kind: "turn-complete", result, turnId: result.turnId });
558
783
  pending.resolve(result);
559
784
  }
560
785
  streamFailureResult(message, sessionUnavailable) {
@@ -575,6 +800,11 @@ export class ClaudeAdapter {
575
800
  }
576
801
  }
577
802
  function finiteNumber(value) { const parsed = Number(value); return value !== null && value !== undefined && value !== "" && Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined; }
803
+ function nonNegativeDelta(current, prior) {
804
+ if (current === undefined)
805
+ return undefined;
806
+ return prior === undefined ? current : Math.max(0, current - prior);
807
+ }
578
808
  function validThreshold(value) {
579
809
  if (!Number.isInteger(value) || value < 1 || value > 99)
580
810
  throw new Error("automatic compaction threshold must be an integer from 1 to 99");
@@ -595,6 +825,8 @@ function activityPhase(phase) {
595
825
  return "reviewing with QA";
596
826
  if (phase === "uninstaller")
597
827
  return "planning uninstall";
828
+ if (phase === "manager")
829
+ return "analyzing diagnostics";
598
830
  return "building";
599
831
  }
600
832
  function formatClaudeFailure(failure) {
@@ -35,6 +35,9 @@ export declare class CodexAdapter implements BuilderAdapter {
35
35
  private nativeCompactionSequence;
36
36
  private manualCompactionInFlight;
37
37
  private observedToolCalls;
38
+ private activeProviderTurnId?;
39
+ private activeProviderTurnSpanId?;
40
+ private readonly toolSpans;
38
41
  constructor(opts: BuilderAdapterOptions);
39
42
  buildInstruction(instruction: string): string;
40
43
  buildAppServerArgs(): string[];
@@ -43,7 +46,9 @@ export declare class CodexAdapter implements BuilderAdapter {
43
46
  sendTurn(instruction: string): Promise<TurnResult>;
44
47
  private sendTurnInternal;
45
48
  compact(): Promise<CompactResult>;
49
+ private compactInternal;
46
50
  prepareAutoCompaction(thresholdPercent?: number | undefined): Promise<NativeAutoCompactionPolicy | void>;
51
+ requiresAutoCompactionSetupTurn(): boolean;
47
52
  autoCompactionPolicy(): NativeAutoCompactionPolicy | undefined;
48
53
  drainNativeCompactions(): NativeCompaction[];
49
54
  restoreNativeCompactions(compactions: NativeCompaction[]): void;
@@ -53,6 +58,7 @@ export declare class CodexAdapter implements BuilderAdapter {
53
58
  switchSettings(settings: ProviderSettingSwitch): Promise<CompactResult>;
54
59
  sessionId(): string | undefined;
55
60
  sessionRef(): ProviderSessionRefV1 | undefined;
61
+ prepareSession(): Promise<ProviderSessionRefV1>;
56
62
  adoptSessionRef(ref: ProviderSessionRefV1): void;
57
63
  validateSession(): Promise<SessionAvailabilityV1>;
58
64
  events(): AsyncIterable<BuilderEvent>;
@@ -63,6 +69,7 @@ export declare class CodexAdapter implements BuilderAdapter {
63
69
  private request;
64
70
  private write;
65
71
  private handle;
72
+ private observationContext;
66
73
  private waitFor;
67
74
  private disconnect;
68
75
  private restartForAutoCompaction;