ai-foreman 1.7.9 → 1.7.11

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/README.md CHANGED
@@ -15,9 +15,9 @@ Loop Claude Code or Codex through tickets.
15
15
  - **Rich ticket system** — tickets carry acceptance criteria, required tests, dependencies, priority, size, and risk level. The agent reads the full context before starting each step.
16
16
  - **Future work tracking** — when the builder discovers out-of-scope work during a run, `ai-foreman tickets discover` captures it without derailing the current ticket. Discovered items live in a separate inbox until you promote them.
17
17
  - **QA that actually gates** — QA runs after every completed ticket and checks code quality, test passing, and regression protection. The ticket only closes after QA passes. QA turns do not count against `--steps`.
18
- - **Visible long-running work** — one elapsed-time activity line identifies provider, phase, tools, retries, and quiet periods; redirected output receives timestamped heartbeats instead of ANSI animation.
18
+ - **Visible long-running work** — semantic activity rows identify provider, phase, tools, retries, and quiet periods; cursor-capable terminals update changing counters in place, while record-oriented terminals coalesce them.
19
19
 
20
- Provider-managed retries are reported immediately as durable lines. A quiet-provider warning appears after 60 seconds without a signal, but Foreman does not add retries, abort the provider, or change provider retry limits. Non-TTY output receives a heartbeat every 30 seconds while work remains pending.
20
+ Provider-managed retries are reported immediately as durable lines. A quiet-provider warning appears after 60 seconds without a signal, but Foreman does not add retries, abort the provider, or change provider retry limits. Non-TTY output receives a heartbeat every 30 seconds while work remains pending. TTY rendering defaults to automatic detection: Codex, CI, and `TERM=dumb` use newline records and suppress spinner- or number-only repeats; other terminals redraw those changes in place and retain a row when the textual status changes. Use `RAFI_ACTIVITY_RENDER_MODE=auto|cursor|records` to override detection.
21
21
 
22
22
  ## Install
23
23
 
@@ -146,7 +146,7 @@ Saved `tickets.build` defaults in `rafi-config.yaml` can enable branch-per-ticke
146
146
 
147
147
  With the saved `current` strategy, Foreman works in the active branch while the user owns Git. It may edit, test, run QA, and update tracker/recovery state, but all branch/worktree, commit, push, merge, rebase, and review lifecycle commands are fenced. An unexpected active ref or worktree change pauses the run. Explicit isolated flags are reported as run overrides.
148
148
 
149
- The active terminal line always includes the current 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.
149
+ 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
150
 
151
151
  ### GitHub PR Failure Recovery
152
152
 
@@ -11,7 +11,10 @@ export interface ActivityReporterOptions {
11
11
  tickMs?: number;
12
12
  heartbeatMs?: number;
13
13
  quietWarningMs?: number;
14
+ /** Override automatic TTY capability detection for tests or embedding hosts. */
15
+ ttyMode?: ActivityTtyMode;
14
16
  }
17
+ export type ActivityTtyMode = "auto" | "cursor" | "records";
15
18
  /** One process-local live view shared by nested RAFI operations. */
