ai-foreman 1.7.7 → 1.7.8

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.
package/dist/cli/start.js CHANGED
@@ -625,6 +625,13 @@ export function buildStartCommand() {
625
625
  fast: builderFast,
626
626
  systemPromptAppend: roleBundle.system || undefined,
627
627
  skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
628
+ contextManagementPolicy: {
629
+ role: "builder",
630
+ configuredThresholdPercent: thresholdOverride ?? roleDefaults.builder?.auto_compact_threshold_percent ?? 50,
631
+ compactMaximum: roleDefaults.builder?.compact_maximum ?? 10,
632
+ settingsRevision,
633
+ model: model ?? "default",
634
+ },
628
635
  };
629
636
  const adapter = agent === "codex"
630
637
  ? new CodexAdapter(adapterOpts)
@@ -641,6 +648,7 @@ export function buildStartCommand() {
641
648
  let activeContinuityRunId;
642
649
  let liveStatusReporter;
643
650
  let activeBuilderForStatus;
651
+ let activeBuilderContextSnapshot;
644
652
  const resolvedContinuitySettings = (role) => role === "builder" ? {
645
653
  role, source: roleDefaults.builder ? "project" : "provider", make: agent, model: model ?? "default",
646
654
  reasoning: builderEffort ?? "default", fast: Boolean(builderFast), session_strategy: roleDefaults.builder?.session_strategy ?? "compact",
@@ -651,27 +659,70 @@ export function buildStartCommand() {
651
659
  role, source: roleDefaults.qa ? "project" : "provider", make: qaAgent, model: qaModel ?? "default",
652
660
  reasoning: qaEffort ?? "default", fast: qaFast, session_strategy: roleDefaults.qa?.session_strategy ?? "compact",
653
661
  settings_revision: settingsRevision, display_session_cost: sessionCostOverride ?? roleDefaults.qa?.display_session_cost ?? false,
654
- auto_compact_threshold_percent: 50, compact_maximum: 10,
662
+ auto_compact_threshold_percent: roleDefaults.qa?.auto_compact_threshold_percent ?? 50,
663
+ compact_maximum: roleDefaults.qa?.compact_maximum ?? 10,
664
+ };
665
+ const liveRoleFields = [
666
+ "make", "model", "reasoning", "fast", "session_strategy", "display_session_cost",
667
+ "auto_compact_threshold_percent", "compact_maximum",
668
+ ];
669
+ const observedRoleDefaults = {
670
+ builder: { ...(roleDefaults.builder ?? {}) },
671
+ qa: { ...(roleDefaults.qa ?? {}) },
672
+ };
673
+ const observedProjectRevision = { builder: settingsRevision, qa: settingsRevision };
674
+ const effectiveRoleRevision = { builder: settingsRevision, qa: settingsRevision };
675
+ const liveRoleOverrides = {
676
+ builder: new Map(),
677
+ qa: new Map(),
655
678
  };
656
679
  const liveRoleSettings = (role, base) => {
657
680
  const live = readAgentDefaults(cwd);
658
681
  const revision = live.revision ?? 0;
659
- if (revision <= base.settings_revision)
660
- return base;
661
682
  const defaults = role === "builder" ? live.builder : live.qa;
662
- return {
683
+ if (revision > observedProjectRevision[role]) {
684
+ const prior = observedRoleDefaults[role];
685
+ let roleChanged = false;
686
+ for (const field of liveRoleFields) {
687
+ const value = defaults?.[field];
688
+ if (!Object.is(value, prior[field])) {
689
+ liveRoleOverrides[role].set(field, value);
690
+ roleChanged = true;
691
+ }
692
+ }
693
+ const revisionDb = new WorkflowDb(cwd);
694
+ let targetedFields;
695
+ try {
696
+ targetedFields = revisionDb.projectSettingsRevisionTargets(revision)[role] ?? [];
697
+ }
698
+ finally {
699
+ revisionDb.close();
700
+ }
701
+ for (const field of targetedFields) {
702
+ if (liveRoleFields.includes(field)) {
703
+ const typedField = field;
704
+ liveRoleOverrides[role].set(typedField, defaults?.[typedField]);
705
+ }
706
+ }
707
+ observedRoleDefaults[role] = { ...(defaults ?? {}) };
708
+ observedProjectRevision[role] = revision;
709
+ if (roleChanged || targetedFields.length)
710
+ effectiveRoleRevision[role] = revision;
711
+ }
712
+ if (effectiveRoleRevision[role] <= base.settings_revision)
713
+ return base;
714
+ const merged = {
663
715
  ...base,
664
716
  source: defaults ? "project" : base.source,
665
- make: defaults?.make ?? base.make,
666
- model: defaults?.model ?? base.model,
667
- reasoning: defaults?.reasoning ?? base.reasoning,
668
- fast: defaults?.fast ?? base.fast,
669
- session_strategy: defaults?.session_strategy ?? base.session_strategy,
670
- display_session_cost: defaults?.display_session_cost ?? base.display_session_cost,
671
- auto_compact_threshold_percent: defaults?.auto_compact_threshold_percent ?? base.auto_compact_threshold_percent,
672
- compact_maximum: defaults?.compact_maximum ?? base.compact_maximum,
673
- settings_revision: revision,
717
+ settings_revision: effectiveRoleRevision[role],
674
718
  };
719
+ const mutable = merged;
720
+ for (const [field, value] of liveRoleOverrides[role]) {
721
+ if (value === undefined)
722
+ continue;
723
+ mutable[field] = value;
724
+ }
725
+ return merged;
675
726
  };
676
727
  const createBuilderForSettings = async (builderCwd, settings) => {
677
728
  const ready = await ensureRuntimeReadyForCommand(builderCwd, settings.make, {
@@ -696,6 +747,10 @@ export function buildStartCommand() {
696
747
  fast: settings.fast,
697
748
  systemPromptAppend: roleBundle.system || undefined,
698
749
  skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
750
+ contextManagementPolicy: {
751
+ role: "builder", configuredThresholdPercent: settings.auto_compact_threshold_percent,
752
+ compactMaximum: settings.compact_maximum, settingsRevision: settings.settings_revision, model: settings.model,
753
+ },
699
754
  };
700
755
  const created = settings.make === "codex" ? new CodexAdapter(adapterOptions) : await ClaudeAdapter.create(adapterOptions);
701
756
  return !branchMode ? new CurrentWorkflowGuardAdapter(created, builderCwd) : created;
@@ -722,6 +777,10 @@ export function buildStartCommand() {
722
777
  fast: settings.fast,
723
778
  systemPromptAppend: `${roleBundle.system}\n\nYou are an independent QA reviewer. Do not edit source, tickets, configuration, or project documentation. You may run tests and create only harmless ignored caches or coverage output.`,
724
779
  skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
780
+ contextManagementPolicy: {
781
+ role: "qa", configuredThresholdPercent: settings.auto_compact_threshold_percent,
782
+ compactMaximum: settings.compact_maximum, settingsRevision: settings.settings_revision, model: settings.model,
783
+ },
725
784
  };
726
785
  return settings.make === "codex" ? new CodexAdapter(adapterOptions) : await ClaudeAdapter.create(adapterOptions);
727
786
  };
@@ -928,6 +987,13 @@ export function buildStartCommand() {
928
987
  fast: qaFast,
929
988
  systemPromptAppend: `${roleBundle.system}\n\nYou are an independent QA reviewer. Do not edit source, tickets, configuration, or project documentation. You may run tests and create only harmless ignored caches or coverage output.`,
930
989
  skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
990
+ contextManagementPolicy: {
991
+ role: "qa",
992
+ configuredThresholdPercent: roleDefaults.qa?.auto_compact_threshold_percent ?? 50,
993
+ compactMaximum: roleDefaults.qa?.compact_maximum ?? 10,
994
+ settingsRevision,
995
+ model: qaModel ?? "default",
996
+ },
931
997
  };
932
998
  return qaAgent === "codex"
933
999
  ? new CodexAdapter(adapterOpts)
@@ -1048,6 +1114,7 @@ export function buildStartCommand() {
1048
1114
  finally {
1049
1115
  db.close();
1050
1116
  } },
1117
+ contextSnapshot: activeBuilderContextSnapshot,
1051
1118
  });
1052
1119
  });
1053
1120
  };
