ai-foreman 1.7.13 → 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 (47) hide show
  1. package/README.md +33 -3
  2. package/dist/adapters/claude.d.ts +5 -0
  3. package/dist/adapters/claude.js +122 -16
  4. package/dist/adapters/codex.d.ts +2 -0
  5. package/dist/adapters/codex.js +30 -8
  6. package/dist/adapters/recovering.d.ts +2 -0
  7. package/dist/adapters/recovering.js +6 -0
  8. package/dist/adapters/types.d.ts +22 -0
  9. package/dist/agentRun.js +21 -3
  10. package/dist/branch/currentGuard.d.ts +2 -0
  11. package/dist/branch/currentGuard.js +9 -0
  12. package/dist/branch/finalization.d.ts +22 -0
  13. package/dist/branch/finalization.js +120 -0
  14. package/dist/branch/git.js +7 -2
  15. package/dist/branch/runner.d.ts +3 -2
  16. package/dist/branch/runner.js +277 -75
  17. package/dist/buildRuns.d.ts +1 -0
  18. package/dist/buildRuns.js +33 -3
  19. package/dist/cli/start.js +314 -86
  20. package/dist/cli/state.d.ts +4 -0
  21. package/dist/cli/state.js +69 -0
  22. package/dist/continuity.d.ts +4 -0
  23. package/dist/continuity.js +26 -3
  24. package/dist/foreman.d.ts +22 -4
  25. package/dist/foreman.js +105 -19
  26. package/dist/handoffs.d.ts +5 -2
  27. package/dist/handoffs.js +93 -12
  28. package/dist/index.js +2 -0
  29. package/dist/qaFailureDelivery.d.ts +100 -0
  30. package/dist/qaFailureDelivery.js +570 -0
  31. package/dist/qaProtocolV2.d.ts +233 -0
  32. package/dist/qaProtocolV2.js +114 -0
  33. package/dist/qaRecovery.d.ts +15 -3
  34. package/dist/qaRecovery.js +477 -83
  35. package/dist/qaReview.d.ts +42 -17
  36. package/dist/qaReview.js +1216 -457
  37. package/dist/qaRuntime.d.ts +14 -0
  38. package/dist/qaRuntime.js +44 -0
  39. package/dist/qaSnapshot.d.ts +34 -0
  40. package/dist/qaSnapshot.js +302 -34
  41. package/dist/stateTransfer.d.ts +57 -0
  42. package/dist/stateTransfer.js +644 -0
  43. package/dist/statusReporter.d.ts +2 -0
  44. package/dist/statusReporter.js +6 -0
  45. package/dist/workflowDb.d.ts +245 -2
  46. package/dist/workflowDb.js +647 -6
  47. package/package.json +15 -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,7 +163,9 @@ 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
 
151
- 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 and preserves reports by digest. A malformed failing report receives two correction-only turns, a compacted pair, then five turns in a validated fresh successor. Recovery packets are owner-only, locally Git-excluded, and stored under `.foreman/qa-report-recovery/`; the successor must acknowledge the copied packet and reviewed-state digests before reporting.
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`.
152
169
 
153
170
  ### `ai-foreman manager`
154
171
 
@@ -497,6 +514,11 @@ ai-foreman start ./my-project --steps 5 --no-qa
497
514
  # Resume the latest session / a specific session
498
515
  ai-foreman start ./my-project --steps 5 --continue
499
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
500
522
  ```
501
523
 
502
524
  ## Ticket Lifecycle Commands
@@ -558,16 +580,24 @@ STEP_STATUS: needs_input | question="Which storage backend?" choices="SQLite|Pos
558
580
 
559
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.
560
582
 
561
- 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:
562
584
 
563
585
  ```txt
564
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
565
595
  STEP_STATUS: qa_fail | issues="missing test for empty config"
566
596
  ```
567
597
 
568
598
  If QA fails:
569
599
 