16
19
  export declare class ActivityReporter {
17
20
  readonly command: string;
@@ -21,6 +24,7 @@ export declare class ActivityReporter {
21
24
  private readonly tickMs;
22
25
  private readonly heartbeatMs;
23
26
  private readonly quietWarningMs;
27
+ private readonly ttyMode;
24
28
  private readonly commandStartedAt;
25
29
  private readonly phases;
26
30
  private nextPhaseId;
@@ -33,6 +37,7 @@ export declare class ActivityReporter {
33
37
  private quietWarningPrinted;
34
38
  private lastHeartbeatAt;
35
39
  private lineVisible;
40
+ private lastSemanticKey?;
36
41
  private paused;
37
42
  private agentStatusLine?;
38
43
  constructor(command: string, options?: ActivityReporterOptions);
@@ -45,7 +50,7 @@ export declare class ActivityReporter {
45
50
  pulse(detail?: string): void;
46
51
  note(message: string): void;
47
52
  writePersistent(text: string): void;
48
- /** Keep role/provider/context state in the single bottom-most TTY line. */
53
+ /** Keep role/provider/context state attached to the current activity status. */
49
54
  setAgentStatus(line: string | undefined): void;
50
55
  pause(): () => void;
51
56
  dispose(): void;
package/dist/activity.js CHANGED
@@ -10,6 +10,7 @@ export class ActivityReporter {
10
10
  tickMs;
11
11
  heartbeatMs;
12
12
  quietWarningMs;
13
+ ttyMode;
13
14
  commandStartedAt;
14
15
  phases = new Map();
15
16
  nextPhaseId = 1;
@@ -22,6 +23,7 @@ export class ActivityReporter {
22
23
  quietWarningPrinted = false;
23
24
  lastHeartbeatAt = 0;
24
25
  lineVisible = false;
26
+ lastSemanticKey;
25
27
  paused = 0;
26
28
  agentStatusLine;
27
29
  constructor(command, options = {}) {
@@ -35,6 +37,7 @@ export class ActivityReporter {
35
37
  this.tickMs = options.tickMs ?? 1_000;
36
38
  this.heartbeatMs = options.heartbeatMs ?? 30_000;
37
39
  this.quietWarningMs = options.quietWarningMs ?? 60_000;
40
+ this.ttyMode = resolveTtyMode(options.ttyMode);
38
41
  this.commandStartedAt = this.now();
39
42
  }
40
43
  begin(label) {
@@ -91,7 +94,7 @@ export class ActivityReporter {
91
94
  this.output.write("\n");
92
95
  this.render(true);
93
96
  }
94
- /** Keep role/provider/context state in the single bottom-most TTY line. */
97
+ /** Keep role/provider/context state attached to the current activity status. */
95
98
  setAgentStatus(line) {
96
99
  this.agentStatusLine = line ? clean(line, 500) : undefined;
97
100
  if (this.agentStatusLine && !this.timer) {
@@ -130,7 +133,6 @@ export class ActivityReporter {
130
133
  }
131
134
  idle() {
132
135
  if (this.agentStatusLine) {
133
- this.clearLine();
134
136
  this.detail = undefined;
135
137
  this.provider = undefined;
136
138
  this.model = undefined;
@@ -145,6 +147,7 @@ export class ActivityReporter {
145
147
  this.provider = undefined;
146
148
  this.model = undefined;
147
149
  this.agentStatusLine = undefined;
150
+ this.lastSemanticKey = undefined;
148
151
  }
149
152
  currentPhase() {
150
153
  return [...this.phases.values()].at(-1);
@@ -168,8 +171,21 @@ export class ActivityReporter {
168
171
  const body = [phase?.label, provider || undefined, phase ? quiet : undefined, this.agentStatusLine].filter(Boolean).join(" — ");
169
172
  if (this.output.isTTY) {
170
173
  const line = `${FRAMES[this.frame++ % FRAMES.length]} RAFI working: ${body} (${formatDuration(now - this.commandStartedAt)})`;
174
+ const semanticKey = semanticStatusKey(body);
175
+ if (this.ttyMode === "records") {
176
+ if (semanticKey === this.lastSemanticKey)
177
+ return;
178
+ this.output.write(`${line}\n`);
179
+ this.lastSemanticKey = semanticKey;
180
+ return;
181
+ }
182
+ if (this.lineVisible && semanticKey !== this.lastSemanticKey) {
183
+ this.output.write("\n");
184
+ this.lineVisible = false;
185
+ }
171
186
  this.output.write(`\r\x1b[2K${line}`);
172
187
  this.lineVisible = true;
188
+ this.lastSemanticKey = semanticKey;
173
189
  return;
174
190
  }
175
191
  if (force || now - this.lastHeartbeatAt >= this.heartbeatMs) {
@@ -260,6 +276,27 @@ function briefInput(input) {
260
276
  function clean(value, maximum) {
261
277
  return value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim().slice(0, maximum);
262
278
  }
279
+ function semanticStatusKey(body) {
280
+ return body.replace(/\p{Number}+(?:[.,]\p{Number}+)*/gu, "#");
281
+ }
282
+ function resolveTtyMode(requested) {
283
+ const configured = requested ?? configuredTtyMode(process.env.RAFI_ACTIVITY_RENDER_MODE);
284
+ if (configured === "cursor" || configured === "records")
285
+ return configured;
286
+ return truthyEnvironmentValue(process.env.CODEX_CI)
287
+ || truthyEnvironmentValue(process.env.CI)
288
+ || process.env.TERM?.toLowerCase() === "dumb"
289
+ ? "records"
290
+ : "cursor";
291
+ }
292
+ function configuredTtyMode(value) {
293
+ return value === "cursor" || value === "records" || value === "auto" ? value : "auto";
294
+ }
295
+ function truthyEnvironmentValue(value) {
296
+ if (value === undefined)
297
+ return false;
298
+ return !["", "0", "false"].includes(value.trim().toLowerCase());
299
+ }
263
300
  function formatDuration(milliseconds) {
264
301
  const seconds = Math.max(0, Math.floor(milliseconds / 1_000));
265
302
  if (seconds < 60)
@@ -1,7 +1,7 @@
1
1
  import type { SDKSessionInfo, PermissionResult } from "@anthropic-ai/claude-agent-sdk";
2
2
  /** Lazy-load the Claude Agent SDK. Throws an actionable error if not installed. */
3
3
  export declare function requireClaudeSDK(): Promise<typeof import("@anthropic-ai/claude-agent-sdk")>;
4
- import type { BuilderAdapter, BuilderAdapterOptions, BuilderEvent, NativeCompaction, CompactResult, ContextUsage, PermissionDecision, ProviderSettingSwitch, ProviderSessionUsage, TurnResult } from "./types.js";
4
+ import type { BuilderAdapter, BuilderAdapterOptions, BuilderEvent, NativeCompaction, NativeAutoCompactionPolicy, CompactResult, ContextUsage, PermissionDecision, ProviderSettingSwitch, ProviderSessionUsage, TurnResult } from "./types.js";
5
5
  import type { ProviderSessionRefV1, SessionAvailabilityV1 } from "rafi-spec";
6
6
  /**
7
7
  * Pure function: build the `options` object passed to `query()`.
@@ -57,6 +57,7 @@ export declare class ClaudeAdapter implements BuilderAdapter {
57
57
  private compactResult?;
58
58
  private autoCompactionPrepared;
59
59
  private preparedAutoCompactThreshold?;
60
+ private preparedAutoCompactionPolicy?;
60
61
  private manualCompactionInFlight;
61
62
  private nativeCompactions;
62
63
  private nativeCompactionSequence;
@@ -78,7 +79,8 @@ export declare class ClaudeAdapter implements BuilderAdapter {
78
79
  adoptSessionRef(ref: ProviderSessionRefV1): void;
79
80
  validateSession(): Promise<SessionAvailabilityV1>;
80
81
  compact(): Promise<CompactResult>;
81
- prepareAutoCompaction(thresholdPercent?: number | undefined): Promise<void>;
82
+ prepareAutoCompaction(thresholdPercent?: number | undefined): Promise<NativeAutoCompactionPolicy | void>;
83
+ autoCompactionPolicy(): NativeAutoCompactionPolicy | undefined;
82
84
  drainNativeCompactions(): NativeCompaction[];
83
85
  restoreNativeCompactions(compactions: NativeCompaction[]): void;
84
86
  contextUsage(): Promise<ContextUsage | undefined>;
@@ -154,6 +154,7 @@ export class ClaudeAdapter {
154
154
  compactResult;
155
155
  autoCompactionPrepared = false;
156
156
  preparedAutoCompactThreshold;
157
+ preparedAutoCompactionPolicy;
157
158
  manualCompactionInFlight = false;
158
159
  nativeCompactions = [];
159
160
  nativeCompactionSequence = 0;
@@ -450,7 +451,7 @@ export class ClaudeAdapter {
450
451
  return;
451
452
  const threshold = validThreshold(thresholdPercent);
452
453
  if (this.autoCompactionPrepared && this.preparedAutoCompactThreshold === threshold)
453
- return;
454
+ return this.preparedAutoCompactionPolicy;
454
455
  this.opts.autoCompactThresholdPercent = threshold;
455
456
  await this.query.initializationResult();
456
457
  await this.query.applyFlagSettings({ autoCompactEnabled: true });
@@ -469,13 +470,26 @@ export class ClaudeAdapter {
469
470
  const requestedWindow = tokenLimit(baseline.maxTokens, threshold) + reserve;
470
471
  await this.query.applyFlagSettings({ autoCompactEnabled: true, autoCompactWindow: requestedWindow });
471
472
  const installed = await this.query.getContextUsage();
472
- if (!installed.isAutoCompactEnabled || installed.autoCompactThreshold === undefined
473
- || installed.autoCompactThreshold > tokenLimit(baseline.maxTokens, threshold)) {
474
- throw new Error("Claude did not accept the requested native automatic-compaction ceiling");
473
+ if (!installed.isAutoCompactEnabled || !Number.isFinite(installed.maxTokens) || installed.maxTokens <= 0
474
+ || !Number.isFinite(installed.autoCompactThreshold) || installed.autoCompactThreshold === undefined) {
475
+ throw new Error("Claude did not expose an enabled native automatic-compaction threshold after configuration");
475
476
  }
477
+ // `applyFlagSettings` can legitimately clamp autoCompactWindow. The
478
+ // provider's reported trigger is authoritative; rejecting the entire run
479
+ // merely because a configured value is below that minimum made defaults
480
+ // unusable. Lifecycle code consumes this verified effective ceiling.
481
+ const effectiveThresholdPercent = Math.max(1, Math.min(99, Math.floor(installed.autoCompactThreshold / installed.maxTokens * 100)));
482
+ this.preparedAutoCompactionPolicy = {
483
+ requestedThresholdPercent: threshold,
484
+ effectiveThresholdPercent,
485
+ modelContextWindow: installed.maxTokens,
486
+ triggerTokens: installed.autoCompactThreshold,
487
+ };
476
488
  this.autoCompactionPrepared = true;
477
489
  this.preparedAutoCompactThreshold = threshold;
490
+ return this.preparedAutoCompactionPolicy;
478
491
  }
492
+ autoCompactionPolicy() { return this.preparedAutoCompactionPolicy; }
479
493
  drainNativeCompactions() {
480
494
  const pending = this.nativeCompactions;
481
495
  this.nativeCompactions = [];
@@ -1,4 +1,4 @@
1
- import type { BuilderAdapter, BuilderAdapterOptions, BuilderEvent, CompactResult, ContextUsage, NativeCompaction, ProviderSessionUsage, ProviderSettingSwitch, TurnResult } from "./types.js";
1
+ import type { BuilderAdapter, BuilderAdapterOptions, BuilderEvent, CompactResult, ContextUsage, NativeAutoCompactionPolicy, NativeCompaction, ProviderSessionUsage, ProviderSettingSwitch, TurnResult } from "./types.js";
2
2
  import type { ProviderSessionRefV1, SessionAvailabilityV1 } from "rafi-spec";
3
3
  export interface CodexLineResult {
4
4
  events: BuilderEvent[];
@@ -30,6 +30,7 @@ export declare class CodexAdapter implements BuilderAdapter {
30
30
  private nativeAutoCompactTokenLimit?;
31
31
  private autoCompactionPrepared;
32
32
  private preparedAutoCompactThreshold?;
33
+ private preparedAutoCompactionPolicy?;
33
34
  private nativeCompactions;
34
35
  private nativeCompactionSequence;
35
36
  private manualCompactionInFlight;
@@ -42,7 +43,8 @@ export declare class CodexAdapter implements BuilderAdapter {
42
43
  sendTurn(instruction: string): Promise<TurnResult>;
43
44
  private sendTurnInternal;
44
45
  compact(): Promise<CompactResult>;
45
- prepareAutoCompaction(thresholdPercent?: number | undefined): Promise<void>;
46
+ prepareAutoCompaction(thresholdPercent?: number | undefined): Promise<NativeAutoCompactionPolicy | void>;
47
+ autoCompactionPolicy(): NativeAutoCompactionPolicy | undefined;
46
48
  drainNativeCompactions(): NativeCompaction[];
47
49
  restoreNativeCompactions(compactions: NativeCompaction[]): void;
48
50
  contextUsage(): Promise<ContextUsage | undefined>;
@@ -46,6 +46,7 @@ export class CodexAdapter {
46
46
  nativeAutoCompactTokenLimit;
47
47
  autoCompactionPrepared = false;
48
48
  preparedAutoCompactThreshold;
49
+ preparedAutoCompactionPolicy;
49
50
  nativeCompactions = [];
50
51
  nativeCompactionSequence = 0;
51
52
  manualCompactionInFlight = false;
@@ -181,7 +182,7 @@ export class CodexAdapter {
181
182
  return;
182
183
  const threshold = validThreshold(thresholdPercent);
183
184
  if (this.autoCompactionPrepared && this.preparedAutoCompactThreshold === threshold)
184
- return;
185
+ return this.preparedAutoCompactionPolicy;
185
186
  this.opts.autoCompactThresholdPercent = threshold;
186
187
  await this.ensureThread();
187
188
  // Codex reports its model context window in token-usage notifications, not
@@ -205,7 +206,15 @@ export class CodexAdapter {
205
206
  await this.ensureThread();
206
207
  this.autoCompactionPrepared = true;
207
208
  this.preparedAutoCompactThreshold = threshold;
209
+ this.preparedAutoCompactionPolicy = {
210
+ requestedThresholdPercent: threshold,
211
+ effectiveThresholdPercent: threshold,
212
+ modelContextWindow: maximum,
213
+ triggerTokens: this.nativeAutoCompactTokenLimit,
214
+ };
215
+ return this.preparedAutoCompactionPolicy;
208
216
  }
217
+ autoCompactionPolicy() { return this.preparedAutoCompactionPolicy; }
209
218
  drainNativeCompactions() {
210
219
  const pending = this.nativeCompactions;
211
220
  this.nativeCompactions = [];
@@ -1,5 +1,5 @@
1
1
  import type { AgentRuntime } from "../runtimeAuth.js";
2
- import type { BuilderAdapter, BuilderEvent, CompactResult, ContextUsage, NativeCompaction, ProviderSessionUsage, TurnResult } from "./types.js";
2
+ import type { BuilderAdapter, BuilderEvent, CompactResult, ContextUsage, NativeAutoCompactionPolicy, NativeCompaction, ProviderSessionUsage, TurnResult } from "./types.js";
3
3
  import type { ProviderSessionRefV1, SessionAvailabilityV1 } from "rafi-spec";
4
4
  export type TurnRecoveryChoice = "retry" | "switch" | "cancel";
5
5
  export interface RecoveringAdapterOptions {
@@ -29,7 +29,8 @@ export declare class RecoveringAdapter implements BuilderAdapter {
29
29
  adoptSessionRef(ref: ProviderSessionRefV1): void;
30
30
  validateSession(): Promise<SessionAvailabilityV1>;
31
31
  compact(): Promise<CompactResult>;
32
- prepareAutoCompaction(thresholdPercent?: number): Promise<void>;
32
+ prepareAutoCompaction(thresholdPercent?: number): Promise<NativeAutoCompactionPolicy | void>;
33
+ autoCompactionPolicy(): NativeAutoCompactionPolicy | undefined;
33
34
  drainNativeCompactions(): import("./types.js").NativeCompaction[];
34
35
  restoreNativeCompactions(compactions: NativeCompaction[]): void;
35
36
  contextUsageAfterNativeCompaction(compaction: NativeCompaction): Promise<ContextUsage | undefined>;
@@ -68,6 +68,7 @@ export class RecoveringAdapter {
68
68
  }
69
69
  compact() { return this.adapter.compact?.() ?? Promise.resolve({ ok: false, error: "native compaction unavailable" }); }
70
70
  prepareAutoCompaction(thresholdPercent) { return this.adapter.prepareAutoCompaction?.(thresholdPercent) ?? Promise.resolve(); }
71
+ autoCompactionPolicy() { return this.adapter.autoCompactionPolicy?.(); }
71
72
  drainNativeCompactions() { return this.adapter.drainNativeCompactions?.() ?? []; }
72
73
  restoreNativeCompactions(compactions) { this.adapter.restoreNativeCompactions?.(compactions); }
73
74
  contextUsageAfterNativeCompaction(compaction) { return this.adapter.contextUsageAfterNativeCompaction?.(compaction) ?? Promise.resolve(undefined); }
@@ -108,6 +108,17 @@ export interface ContextUsage {
108
108
  observedAt?: string;
109
109
  source?: "provider-event" | "provider-query" | "post-compact";
110
110
  }
111
+ /**
112
+ * The provider-native automatic-compaction policy that was actually installed.
113
+ * Providers may clamp a requested ceiling to a model- or runtime-specific
114
+ * minimum, so lifecycle enforcement must consume the effective value.
115
+ */
116
+ export interface NativeAutoCompactionPolicy {
117
+ requestedThresholdPercent: number;
118
+ effectiveThresholdPercent: number;
119
+ modelContextWindow?: number;
120
+ triggerTokens?: number;
121
+ }
111
122
  export interface ProviderSessionUsage {
112
123
  inputTokens?: number;
113
124
  outputTokens?: number;
@@ -194,7 +205,9 @@ export interface BuilderAdapter {
194
205
  * dispatched. This intentionally happens outside a work turn so a long,
195
206
  * tool-heavy first turn is protected too.
196
207
  */
197
- prepareAutoCompaction?(thresholdPercent?: number): Promise<void>;
208
+ prepareAutoCompaction?(thresholdPercent?: number): Promise<NativeAutoCompactionPolicy | void>;
209
+ /** The last provider-native automatic-compaction policy verified on this transport. */
210
+ autoCompactionPolicy?(): NativeAutoCompactionPolicy | undefined;
198
211
  /** Consume provider-native compactions observed since the prior drain. */
199
212
  drainNativeCompactions?(): NativeCompaction[];
200
213
  /** Return a drained batch to the adapter when durable receipt failed. */
@@ -1,4 +1,4 @@
1
- import type { BuilderAdapter, BuilderEvent, CompactResult, ContextUsage, NativeCompaction, ProviderSessionUsage, ProviderSettingSwitch, TurnResult } from "../adapters/types.js";
1
+ import type { BuilderAdapter, BuilderEvent, CompactResult, ContextUsage, NativeAutoCompactionPolicy, NativeCompaction, ProviderSessionUsage, ProviderSettingSwitch, TurnResult } from "../adapters/types.js";
2
2
  import type { ProviderSessionRefV1, SessionAvailabilityV1 } from "rafi-spec";
3
3
  export interface CurrentWorkflowIdentity {
4
4
  worktree: string;
@@ -26,7 +26,8 @@ export declare class CurrentWorkflowGuardAdapter implements BuilderAdapter {
26
26
  adoptSessionRef(ref: ProviderSessionRefV1): void;
27
27
  validateSession(): Promise<SessionAvailabilityV1>;
28
28
  compact(): Promise<CompactResult>;
29
- prepareAutoCompaction(thresholdPercent?: number): Promise<void>;
29
+ prepareAutoCompaction(thresholdPercent?: number): Promise<NativeAutoCompactionPolicy | void>;
30
+ autoCompactionPolicy(): NativeAutoCompactionPolicy | undefined;
30
31
  drainNativeCompactions(): import("../adapters/types.js").NativeCompaction[];
31
32
  restoreNativeCompactions(compactions: NativeCompaction[]): void;
32
33
  contextUsageAfterNativeCompaction(compaction: NativeCompaction): Promise<ContextUsage | undefined>;
@@ -49,6 +49,7 @@ export class CurrentWorkflowGuardAdapter {
49
49
  validateSession() { assertCurrentWorkflowIdentity(this.cwd, this.expected); return this.adapter.validateSession?.() ?? Promise.resolve({ version: 1, status: "unknown", checkedAt: new Date().toISOString(), reason: "legacy-unscoped" }); }
50
50
  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
51
  prepareAutoCompaction(thresholdPercent) { return this.adapter.prepareAutoCompaction?.(thresholdPercent) ?? Promise.resolve(); }
52
+ autoCompactionPolicy() { return this.adapter.autoCompactionPolicy?.(); }
52
53
  drainNativeCompactions() { return this.adapter.drainNativeCompactions?.() ?? []; }
53
54
  restoreNativeCompactions(compactions) { this.adapter.restoreNativeCompactions?.(compactions); }
54
55
  contextUsageAfterNativeCompaction(compaction) { return this.adapter.contextUsageAfterNativeCompaction?.(compaction) ?? Promise.resolve(undefined); }
package/dist/cli/start.js CHANGED
@@ -1959,7 +1959,12 @@ async function prepareNativeAutoCompaction(adapter) {
1959
1959
  throw new Error(`${adapter.agent} does not support provider-native automatic compaction`);
1960
1960
  }
1961
1961
  try {
1962
- await adapter.prepareAutoCompaction();
1962
+ const policy = await adapter.prepareAutoCompaction();
1963
+ if (policy && policy.effectiveThresholdPercent !== policy.requestedThresholdPercent) {
1964
+ console.log(`foreman: ${adapter.agent} applied automatic compaction at ${policy.effectiveThresholdPercent}% `
1965
+ + `(configured ${policy.requestedThresholdPercent}%; provider minimum or clamp). `
1966
+ + "Use `rafi agents .` to update the Builder or QA saved threshold interactively.");
1967
+ }
1963
1968
  }
1964
1969
  catch (error) {
1965
1970
  await adapter.close().catch(() => { });
@@ -1,5 +1,5 @@
1
1
  import type { ContinuityDelta, ResolvedAgentSettings } from "rafi-spec";
2
- import type { BuilderAdapter, CompactResult, ContextUsage, NativeCompaction, ProviderSessionUsage, ProviderSettingSwitch, RuntimeFailure, TurnResult } from "./adapters/types.js";
2
+ import type { BuilderAdapter, CompactResult, ContextUsage, NativeAutoCompactionPolicy, NativeCompaction, 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[];
@@ -70,7 +70,8 @@ export declare class ContinuityAdapter implements BuilderAdapter {
70
70
  adoptSessionRef(ref: import("rafi-spec").ProviderSessionRefV1): void;
71
71
  validateSession(): Promise<import("rafi-spec").SessionAvailabilityV1>;
72
72
  compact(): Promise<CompactResult>;
73
- prepareAutoCompaction(thresholdPercent?: number): Promise<void>;
73
+ prepareAutoCompaction(thresholdPercent?: number): Promise<NativeAutoCompactionPolicy | void>;
74
+ autoCompactionPolicy(): NativeAutoCompactionPolicy | undefined;
74
75
  drainNativeCompactions(): import("./adapters/types.js").NativeCompaction[];
75
76
  restoreNativeCompactions(compactions: NativeCompaction[]): void;
76
77
  contextUsageAfterNativeCompaction(compaction: NativeCompaction): Promise<ContextUsage | undefined>;
@@ -233,6 +233,7 @@ export class ContinuityAdapter {
233
233
  validateSession() { return this.adapter.validateSession?.() ?? Promise.resolve({ version: 1, status: "unknown", checkedAt: new Date().toISOString(), reason: "legacy-unscoped" }); }
234
234
  compact() { return this.adapter.compact ? this.adapter.compact() : Promise.resolve({ ok: false, error: "provider adapter does not expose native compaction" }); }
235
235
  prepareAutoCompaction(thresholdPercent) { return this.adapter.prepareAutoCompaction?.(thresholdPercent) ?? Promise.resolve(); }
236
+ autoCompactionPolicy() { return this.adapter.autoCompactionPolicy?.(); }
236
237
  drainNativeCompactions() { return this.adapter.drainNativeCompactions?.() ?? []; }
237
238
  restoreNativeCompactions(compactions) { this.adapter.restoreNativeCompactions?.(compactions); }
238
239
  contextUsageAfterNativeCompaction(compaction) { return this.adapter.contextUsageAfterNativeCompaction?.(compaction) ?? Promise.resolve(undefined); }
@@ -118,6 +118,8 @@ export declare class ThresholdCompactionController {
118
118
  private crossing;
119
119
  private readonly seenSessions;
120
120
  private runOnlyThreshold?;
121
+ /** Verified provider ceiling. It may be higher than configured when the provider clamps. */
122
+ private providerEffectiveThreshold?;
121
123
  private bootstrapScheduled;
122
124
  private bootstrapRecoveryDepth;
123
125
  private historicalCountUncertain;
@@ -136,6 +138,7 @@ export declare class ThresholdCompactionController {
136
138
  private sampleFromUsage;
137
139
  private compactionCount;
138
140
  private handoffGeneration;
141
+ private syncProviderEffectiveThreshold;
139
142
  private threshold;
140
143
  private now;
141
144
  }
@@ -310,6 +310,8 @@ export class ThresholdCompactionController {
310
310
  crossing = 0;
311
311
  seenSessions = new Set();
312
312
  runOnlyThreshold;
313
+ /** Verified provider ceiling. It may be higher than configured when the provider clamps. */
314
+ providerEffectiveThreshold;
313
315
  bootstrapScheduled = false;
314
316
  bootstrapRecoveryDepth = 0;
315
317
  historicalCountUncertain;
@@ -320,6 +322,7 @@ export class ThresholdCompactionController {
320
322
  }
321
323
  async atSafeBoundary(adapter, frozenAction) {
322
324
  adapter = await this.adoptSettings(adapter, frozenAction);
325
+ this.syncProviderEffectiveThreshold(adapter);
323
326
  if (!adapter.sessionId() && !this.bootstrapScheduled) {
324
327
  this.bootstrapScheduled = true;
325
328
  const sample = {
@@ -376,6 +379,7 @@ export class ThresholdCompactionController {
376
379
  const revisionBeforeSettle = this.settings.settings_revision;
377
380
  const sessionBeforeSettle = adapterSessionKey(adapter);
378
381
  adapter = await this.adoptSettings(adapter, frozenAction);
382
+ this.syncProviderEffectiveThreshold(adapter);
379
383
  if (this.settings.settings_revision !== revisionBeforeSettle || adapterSessionKey(adapter) !== sessionBeforeSettle) {
380
384
  sample = await this.measure(adapter, "provider-query");
381
385
  session = adapter.sessionRef?.() ?? adapter.sessionId();
@@ -403,6 +407,7 @@ export class ThresholdCompactionController {
403
407
  /** Apply the configured ordinary work-session boundary without bypassing handoff or compaction accounting. */
404
408
  async atWorkSessionBoundary(adapter, frozenAction, strategy = this.settings.session_strategy) {
405
409
  adapter = await this.adoptSettings(adapter, frozenAction);
410
+ this.syncProviderEffectiveThreshold(adapter);
406
411
  const nativeCount = await this.observeNativeCompactions(adapter);
407
412
  const sample = await this.measure(adapter, "provider-query");
408
413
  const db = new WorkflowDb(this.options.projectDir);
@@ -657,6 +662,7 @@ export class ThresholdCompactionController {
657
662
  }
658
663
  this.settings = next;
659
664
  this.runOnlyThreshold = undefined;
665
+ this.syncProviderEffectiveThreshold(adoptedAdapter);
660
666
  const db = new WorkflowDb(this.options.projectDir);
661
667
  try {
662
668
  const ref = adoptedAdapter.sessionRef?.();
@@ -718,7 +724,14 @@ export class ThresholdCompactionController {
718
724
  db.close();
719
725
  }
720
726
  }
721
- threshold() { return this.runOnlyThreshold ?? this.settings.auto_compact_threshold_percent ?? 50; }
727
+ syncProviderEffectiveThreshold(adapter) {
728
+ const policy = adapter.autoCompactionPolicy?.();
729
+ const effective = policy?.effectiveThresholdPercent;
730
+ this.providerEffectiveThreshold = effective !== undefined && Number.isInteger(effective) && effective >= 1 && effective <= 99
731
+ ? effective
732
+ : undefined;
733
+ }
734
+ threshold() { return this.runOnlyThreshold ?? this.providerEffectiveThreshold ?? this.settings.auto_compact_threshold_percent ?? 50; }
722
735
  now() { return this.options.now?.() ?? new Date(); }
723
736
  }
724
737
  function normalizeContextSettings(settings) {
@@ -1,5 +1,5 @@
1
1
  import type { ContextSample, SessionUsageSample } from "rafi-spec";
2
- import type { BuilderAdapter, BuilderEvent, CompactResult, ContextUsage, NativeCompaction, ProviderSessionUsage, ProviderSettingSwitch, TurnResult } from "./adapters/types.js";
2
+ import type { BuilderAdapter, BuilderEvent, CompactResult, ContextUsage, NativeAutoCompactionPolicy, NativeCompaction, ProviderSessionUsage, ProviderSettingSwitch, TurnResult } from "./adapters/types.js";
3
3
  export interface AgentStatusSnapshot {
4
4
  role: "builder" | "qa";
5
5
  provider: "claude" | "codex";
@@ -82,7 +82,8 @@ export declare class RoleStatusAdapter implements BuilderAdapter {
82
82
  adoptSessionRef(ref: import("rafi-spec").ProviderSessionRefV1): void;
83
83
  validateSession(): Promise<import("rafi-spec").SessionAvailabilityV1>;
84
84
  compact(): Promise<CompactResult>;
85
- prepareAutoCompaction(thresholdPercent?: number): Promise<void>;
85
+ prepareAutoCompaction(thresholdPercent?: number): Promise<NativeAutoCompactionPolicy | void>;
86
+ autoCompactionPolicy(): NativeAutoCompactionPolicy | undefined;
86
87
  drainNativeCompactions(): import("./adapters/types.js").NativeCompaction[];
87
88
  restoreNativeCompactions(compactions: NativeCompaction[]): void;
88
89
  contextUsageAfterNativeCompaction(compaction: NativeCompaction): Promise<ContextUsage | undefined>;
@@ -178,6 +178,7 @@ export class RoleStatusAdapter {
178
178
  validateSession() { return this.adapter.validateSession?.() ?? Promise.resolve({ version: 1, status: "unknown", checkedAt: new Date().toISOString(), reason: "legacy-unscoped" }); }
179
179
  compact() { this.onActive(this.adapter); return this.adapter.compact?.() ?? Promise.resolve({ ok: false, error: "native compaction unavailable" }); }
180
180
  prepareAutoCompaction(thresholdPercent) { return this.adapter.prepareAutoCompaction?.(thresholdPercent) ?? Promise.resolve(); }
181
+ autoCompactionPolicy() { return this.adapter.autoCompactionPolicy?.(); }
181
182
  drainNativeCompactions() { return this.adapter.drainNativeCompactions?.() ?? []; }
182
183
  restoreNativeCompactions(compactions) { this.adapter.restoreNativeCompactions?.(compactions); }
183
184
  contextUsageAfterNativeCompaction(compaction) { return this.adapter.contextUsageAfterNativeCompaction?.(compaction) ?? Promise.resolve(undefined); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-foreman",
3
- "version": "1.7.9",
3
+ "version": "1.7.11",
4
4
  "description": "An orchestrator that keeps Codex CLI / Claude Code builders moving through their step list unattended. Part of Rafi.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",