@@ -1151,6 +1218,7 @@ export function buildStartCommand() {
1151
1218
  projectDir: cwd,
1152
1219
  runId: activeContinuityRunId,
1153
1220
  role: "qa",
1221
+ requireNativeContextManagement: true,
1154
1222
  initialSettings: qaSettings,
1155
1223
  historicalCountUncertain: Boolean(recoveryRecord && (recoveryRecord.legacy || !recoveryRecord.runDecisions)),
1156
1224
  readSettings: () => liveRoleSettings("qa", qaSettings),
@@ -1163,6 +1231,7 @@ export function buildStartCommand() {
1163
1231
  report: (event) => {
1164
1232
  currentActivity()?.update(event.kind, event.detail, { provider: qaSettings.make, model: qaSettings.model });
1165
1233
  log.write("context-lifecycle", { role: "qa", ...event, sample: event.sample });
1234
+ void liveStatusReporter?.tick();
1166
1235
  },
1167
1236
  handoff: async ({ reason, adapter: predecessor, sample, settings, compactionCount }) => {
1168
1237
  const nativeUsage = await predecessor.sessionUsage?.().catch(() => undefined);
@@ -1183,6 +1252,7 @@ export function buildStartCommand() {
1183
1252
  return decorateQa(transfer.successor, qaCwd, undefined, settings);
1184
1253
  },
1185
1254
  });
1255
+ liveStatusReporter?.updateState({ contextSnapshot: () => controller.contextSnapshot() });
1186
1256
  return (await controller.atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter;
1187
1257
  };
1188
1258
  const qaNonconvergence = createQaNonconvergenceHandler(cwd, Boolean(opts.yes), roleDefaults.planner);
@@ -1286,12 +1356,68 @@ export function buildStartCommand() {
1286
1356
  });