570
- - Foreman sends a fix instruction.
600
+ - Foreman sends the Builder the complete validated report, ticket requirements, current source digest, and prior report/remediation history.
571
601
  - Foreman reruns QA.
572
602
  - QA turns do not count against `--steps`.
573
603
 
@@ -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?;
@@ -82,6 +85,7 @@ export declare class ClaudeAdapter implements BuilderAdapter {
82
85
  private observeToolResults;
83
86
  sessionId(): string | undefined;
84
87
  sessionRef(): ProviderSessionRefV1 | undefined;
88
+ prepareSession(timeoutMs?: number): Promise<ProviderSessionRefV1>;
85
89
  adoptSessionRef(ref: ProviderSessionRefV1): void;
86
90
  validateSession(): Promise<SessionAvailabilityV1>;
87
91
  compact(): Promise<CompactResult>;
@@ -95,6 +99,7 @@ export declare class ClaudeAdapter implements BuilderAdapter {
95
99
  switchSettings(settings: ProviderSettingSwitch): Promise<CompactResult>;
96
100
  events(): AsyncIterable<BuilderEvent>;
97
101
  close(): Promise<void>;
102
+ private failSessionIdentity;
98
103
  private observeSession;
99
104
  private settlePending;
100
105
  private streamFailureResult;
@@ -1,4 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
+ import { dirname, join } from "node:path";
2
3
  /** Lazy-load the Claude Agent SDK. Throws an actionable error if not installed. */
3
4
  export async function requireClaudeSDK() {
4
5
  try {
@@ -22,25 +23,42 @@ import { SessionUnavailableError, sessionUnavailableResult } from "./sessionFail
22
23
  * `canUseTool` is omitted here — it's a closure that the constructor adds.
23
24
  */
24
25
  export function buildClaudeQueryOptions(opts) {
26
+ const qaReadOnly = opts.sessionRole === "qa" || opts.sandboxMode === "read-only";
25
27
  const base = {
26
28
  cwd: opts.cwd,
27
29
  pathToClaudeCodeExecutable: opts.runtimeExecutable,
28
- env: { ...process.env },
30
+ env: qaReadOnly ? qaEnvironment(process.env, opts.cwd) : { ...process.env },
29
31
  model: opts.model,
30
32
  resume: opts.resumeSessionRef?.sessionId ?? opts.resumeSessionId,
31
- permissionMode: "acceptEdits",
33
+ permissionMode: qaReadOnly ? "default" : "acceptEdits",
32
34
  effort: opts.effort,
33
35
  extraArgs: opts.fast ? { fast: null } : undefined,
34
- 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
+ } } : {}),
35
45
  };
36
- if (opts.systemPromptAppend) {
37
- 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 };
38
52
  }
39
53
  if (opts.skills !== undefined) {
40
54
  base.skills = opts.skills;
41
55
  }
42
56
  return base;
43
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
+ }
44
62
  export async function probeClaudeSession(ref, input) {
45
63
  const now = input.now ?? new Date();
46
64
  const local = validateProviderSessionScope(ref, {
@@ -148,6 +166,9 @@ export class ClaudeAdapter {
148
166
  pumpDone;
149
167
  _sessionId;
150
168
  _sessionRef;
169
+ sessionIdentityReady;
170
+ resolveSessionIdentity;
171
+ rejectSessionIdentity;
151
172
  stderrChunks = [];
152
173
  turnSignals = [];
153
174
  structuredError;
@@ -210,11 +231,28 @@ export class ClaudeAdapter {
210
231
  this.opts = opts;
211
232
  this._sessionId = opts.resumeSessionRef?.sessionId ?? opts.resumeSessionId;
212
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(() => { });
213
239
  this.query = query({
214
240
  prompt: this.inbox,
215
241
  options: {
216
242
  ...buildClaudeQueryOptions(opts),
217
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
+ }] }] },
218
256
  stderr: (data) => this.captureStderr(data),
219
257
  canUseTool: async (toolName, input, requestOptions = {}) => {
220
258
  const decision = await opts.permission({
@@ -251,6 +289,7 @@ export class ClaudeAdapter {
251
289
  const message = normalizeRuntimeErrorText("claude", rawMessage, null, "builder stream");
252
290
  this.eventQueue.push({ kind: "error", message });
253
291
  const result = this.streamFailureResult(message, isMissingClaudeSession(rawMessage));
292
+ this.rejectSessionIdentity(new Error(message));
254
293
  this.terminalResult = result;
255
294
  this.settlePending(result);
256
295
  }
@@ -265,13 +304,14 @@ export class ClaudeAdapter {
265
304
  }
266
305
  if (!this.closed && !this.terminalResult)
267
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"));
268
309
  this.eventQueue.close();
269
310
  }
270
311
  }
271
312
  handle(msg) {
272
313
  if ("session_id" in msg && typeof msg.session_id === "string") {
273
- this._sessionId = msg.session_id;
274
- this.observeSession(msg.session_id);
314
+ this.observeSession(msg.session_id, "cwd" in msg && typeof msg.cwd === "string" ? msg.cwd : undefined);
275
315
  }
276
316
  if (msg.type === "assistant") {
277
317
  if (msg.error) {
@@ -279,6 +319,7 @@ export class ClaudeAdapter {
279
319
  this.turnSignals.push(`assistant error: ${msg.error}`);
280
320
  }
281
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() });
282
323
  if (block.type === "text" && block.text) {
283
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() });
284
325
  this.opts.observer?.signal(true);
@@ -497,6 +538,7 @@ export class ClaudeAdapter {
497
538
  for (const block of body.content) {
498
539
  if (block.type !== "tool_result")
499
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() });
500
542
  const callId = typeof block.tool_use_id === "string" ? block.tool_use_id : undefined;
501
543
  if (!callId)
502
544
  continue;
@@ -517,6 +559,37 @@ export class ClaudeAdapter {
517
559
  return this._sessionId;
518
560
  }
519
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
+ }
520
593
  adoptSessionRef(ref) {
521
594
  if (ref.provider !== "claude" || ref.sessionId !== this._sessionId)
522
595
  throw new Error("cannot adopt a session reference for a different Claude conversation");
@@ -639,6 +712,8 @@ export class ClaudeAdapter {
639
712
  if (this.closed)
640
713
  return;
641
714
  this.closed = true;
715
+ this.rejectSessionIdentity(new Error("Claude session closed during initialization"));
716
+ this.abort.abort();
642
717
  this.inbox.close();
643
718
  try {
644
719
  await this.query.interrupt();
@@ -646,18 +721,49 @@ export class ClaudeAdapter {
646
721
  catch {
647
722
  // interrupt is best-effort — ignore if no turn is active
648
723
  }
649
- this.abort.abort();
650
724
  await this.pumpDone.catch(() => { });
651
725
  }
652
- observeSession(sessionId) {
653
- 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);
654
753
  return;
655
- this._sessionRef = createProviderSessionRef({
656
- provider: "claude", sessionId, cwd: this.opts.cwd, configRoot: this.opts.configRoot ?? this.opts.cwd,
657
- role: this.opts.sessionRole, stream: this.opts.sessionStream, generation: this.opts.sessionGeneration,
658
- workspaceIdentity: this.opts.workspaceIdentity, ticketId: this.opts.ticketId, deliveryUnitId: this.opts.deliveryUnitId,
659
- source: "observed",
660
- });
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);
661
767
  }
662
768
  settlePending(result, emit = true) {
663
769
  const pending = this.pending;
@@ -48,6 +48,7 @@ export declare class CodexAdapter implements BuilderAdapter {
48
48
  compact(): Promise<CompactResult>;
49
49
  private compactInternal;
50
50
  prepareAutoCompaction(thresholdPercent?: number | undefined): Promise<NativeAutoCompactionPolicy | void>;
51
+ requiresAutoCompactionSetupTurn(): boolean;
51
52
  autoCompactionPolicy(): NativeAutoCompactionPolicy | undefined;
52
53
  drainNativeCompactions(): NativeCompaction[];
53
54
  restoreNativeCompactions(compactions: NativeCompaction[]): void;
@@ -57,6 +58,7 @@ export declare class CodexAdapter implements BuilderAdapter {
57
58
  switchSettings(settings: ProviderSettingSwitch): Promise<CompactResult>;
58
59
  sessionId(): string | undefined;
59
60
  sessionRef(): ProviderSessionRefV1 | undefined;
61
+ prepareSession(): Promise<ProviderSessionRefV1>;
60
62
  adoptSessionRef(ref: ProviderSessionRefV1): void;
61
63
  validateSession(): Promise<SessionAvailabilityV1>;
62
64
  events(): AsyncIterable<BuilderEvent>;
@@ -248,6 +248,8 @@ export class CodexAdapter {
248
248
  // setup turn, then restart the app server with the provider-native ceiling
249
249
  // before any Builder or QA work is sent.
250
250
  if (!this.usage?.maximum) {
251
+ if (this.opts.allowAutoCompactionSetupTurn === false)
252
+ return;
251
253
  const toolsBefore = this.observedToolCalls;
252
254
  const setup = await this.sendTurnInternal("Rafi internal initialization only. Do not call tools or modify files. Reply briefly that the context is ready.");
253
255
  if (setup.isError)
@@ -272,6 +274,9 @@ export class CodexAdapter {
272
274
  };
273
275
  return this.preparedAutoCompactionPolicy;
274
276
  }
277
+ requiresAutoCompactionSetupTurn() {
278
+ return this.opts.autoCompactThresholdPercent !== undefined && !this.autoCompactionPrepared && !this.usage?.maximum;
279
+ }
275
280
  autoCompactionPolicy() { return this.preparedAutoCompactionPolicy; }
276
281
  drainNativeCompactions() {
277
282
  const pending = this.nativeCompactions;
@@ -300,6 +305,14 @@ export class CodexAdapter {
300
305
  }
301
306
  sessionId() { return this._sessionId; }
302
307
  sessionRef() { return this._sessionRef; }
308
+ async prepareSession() {
309
+ await this.ensureThread();
310
+ if (!this._sessionRef)
311
+ throw new Error("Codex did not expose a scoped thread identity during session preparation");
312
+ if (!this._sessionRef.validatedAt)
313
+ this._sessionRef = { ...this._sessionRef, validatedAt: new Date().toISOString() };
314
+ return this._sessionRef;
315
+ }
303
316
  adoptSessionRef(ref) {
304
317
  if (ref.provider !== "codex" || ref.sessionId !== this._sessionId)
305
318
  throw new Error("cannot adopt a session reference for a different Codex thread");
@@ -395,20 +408,22 @@ export class CodexAdapter {
395
408
  availability,
396
409
  });
397
410
  }
398
- this._sessionId = returnedSessionId;
399
- if (!this._sessionId)
411
+ if (!returnedSessionId?.trim())
400
412
  throw new Error(`${method} did not return a thread ID`);
401
413
  const providerCwd = typeof thread?.cwd === "string" ? canonicalSessionPath(thread.cwd) : undefined;
402
- if (this._sessionRef && providerCwd && providerCwd !== canonicalSessionPath(this._sessionRef.cwd)) {
403
- const availability = { version: 1, status: "unavailable", checkedAt: new Date().toISOString(), reason: "cwd-mismatch", observedCwd: providerCwd, detail: `Codex thread cwd ${providerCwd} does not match ${this._sessionRef.cwd}`, sessionRef: this._sessionRef };
414
+ const expectedCwd = canonicalSessionPath(this._sessionRef?.cwd ?? this.opts.cwd);
415
+ if ((providerCwd && providerCwd !== expectedCwd) || (!providerCwd && this.opts.sessionRole === "qa")) {
416
+ const availability = { version: 1, status: "unavailable", checkedAt: new Date().toISOString(), reason: "cwd-mismatch", observedCwd: providerCwd, detail: `Codex thread cwd ${providerCwd ?? "was not returned"} does not match ${expectedCwd}`, sessionRef: this._sessionRef };
404
417
  this.sessionAvailability = availability;
405
418
  throw new SessionUnavailableError({ runtime: "codex", phase: "attach", dispatchState: "not-sent", executable: this.opts.runtimeExecutable ?? "codex", cwd: this.opts.cwd, diagnostics: availability.detail, availability });
406
419
  }
420
+ this._sessionId = returnedSessionId;
407
421
  if (!this._sessionRef) {
408
422
  this._sessionRef = createProviderSessionRef({
409
423
  provider: "codex", sessionId: this._sessionId, cwd: providerCwd ?? this.opts.cwd, configRoot: this.opts.configRoot ?? this.opts.cwd,
410
424
  role: this.opts.sessionRole, stream: this.opts.sessionStream, generation: this.opts.sessionGeneration,
411
425
  workspaceIdentity: this.opts.workspaceIdentity, ticketId: this.opts.ticketId, deliveryUnitId: this.opts.deliveryUnitId,
426
+ validatedAt: new Date().toISOString(),
412
427
  });
413
428
  }
414
429
  else {
@@ -476,6 +491,8 @@ export class CodexAdapter {
476
491
  const params = message.params ?? {};
477
492
  if (message.method === "item/started") {
478
493
  const item = params.item;
494
+ if (item)
495
+ this.eventQueue.push({ kind: "provider-item", provider: "codex", lifecycle: "started", itemType: String(item.type ?? "unknown"), payload: exactProviderItem(item), payloadCompleteness: "complete", providerTurnId: this.activeProviderTurnId, eventId: randomUUID(), observedAt: new Date().toISOString() });
479
496
  if (isCodexToolItem(item)) {
480
497
  this.observedToolCalls += 1;
481
498
  const callId = codexItemId(item) ?? randomUUID();
@@ -493,6 +510,8 @@ export class CodexAdapter {
493
510
  }
494
511
  else if (message.method === "item/completed") {
495
512
  const item = params.item;
513
+ if (item)
514
+ this.eventQueue.push({ kind: "provider-item", provider: "codex", lifecycle: "completed", itemType: String(item.type ?? "unknown"), payload: exactProviderItem(item), payloadCompleteness: "complete", providerTurnId: this.activeProviderTurnId, eventId: randomUUID(), observedAt: new Date().toISOString() });
496
515
  if (isCodexToolItem(item)) {
497
516
  const callId = codexItemId(item);
498
517
  const spanId = callId ? this.toolSpans.get(callId) : undefined;
@@ -643,6 +662,9 @@ export class CodexAdapter {
643
662
  child.kill("SIGTERM");
644
663
  }
645
664
  buildSkillsAppendix() {
665
+ if (this.opts.preloadedSkillContent?.length) {
666
+ return ["# Preloaded Skills", "Use the following skills for this run.", ...this.opts.preloadedSkillContent.map((skill) => skill.content)].join("\n\n");
667
+ }
646
668
  if (!this.opts.skills?.length)
647
669
  return undefined;
648
670
  const blocks = this.opts.skills.map((skill) => loadSkillMarkdown(this.opts.cwd, skill)).filter((block) => Boolean(block));
@@ -699,16 +721,16 @@ function isCodexToolItem(item) {
699
721
  }
700
722
  function codexItemId(item) { const value = item?.id ?? item?.itemId ?? item?.callId; return typeof value === "string" && value ? value : undefined; }
701
723
  function codexToolName(item) { return String(item?.tool ?? item?.name ?? item?.type ?? "tool"); }
702
- function exactToolInput(item) { if (!item)
703
- return {}; return Object.fromEntries(Object.entries(item).filter(([key]) => ["command", "tool", "name", "type", "path", "query", "arguments", "input"].includes(key))); }
724
+ function exactProviderItem(item) { return structuredClone(item); }
725
+ function exactToolInput(item) { return item ? exactProviderItem(item) : {}; }
704
726
  function boundedToolInput(item) { if (!item)
705
727
  return {}; return Object.fromEntries(Object.entries(exactToolInput(item)).map(([key, value]) => [key, typeof value === "string" ? value.slice(0, 1000) : value])); }
706
728
  function codexOutputSummary(item) { const value = item?.aggregatedOutput ?? item?.output ?? item?.stdout; if (typeof value !== "string" || !value)
707
729
  return undefined; const sanitized = value.replace(/\b(?:token|password|secret|api[_-]?key)\s*[:=]\s*\S+/gi, "$1=[REDACTED]"); const providerTruncated = item?.outputTruncated === true || item?.truncated === true; return { summary: sanitized.slice(0, 1000), digest: createHash("sha256").update(value).digest("hex"), completeness: providerTruncated ? "truncated" : "complete", bytes: Buffer.byteLength(value) }; }
708
730
  function finiteNumber(value) { const result = Number(value); return Number.isFinite(result) ? result : undefined; }
709
731
  function loadSkillMarkdown(cwd, skill) {
710
- const projectPath = join(cwd, ".agents", "skills", skill, "SKILL.md");
711
- if (existsSync(projectPath))
732
+ const projectPath = [join(cwd, ".codex", "skills", skill, "SKILL.md"), join(cwd, ".agents", "skills", skill, "SKILL.md")].find(existsSync);
733
+ if (projectPath)
712
734
  return `## ${skill}\n${readFileSync(projectPath, "utf8").trim()}`;
713
735
  try {
714
736
  const bundled = loadSkill(skill);
@@ -28,10 +28,12 @@ export declare class RecoveringAdapter implements BuilderAdapter {
28
28
  sendTurn(text: string): Promise<TurnResult>;
29
29
  sessionId(): string | undefined;
30
30
  sessionRef(): ProviderSessionRefV1 | undefined;
31
+ prepareSession(): Promise<ProviderSessionRefV1>;
31
32
  adoptSessionRef(ref: ProviderSessionRefV1): void;
32
33
  validateSession(): Promise<SessionAvailabilityV1>;
33
34
  compact(): Promise<CompactResult>;
34
35
  prepareAutoCompaction(thresholdPercent?: number): Promise<NativeAutoCompactionPolicy | void>;
36
+ requiresAutoCompactionSetupTurn(): boolean;
35
37
  autoCompactionPolicy(): NativeAutoCompactionPolicy | undefined;
36
38
  drainNativeCompactions(): import("./types.js").NativeCompaction[];
37
39
  restoreNativeCompactions(compactions: NativeCompaction[]): void;
@@ -72,12 +72,18 @@ export class RecoveringAdapter {
72
72
  return this.adapter.sessionId();
73
73
  }
74
74
  sessionRef() { return this.adapter.sessionRef?.(); }
75
+ prepareSession() {
76
+ if (!this.adapter.prepareSession)
77
+ return Promise.reject(new Error("wrapped adapter cannot establish a provider session without a work turn"));
78
+ return this.adapter.prepareSession();
79
+ }
75
80
  adoptSessionRef(ref) { this.adapter.adoptSessionRef?.(ref); }
76
81
  validateSession() {
77
82
  return this.adapter.validateSession?.() ?? Promise.resolve({ version: 1, status: "unknown", checkedAt: new Date().toISOString(), reason: "legacy-unscoped", detail: "wrapped adapter does not expose scoped session validation" });
78
83
  }
79
84
  compact() { return this.adapter.compact?.() ?? Promise.resolve({ ok: false, error: "native compaction unavailable" }); }
80
85
  prepareAutoCompaction(thresholdPercent) { return this.adapter.prepareAutoCompaction?.(thresholdPercent) ?? Promise.resolve(); }
86
+ requiresAutoCompactionSetupTurn() { return this.adapter.requiresAutoCompactionSetupTurn?.() ?? false; }
81
87
  autoCompactionPolicy() { return this.adapter.autoCompactionPolicy?.(); }
82
88
  drainNativeCompactions() { return this.adapter.drainNativeCompactions?.() ?? []; }
83
89
  restoreNativeCompactions(compactions) { this.adapter.restoreNativeCompactions?.(compactions); }
@@ -106,6 +106,14 @@ export type BuilderEvent = ({
106
106
  rawOutputBytes?: number;
107
107
  completionKnown?: boolean;
108
108
  providerTurnId?: string;
109
+ } | {
110
+ kind: "provider-item";
111
+ provider: "claude" | "codex";
112
+ lifecycle: "started" | "completed";
113
+ itemType: string;
114
+ payload: Record<string, unknown>;
115
+ payloadCompleteness: "complete";
116
+ providerTurnId?: string;
109
117
  } | {
110
118
  kind: "activity";
111
119
  state: string;
@@ -227,8 +235,15 @@ export interface BuilderAdapterOptions {
227
235
  systemPromptAppend?: string;
228
236
  /** Skill names to preload for this session (Claude: lazy-loaded; Codex: flattened). */
229
237
  skills?: string[];
238
+ /** Exact host-frozen skill text injected into the provider instruction. */
239
+ preloadedSkillContent?: Array<{
240
+ name: string;
241
+ content: string;
242
+ }>;
230
243
  /** Provider-native context ceiling, as a percentage of that provider's model window. */
231
244
  autoCompactThresholdPercent?: number;
245
+ /** QA forbids an unjournaled model turn merely to discover the context window. */
246
+ allowAutoCompactionSetupTurn?: boolean;
232
247
  /** Raw-adapter observability; wrapper adapters must not persist re-emitted events. */
233
248
  observer?: RunObserver;
234
249
  }
@@ -240,6 +255,11 @@ export interface BuilderAdapter {
240
255
  sessionId(): string | undefined;
241
256
  /** Current location-scoped session reference, once known. */
242
257
  sessionRef?(): ProviderSessionRefV1 | undefined;
258
+ /**
259
+ * Establish and observe the exact provider session without dispatching a
260
+ * role/work turn. Durable QA calls this before binding its first turn.
261
+ */
262
+ prepareSession?(): Promise<ProviderSessionRefV1>;
243
263
  /** Host-only metadata promotion after a validated handoff is accepted. */
244
264
  adoptSessionRef?(ref: ProviderSessionRefV1): void;
245
265
  /** Attach/probe a requested exact session without starting a provider turn. */
@@ -252,6 +272,8 @@ export interface BuilderAdapter {
252
272
  * tool-heavy first turn is protected too.
253
273
  */
254
274
  prepareAutoCompaction?(thresholdPercent?: number): Promise<NativeAutoCompactionPolicy | void>;
275
+ /** True only when a provider needs one observable, tool-free turn to learn its context window. */
276
+ requiresAutoCompactionSetupTurn?(): boolean;
255
277
  /** The last provider-native automatic-compaction policy verified on this transport. */
256
278
  autoCompactionPolicy?(): NativeAutoCompactionPolicy | undefined;
257
279
  /** Consume provider-native compactions observed since the prior drain. */
package/dist/agentRun.js CHANGED
@@ -47,6 +47,22 @@ export function readOnlyPermissionConfig() {
47
47
  "git show --stat",
48
48
  "git ls-files",
49
49
  "git grep",
50
+ "npm test",
51
+ "npm run test",
52
+ "npm run typecheck",
53
+ "npm run lint",
54
+ "npm run check",
55
+ "pnpm test",
56
+ "pnpm run test",
57
+ "pnpm typecheck",
58
+ "pnpm lint",
59
+ "pnpm --filter ",
60
+ "yarn test",
61
+ "pytest",
62
+ "python -m pytest",
63
+ "python3 -m pytest",
64
+ "cargo test",
65
+ "go test",
50
66
  ],
51
67
  escalateBash: [
52
68
  ...DEFAULT_CONFIG.permissions.escalateBash,
@@ -60,10 +76,12 @@ export function readOnlyPermissionConfig() {
60
76
  "git restore",
61
77
  "git stash",
62
78
  "npm install",
63
- "npm run",
64
79
  "npm ci",
65
- "pnpm ",
66
- "yarn ",
80
+ "pnpm install",
81
+ "pnpm add",
82
+ "pnpm remove",
83
+ "yarn add",
84
+ "yarn install",
67
85
  "mkdir ",
68
86
  "touch ",
69
87
  "mv ",
@@ -23,10 +23,12 @@ export declare class CurrentWorkflowGuardAdapter implements BuilderAdapter {
23
23
  sendTurn(text: string): Promise<TurnResult>;
24
24
  sessionId(): string | undefined;
25
25
  sessionRef(): ProviderSessionRefV1 | undefined;
26
+ prepareSession(): Promise<ProviderSessionRefV1>;
26
27
  adoptSessionRef(ref: ProviderSessionRefV1): void;
27
28
  validateSession(): Promise<SessionAvailabilityV1>;
28
29
  compact(): Promise<CompactResult>;
29
30
  prepareAutoCompaction(thresholdPercent?: number): Promise<NativeAutoCompactionPolicy | void>;
31
+ requiresAutoCompactionSetupTurn(): boolean;
30
32
  autoCompactionPolicy(): NativeAutoCompactionPolicy | undefined;
31
33
  drainNativeCompactions(): import("../adapters/types.js").NativeCompaction[];
32
34
  restoreNativeCompactions(compactions: NativeCompaction[]): void;
@@ -45,10 +45,19 @@ export class CurrentWorkflowGuardAdapter {
45
45
  }
46
46
  sessionId() { return this.adapter.sessionId(); }
47
47
  sessionRef() { return this.adapter.sessionRef?.(); }
48
+ async prepareSession() {
49
+ assertCurrentWorkflowIdentity(this.cwd, this.expected);
50
+ if (!this.adapter.prepareSession)
51
+ throw new Error("wrapped adapter cannot establish a provider session without a work turn");
52
+ const ref = await this.adapter.prepareSession();
53
+ assertCurrentWorkflowIdentity(this.cwd, this.expected);
54
+ return ref;
55
+ }
48
56
  adoptSessionRef(ref) { this.adapter.adoptSessionRef?.(ref); }
49
57
  validateSession() { assertCurrentWorkflowIdentity(this.cwd, this.expected); return this.adapter.validateSession?.() ?? Promise.resolve({ version: 1, status: "unknown", checkedAt: new Date().toISOString(), reason: "legacy-unscoped" }); }
50
58
  async compact() { assertCurrentWorkflowIdentity(this.cwd, this.expected); const result = await (this.adapter.compact?.() ?? Promise.resolve({ ok: false, error: "native compaction unavailable" })); assertCurrentWorkflowIdentity(this.cwd, this.expected); return result; }
51
59
  prepareAutoCompaction(thresholdPercent) { return this.adapter.prepareAutoCompaction?.(thresholdPercent) ?? Promise.resolve(); }
60
+ requiresAutoCompactionSetupTurn() { return this.adapter.requiresAutoCompactionSetupTurn?.() ?? false; }
52
61
  autoCompactionPolicy() { return this.adapter.autoCompactionPolicy?.(); }
53
62
  drainNativeCompactions() { return this.adapter.drainNativeCompactions?.() ?? []; }
54
63
  restoreNativeCompactions(compactions) { this.adapter.restoreNativeCompactions?.(compactions); }