1287
1357
  let auditBuilder;
1288
1358
  let auditViewer;
1359
+ let auditCompleted = false;
1289
1360
  const auditRunId = `audit-${stamp}`;
1290
1361
  activeContinuityRunId = auditRunId;
1291
1362
  try {
1363
+ const auditSettings = resolvedContinuitySettings("builder");
1364
+ const auditInstruction = buildBranchAuditInstruction(plan.nodes.map((node) => node.ticket));
1365
+ const auditDb = new WorkflowDb(cwd);
1366
+ try {
1367
+ auditDb.ensureRun(auditRunId);
1368
+ auditDb.appendContinuityEvent({
1369
+ runId: auditRunId, role: "host", kind: "branch_audit_initialized",
1370
+ payload: { frozenActionDigest: createHash("sha256").update(auditInstruction).digest("hex") },
1371
+ authoritativeStateRevision: auditSettings.settings_revision,
1372
+ });
1373
+ auditDb.publishContinuityCheckpoint({
1374
+ runId: auditRunId, role: "builder", authoritativeStateRevision: auditSettings.settings_revision,
1375
+ delta: {
1376
+ version: 1, decisions: [], constraints: [], discoveries: [], completedActions: [], evidence: [], failures: [], blockers: [],
1377
+ openWork: ["complete the selected-ticket dependency audit"], nextAction: "complete the selected-ticket dependency audit",
1378
+ },
1379
+ });
1380
+ }
1381
+ finally {
1382
+ auditDb.close();
1383
+ }
1292
1384
  auditBuilder = await createBuilder(cwd);
1385
+ const auditController = RoleSessionController.managed({
1386
+ projectDir: cwd, runId: auditRunId, role: "builder", initialSettings: auditSettings,
1387
+ requireNativeContextManagement: true,
1388
+ readSettings: () => liveRoleSettings("builder", auditSettings),
1389
+ settingsBoundary: (input) => applyBuilderSettingsBoundary(auditRunId, cwd, input),
1390
+ report: (event) => log.write("context-lifecycle", { phase: "branch-audit", ...event, sample: event.sample }),
1391
+ handoff: async ({ reason, adapter, sample, settings, compactionCount, frozenAction }) => {
1392
+ const nativeUsage = await adapter.sessionUsage?.().catch(() => undefined);
1393
+ const transfer = await new HandoffService(cwd).transfer({
1394
+ runId: auditRunId, role: "builder", reason, predecessorSessionId: adapter.sessionId(), predecessorSessionRef: adapter.sessionRef?.(),
1395
+ roleState: { phase: "branch-audit", frozenActionDigest: createHash("sha256").update(frozenAction).digest("hex"), contextSample: sample },
1396
+ ...(nativeUsage ? { sessionUsage: {
1397
+ version: 1, runId: auditRunId, role: "builder", provider: adapter.agent,
1398
+ providerSessionId: adapter.sessionId(), observedAt: nativeUsage.observedAt, source: nativeUsage.source,
1399
+ cumulativeInputTokens: nativeUsage.inputTokens, cumulativeOutputTokens: nativeUsage.outputTokens,
1400
+ cumulativeTotalTokens: nativeUsage.totalTokens, authoritativeCostUsd: nativeUsage.authoritativeCostUsd,
1401
+ } } : {}),
1402
+ compactionCount, compactMaximum: settings.compact_maximum,
1403
+ resources: [{ label: "frozen-branch-audit", content: frozenAction, authoritative: true }],
1404
+ }, () => createBuilderForSettings(cwd, settings));
1405
+ log.write("handoff-transfer", {
1406
+ phase: "branch-audit", reason, generation: transfer.manifest.generation,
1407
+ predecessorSessionId: transfer.manifest.predecessorSessionId, successorSessionId: transfer.successorSessionId,
1408
+ acceptanceCheckpointDigest: transfer.acceptanceCheckpointDigest,
1409
+ });
1410
+ if (adapter instanceof ContinuityAdapter) {
1411
+ await adapter.adoptValidatedSuccessor(transfer.successor);
1412
+ return adapter;
1413
+ }
1414
+ await adapter.close().catch(() => { });
1415
+ return continuousBuilder(transfer.successor, cwd, auditRunId, settings);
1416
+ },
1417
+ });
1418
+ auditBuilder = (await auditController.atSafeBoundary(auditBuilder, auditInstruction)).adapter;
1293
1419
  auditViewer = printEvents(auditBuilder.events());
1294
- const audit = await auditBuilder.sendTurn(buildBranchAuditInstruction(plan.nodes.map((node) => node.ticket)));
1420
+ const audit = await auditBuilder.sendTurn(auditInstruction);
1295
1421
  if (audit.isError)
1296
1422
  throw new Error(audit.text);
1297
1423
  const auditDependencies = parseAuditDependencies(audit.text);
@@ -1305,6 +1431,7 @@ export function buildStartCommand() {
1305
1431
  ticketIds: selectedNewTicket ? [selectedNewTicket] : selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
1306
1432
  });
1307
1433
  auditDependencyCount = auditDependencies.length;
1434
+ auditCompleted = true;
1308
1435
  }
1309
1436
  finally {
1310
1437
  await auditBuilder?.close().catch(() => { });
@@ -1312,7 +1439,9 @@ export function buildStartCommand() {
1312
1439
  const auditDb = new WorkflowDb(cwd);
1313
1440
  try {
1314
1441
  if (auditDb.getRun(auditRunId))
1315
- auditDb.transition(auditRunId, { status: "completed", checkpoint: "branch-audit-complete", remainingWork: {}, event: "audit_completed" });
1442
+ auditDb.transition(auditRunId, auditCompleted
1443
+ ? { status: "completed", checkpoint: "branch-audit-complete", remainingWork: {}, event: "audit_completed" }
1444
+ : { status: "paused", checkpoint: "branch-audit-interrupted", remainingWork: { action: "complete the selected-ticket dependency audit" }, event: "audit_interrupted" });
1316
1445
  }
1317
1446
  finally {
1318
1447
  auditDb.close();
@@ -1416,7 +1545,8 @@ export function buildStartCommand() {
1416
1545
  role: "qa", source: roleDefaults.qa ? "project" : "provider", make: qaAgent, model: qaModel ?? "default",
1417
1546
  reasoning: qaEffort ?? "default", fast: qaFast, session_strategy: roleDefaults.qa?.session_strategy ?? "compact", settings_revision: settingsRevision,
1418
1547
  display_session_cost: sessionCostOverride ?? roleDefaults.qa?.display_session_cost ?? false,
1419
- auto_compact_threshold_percent: 50, compact_maximum: 10,
1548
+ auto_compact_threshold_percent: roleDefaults.qa?.auto_compact_threshold_percent ?? 50,
1549
+ compact_maximum: roleDefaults.qa?.compact_maximum ?? 10,
1420
1550
  };
1421
1551
  const firstResumeRef = plan.nodes[0] ? resumeSessionByTicket.get(plan.nodes[0].ticket.id)?.sessionRef : undefined;
1422
1552
  let masterRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBranchBuilder, qa: capturedBranchQa, builderSessionId: firstResumeRef?.sessionId ?? null, builderSessionRef: firstResumeRef ?? null }) : createBuildRun({
@@ -1432,6 +1562,7 @@ export function buildStartCommand() {
1432
1562
  return existing;
1433
1563
  const controller = RoleSessionController.managed({
1434
1564
  projectDir: cwd, runId: masterRun.runId, role: "builder", initialSettings: capturedBranchBuilder,
1565
+ requireNativeContextManagement: true,
1435
1566
  historicalCountUncertain: Boolean(recoveryRecord && (recoveryRecord.legacy || !recoveryRecord.runDecisions)),
1436
1567
  readSettings: () => liveRoleSettings("builder", capturedBranchBuilder),
1437
1568
  settingsBoundary: (input) => applyBuilderSettingsBoundary(masterRun.runId, worktreePath, input),
@@ -1440,7 +1571,7 @@ export function buildStartCommand() {
1440
1571
  adapter: active, settingsRevision: settings.settings_revision, displaySessionCost: settings.display_session_cost,
1441
1572
  sessionTransition: "adopted live settings",
1442
1573
  }),
1443
- report: (event) => { currentActivity()?.update(event.kind, event.detail, { provider: capturedBranchBuilder.make, model: capturedBranchBuilder.model }); log.write("context-lifecycle", { worktreePath, ...event, sample: event.sample }); },
1574
+ report: (event) => { currentActivity()?.update(event.kind, event.detail, { provider: capturedBranchBuilder.make, model: capturedBranchBuilder.model }); log.write("context-lifecycle", { worktreePath, ...event, sample: event.sample }); void liveStatusReporter?.tick(); },
1444
1575
  handoff: async ({ reason, adapter, sample, settings, compactionCount, frozenAction }) => {
1445
1576
  const nativeUsage = await adapter.sessionUsage?.().catch(() => undefined);
1446
1577
  const transfer = await new HandoffService(cwd).transfer({
@@ -1492,7 +1623,7 @@ export function buildStartCommand() {
1492
1623
  archiveDoc: ticketsConfig.paths.archiveDoc,
1493
1624
  },
1494
1625
  resumeSessions: resumeSessionByTicket,
1495
- createBuilder: (builderCwd, sessionId, sessionRef) => createBuilder(builderCwd, sessionId, sessionRef),
1626
+ createBuilder: async (builderCwd, sessionId, sessionRef) => branchContextController(builderCwd).manage(await createBuilder(builderCwd, sessionId, sessionRef)),
1496
1627
  recordBuilderSession: (session, ticketId) => { masterRun = persistBuildSession(cwd, { ...masterRun, currentTicket: ticketId }, "builder", session); },
1497
1628
  recordQaSession: (session, ticketId) => { masterRun = persistBuildSession(cwd, { ...masterRun, currentTicket: ticketId }, "qa", session); },
1498
1629
  onSessionUnavailable: (error) => { branchSessionFailure = error; },
@@ -1502,6 +1633,9 @@ export function buildStartCommand() {
1502
1633
  observeBuilder: async (builder) => {
1503
1634
  liveStatusReporter?.stop();
1504
1635
  activeBuilderForStatus = () => builder;
1636
+ activeBuilderContextSnapshot = () => [...branchContextControllers.values()]
1637
+ .map((controller) => controller.contextSnapshot())
1638
+ .find((snapshot) => snapshot?.contextSample.providerSessionId === builder.sessionId());
1505
1639
  const reporter = liveStatusReporter = new AgentStatusReporter({
1506
1640
  runId: masterRun.runId, role: "builder", provider: builder.agent, model: capturedBranchBuilder.model,
1507
1641
  reasoning: capturedBranchBuilder.reasoning, fast: capturedBranchBuilder.fast, step: Math.min(plan.nodes.length, branchContextControllers.size + 1), total: plan.nodes.length,
@@ -1519,6 +1653,7 @@ export function buildStartCommand() {
1519
1653
  finally {
1520
1654
  db.close();
1521
1655
  } },
1656
+ contextSnapshot: activeBuilderContextSnapshot,
1522
1657
  }, (line, snapshot) => {
1523
1658
  const activity = currentActivity();
1524
1659
  if (activity && process.stdout.isTTY)
@@ -1529,7 +1664,6 @@ export function buildStartCommand() {
1529
1664
  const db = new WorkflowDb(cwd);
1530
1665
  try {
1531
1666
  db.recordTelemetry(masterRun.runId, snapshot);
1532
- db.recordContextSample(snapshot.contextSample);
1533
1667
  if (snapshot.sessionUsage)
1534
1668
  db.recordSessionUsage(snapshot.sessionUsage);
1535
1669
  }
@@ -1653,7 +1787,7 @@ export function buildStartCommand() {
1653
1787
  auto_compact_threshold_percent: thresholdOverride ?? roleDefaults.builder?.auto_compact_threshold_percent ?? 50,
1654
1788
  compact_maximum: roleDefaults.builder?.compact_maximum ?? 10,
1655
1789
  };
1656
- const capturedQa = { role: "qa", source: roleDefaults.qa ? "project" : "provider", make: qaAgent, model: qaModel ?? "default", reasoning: qaEffort ?? "default", fast: qaFast, session_strategy: roleDefaults.qa?.session_strategy ?? "compact", display_session_cost: sessionCostOverride ?? roleDefaults.qa?.display_session_cost ?? false, auto_compact_threshold_percent: 50, compact_maximum: 10, settings_revision: settingsRevision };
1790
+ const capturedQa = { role: "qa", source: roleDefaults.qa ? "project" : "provider", make: qaAgent, model: qaModel ?? "default", reasoning: qaEffort ?? "default", fast: qaFast, session_strategy: roleDefaults.qa?.session_strategy ?? "compact", display_session_cost: sessionCostOverride ?? roleDefaults.qa?.display_session_cost ?? false, auto_compact_threshold_percent: roleDefaults.qa?.auto_compact_threshold_percent ?? 50, compact_maximum: roleDefaults.qa?.compact_maximum ?? 10, settings_revision: settingsRevision };
1657
1791
  let buildRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBuilder, qa: capturedQa, builderSessionId: resumeSessionId ?? null, builderSessionRef: resumeSessionRef ?? null }) : createBuildRun({
1658
1792
  tickets: [], repositoryRoot: cwd, branchMode: "current", baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch, builder: capturedBuilder, qa: capturedQa,
1659
1793
  runDecisions: { workMode: "current", workModeSource, branchPrefix, branchPrefixSource: resolvedPrefix.source, autoCompactThresholdPercent: capturedBuilder.auto_compact_threshold_percent ?? 50, thresholdSource: thresholdOverride === undefined ? "project" : "cli" },
@@ -1691,6 +1825,7 @@ export function buildStartCommand() {
1691
1825
  projectDir: cwd,
1692
1826
  runId: buildRun.runId,
1693
1827
  role: "builder",
1828
+ requireNativeContextManagement: true,
1694
1829
  initialSettings: capturedBuilder,
1695
1830
  historicalCountUncertain: Boolean(recoveryRecord && (recoveryRecord.legacy || !recoveryRecord.runDecisions)),
1696
1831
  readSettings: () => liveRoleSettings("builder", capturedBuilder),
@@ -1703,6 +1838,7 @@ export function buildStartCommand() {
1703
1838
  report: (event) => {
1704
1839
  currentActivity()?.update(event.kind, event.detail, { provider: capturedBuilder.make, model: capturedBuilder.model });
1705
1840
  log.write("context-lifecycle", { ...event, sample: event.sample });
1841
+ void liveStatusReporter?.tick();
1706
1842
  },
1707
1843
  handoff: async ({ reason, adapter, sample, settings, compactionCount, frozenAction }) => {
1708
1844
  const nativeUsage = await adapter.sessionUsage?.().catch(() => undefined);
@@ -1732,6 +1868,8 @@ export function buildStartCommand() {
1732
1868
  return continuousBuilder(transfer.successor, cwd, buildRun.runId, settings);
1733
1869
  },
1734
1870
  });
1871
+ builder = contextController.manage(builder);
1872
+ activeBuilderContextSnapshot = () => contextController.contextSnapshot();
1735
1873
  const foreman = new Foreman(builder, log, config.notifications.enabled, qaEnabled, 3, cwd, undefined, qaEnabled ? createQa : undefined, capturedQa.session_strategy, createBuilder, capturedBuilder.session_strategy, qaNonconvergence, async (adapter, frozenAction) => (await contextController.atSafeBoundary(adapter, frozenAction)).adapter, async (adapter, frozenAction, strategy) => (await contextController.atWorkSessionBoundary(adapter, frozenAction, strategy)).adapter, qaSessionBoundary);
1736
1874
  activeBuilderForStatus = () => foreman.builderAdapter();
1737
1875
  const statusReporter = liveStatusReporter = new AgentStatusReporter({
@@ -1753,6 +1891,7 @@ export function buildStartCommand() {
1753
1891
  finally {
1754
1892
  db.close();
1755
1893
  } },
1894
+ contextSnapshot: () => contextController.contextSnapshot(),
1756
1895
  }, (line, snapshot) => {
1757
1896
  const activity = currentActivity();
1758
1897
  if (activity && process.stdout.isTTY)
@@ -1763,7 +1902,6 @@ export function buildStartCommand() {
1763
1902
  const db = new WorkflowDb(cwd);
1764
1903
  try {
1765
1904
  db.recordTelemetry(buildRun.runId, snapshot);
1766
- db.recordContextSample(snapshot.contextSample);
1767
1905
  if (snapshot.sessionUsage)
1768
1906
  db.recordSessionUsage(snapshot.sessionUsage);
1769
1907
  }
@@ -1,5 +1,5 @@
1
1
  import type { ContinuityDelta, ResolvedAgentSettings } from "rafi-spec";
2
- import type { BuilderAdapter, CompactResult, ContextUsage, ProviderSessionUsage, ProviderSettingSwitch, RuntimeFailure, TurnResult } from "./adapters/types.js";
2
+ import type { BuilderAdapter, CompactResult, ContextManagementPolicy, ContextUsage, InterruptResult, ManagedTurnDispatcher, PreparedContextManagement, ProviderSessionUsage, ProviderSettingSwitch, RuntimeFailure, TurnResult } from "./adapters/types.js";
3
3
  export declare const CONTINUITY_MARKER = "RAFI_CONTINUITY_DELTA:";
4
4
  export declare class ContinuityValidationError extends Error {
5
5
  readonly problems: string[];
@@ -38,7 +38,7 @@ export interface ContinuityAdapterOptions {
38
38
  role: "builder" | "qa";
39
39
  settings: ResolvedAgentSettings;
40
40
  authoritativeStateRevision?: () => number;
41
- /** Fresh successor creation after the same-session repair also fails. */
41
+ /** @deprecated Use recoverWithHandoff so successor acceptance is lifecycle-managed and durable. */
42
42
  createSuccessor?: (handoff: string) => Promise<BuilderAdapter>;
43
43
  /**
44
44
  * Production recovery path for a double-invalid delta. The callback must
@@ -62,6 +62,7 @@ export declare class ContinuityAdapter implements BuilderAdapter {
62
62
  private readonly queue;
63
63
  private sourcePump?;
64
64
  private recoveryLeasePending;
65
+ private turnDispatcher?;
65
66
  constructor(options: ContinuityAdapterOptions);
66
67
  get agent(): "claude" | "codex";
67
68
  sendTurn(instruction: string): Promise<TurnResult>;
@@ -70,6 +71,10 @@ export declare class ContinuityAdapter implements BuilderAdapter {
70
71
  adoptSessionRef(ref: import("rafi-spec").ProviderSessionRefV1): void;
71
72
  validateSession(): Promise<import("rafi-spec").SessionAvailabilityV1>;
72
73
  compact(): Promise<CompactResult>;
74
+ prepareContextManagement(policy: ContextManagementPolicy): Promise<PreparedContextManagement>;
75
+ updateContextManagement(policy: ContextManagementPolicy): Promise<PreparedContextManagement>;
76
+ interruptTurnAtCompactionBoundary(providerEventId?: string): Promise<InterruptResult>;
77
+ installManagedTurnDispatcher(dispatcher: ManagedTurnDispatcher): void;
73
78
  contextUsage(): Promise<ContextUsage | undefined>;
74
79
  sessionUsage(): Promise<ProviderSessionUsage | undefined>;
75
80
  switchSettings(settings: ProviderSettingSwitch): Promise<CompactResult>;
@@ -77,6 +82,7 @@ export declare class ContinuityAdapter implements BuilderAdapter {
77
82
  close(): Promise<void>;
78
83
  /** Keep the observable wrapper and event stream stable while moving to an accepted successor. */
79
84
  adoptValidatedSuccessor(successor: BuilderAdapter): Promise<void>;
85
+ private providerTurn;
80
86
  private publish;
81
87
  private moveRecoveryLeaseAfterCheckpoint;
82
88
  private recordSessionUnavailable;
@@ -109,6 +109,7 @@ export class ContinuityAdapter {
109
109
  queue = new BuilderEventQueue();
110
110
  sourcePump;
111
111
  recoveryLeasePending;
112
+ turnDispatcher;
112
113
  constructor(options) {
113
114
  this.options = options;
114
115
  this.adapter = options.adapter;
@@ -138,7 +139,7 @@ export class ContinuityAdapter {
138
139
  finally {
139
140
  db.close();
140
141
  }
141
- const original = await this.adapter.sendTurn(`${instruction}\n\n${continuityInstruction()}`);
142
+ const original = await this.providerTurn(`${instruction}\n\n${continuityInstruction()}`);
142
143
  const activeSession = this.adapter.sessionRef?.() ?? this.adapter.sessionId();
143
144
  if (activeSession) {
144
145
  const leaseDb = new WorkflowDb(this.options.projectDir);
@@ -153,6 +154,30 @@ export class ContinuityAdapter {
153
154
  this.recordSessionUnavailable(original.failure, instruction);
154
155
  throw new SessionUnavailableContinuityError(this.options.runId, this.options.role, original.failure);
155
156
  }
157
+ if (original.interrupted?.reason === "compaction-boundary") {
158
+ // The coordinator owns the validated handoff and replay of this frozen
159
+ // action. A protocol-repair turn here would run in the interrupted
160
+ // predecessor (or race successor adoption) without a completed delta.
161
+ const interruptedDb = new WorkflowDb(this.options.projectDir);
162
+ try {
163
+ interruptedDb.appendContinuityEvent({
164
+ runId: this.options.runId,
165
+ role: "host",
166
+ kind: "turn_interrupted_at_compaction_boundary",
167
+ payload: {
168
+ role: this.options.role,
169
+ providerEventId: original.interrupted.providerEventId,
170
+ instructionDigest: sha(instruction),
171
+ },
172
+ authoritativeStateRevision: this.revision(),
173
+ sessionRef: this.adapter.sessionRef?.(),
174
+ });
175
+ }
176
+ finally {
177
+ interruptedDb.close();
178
+ }
179
+ return original;
180
+ }
156
181
  const parsed = parseContinuityDelta(original.text);
157
182
  if (parsed.delta) {
158
183
  this.publish(parsed.delta, "turn_completed", original);
@@ -167,12 +192,37 @@ export class ContinuityAdapter {
167
192
  }
168
193
  return { ...original, text: parsed.cleanText };
169
194
  }
170
- const repair = await this.adapter.sendTurn([
195
+ const repair = await this.providerTurn([
171
196
  "Continuity protocol repair only. Do not run tools, repeat work, or change files.",
172
197
  `Your prior turn's continuity record was invalid: ${parsed.error?.problems.join("; ")}.`,
173
198
  continuityInstruction(),
174
199
  "Return only the continuity record.",
175
200
  ].join("\n"));
201
+ if (repair.interrupted?.reason === "compaction-boundary") {
202
+ // Preserve the predecessor and let the outer managed dispatch perform
203
+ // the validated handoff/replay. Treating this as another invalid delta
204
+ // would race continuity recovery against the coordinator and could lose
205
+ // the original frozen action.
206
+ const interruptedDb = new WorkflowDb(this.options.projectDir);
207
+ try {
208
+ interruptedDb.appendContinuityEvent({
209
+ runId: this.options.runId,
210
+ role: "host",
211
+ kind: "continuity_repair_interrupted_at_compaction_boundary",
212
+ payload: {
213
+ role: this.options.role,
214
+ providerEventId: repair.interrupted.providerEventId,
215
+ instructionDigest: sha(instruction),
216
+ },
217
+ authoritativeStateRevision: this.revision(),
218
+ sessionRef: this.adapter.sessionRef?.(),
219
+ });
220
+ }
221
+ finally {
222
+ interruptedDb.close();
223
+ }
224
+ return repair;
225
+ }
176
226
  const repaired = parseContinuityDelta(repair.text);
177
227
  if (repaired.delta) {
178
228
  this.publish(repaired.delta, "turn_completed_after_repair", original);
@@ -207,31 +257,25 @@ export class ContinuityAdapter {
207
257
  await this.adoptValidatedSuccessor(successor);
208
258
  return { ...original, text: parsed.cleanText };
209
259
  }
210
- if (!this.options.createSuccessor)
211
- throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "continuity repair failed in the original session and no validated successor is configured");
212
- const successor = await this.options.createSuccessor(handoff);
213
- const accepted = await successor.sendTurn(`${handoff}\n\nReply with HANDOFF_ACCEPTED on the first line, then ${continuityInstruction()}`);
214
- const successorDelta = parseContinuityDelta(accepted.text);
215
- if (!/^HANDOFF_ACCEPTED\b/m.test(accepted.text) || !successorDelta.delta || !successor.sessionId()) {
216
- await successor.close().catch(() => { });
217
- throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "fresh successor did not validate and accept the cumulative checkpoint");
218
- }
219
- await this.adoptValidatedSuccessor(successor);
220
- this.publish(successorDelta.delta, "fresh_successor_accepted", accepted);
221
- const leaseDb = new WorkflowDb(this.options.projectDir);
222
- try {
223
- leaseDb.moveRoleLeaseAfterValidatedRecovery(this.options.runId, this.options.role, successor.sessionRef?.() ?? successor.sessionId(), "double-invalid continuity recovery");
224
- }
225
- finally {
226
- leaseDb.close();
227
- }
228
- return { ...original, text: parsed.cleanText };
260
+ // A raw create-and-send fallback cannot install native context control,
261
+ // account compactions, or validate the scoped successor atomically. Refuse
262
+ // it instead of allowing continuity recovery to escape the managed gate.
263
+ throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "continuity repair failed in the original session and no lifecycle-managed recoverWithHandoff callback is configured");
229
264
  }
230
265
  sessionId() { return this.adapter.sessionId(); }
231
266
  sessionRef() { return this.adapter.sessionRef?.(); }
232
267
  adoptSessionRef(ref) { this.adapter.adoptSessionRef?.(ref); }
233
268
  validateSession() { return this.adapter.validateSession?.() ?? Promise.resolve({ version: 1, status: "unknown", checkedAt: new Date().toISOString(), reason: "legacy-unscoped" }); }
234
269
  compact() { return this.adapter.compact ? this.adapter.compact() : Promise.resolve({ ok: false, error: "provider adapter does not expose native compaction" }); }
270
+ prepareContextManagement(policy) { if (!this.adapter.prepareContextManagement)
271
+ return Promise.reject(new Error("provider adapter does not expose native context management")); return this.adapter.prepareContextManagement(policy); }
272
+ updateContextManagement(policy) { if (!this.adapter.updateContextManagement)
273
+ return Promise.reject(new Error("provider adapter does not expose native context reconfiguration")); return this.adapter.updateContextManagement(policy); }
274
+ interruptTurnAtCompactionBoundary(providerEventId) { return this.adapter.interruptTurnAtCompactionBoundary?.(providerEventId) ?? Promise.resolve({ ok: false, error: "provider adapter does not expose compaction-boundary interruption", providerEventId }); }
275
+ installManagedTurnDispatcher(dispatcher) {
276
+ this.turnDispatcher = dispatcher;
277
+ this.adapter.installManagedTurnDispatcher?.(dispatcher);
278
+ }
235
279
  contextUsage() { return this.adapter.contextUsage?.() ?? Promise.resolve(undefined); }
236
280
  sessionUsage() { return this.adapter.sessionUsage?.() ?? Promise.resolve(undefined); }
237
281
  switchSettings(settings) { return this.adapter.switchSettings ? this.adapter.switchSettings(settings) : Promise.resolve({ ok: false, error: "provider adapter does not support settings changes" }); }
@@ -243,9 +287,15 @@ export class ContinuityAdapter {
243
287
  return;
244
288
  const prior = this.adapter;
245
289
  this.adapter = successor;
290
+ if (this.turnDispatcher)
291
+ successor.installManagedTurnDispatcher?.(this.turnDispatcher);
246
292
  this.pumpEvents();
247
293
  await prior.close().catch(() => { });
248
294
  }
295
+ providerTurn(text) {
296
+ const invoke = () => this.adapter.sendTurn(text);
297
+ return this.turnDispatcher ? this.turnDispatcher(text, invoke) : invoke();
298
+ }
249
299
  publish(delta, kind, result) {
250
300
  const db = new WorkflowDb(this.options.projectDir);
251
301
  try {
@@ -322,7 +372,7 @@ export class ContinuityAdapter {
322
372
  try {
323
373
  for await (const event of source.events()) {
324
374
  this.queue.push(event);
325
- if (event.kind === "tool" || event.kind === "session-transition" || event.kind === "context-usage") {
375
+ if (event.kind === "tool" || event.kind === "session-transition" || event.kind === "context-usage" || event.kind === "context-compaction") {
326
376
  const db = new WorkflowDb(this.options.projectDir);
327
377
  try {
328
378
  const payload = event.kind === "tool"