ai-foreman 1.7.6 → 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.
Files changed (69) hide show
  1. package/README.md +26 -1
  2. package/dist/activity.d.ts +3 -0
  3. package/dist/activity.js +31 -6
  4. package/dist/adapters/claude.d.ts +61 -6
  5. package/dist/adapters/claude.js +544 -36
  6. package/dist/adapters/codex.d.ts +28 -1
  7. package/dist/adapters/codex.js +398 -27
  8. package/dist/adapters/recovering.d.ts +13 -2
  9. package/dist/adapters/recovering.js +32 -4
  10. package/dist/adapters/sessionFailure.d.ts +18 -0
  11. package/dist/adapters/sessionFailure.js +51 -0
  12. package/dist/adapters/types.d.ts +117 -9
  13. package/dist/agentRun.d.ts +4 -1
  14. package/dist/agentRun.js +96 -9
  15. package/dist/branch/currentGuard.d.ts +38 -0
  16. package/dist/branch/currentGuard.js +71 -0
  17. package/dist/branch/planner.d.ts +1 -1
  18. package/dist/branch/planner.js +2 -7
  19. package/dist/branch/prefix.d.ts +8 -0
  20. package/dist/branch/prefix.js +26 -0
  21. package/dist/branch/resume.d.ts +2 -0
  22. package/dist/branch/resume.js +8 -0
  23. package/dist/branch/runner.d.ts +14 -2
  24. package/dist/branch/runner.js +77 -17
  25. package/dist/buildRuns.d.ts +13 -4
  26. package/dist/buildRuns.js +85 -19
  27. package/dist/cli/handoffs.d.ts +2 -0
  28. package/dist/cli/handoffs.js +74 -0
  29. package/dist/cli/start.js +1079 -43
  30. package/dist/cli/tickets.js +199 -3
  31. package/dist/continuity.d.ts +92 -0
  32. package/dist/continuity.js +415 -0
  33. package/dist/foreman.d.ts +5 -1
  34. package/dist/foreman.js +41 -6
  35. package/dist/handoffs.d.ts +69 -0
  36. package/dist/handoffs.js +546 -0
  37. package/dist/log.d.ts +1 -1
  38. package/dist/permissions/policy.d.ts +5 -1
  39. package/dist/permissions/policy.js +48 -1
  40. package/dist/qaReview.d.ts +6 -6
  41. package/dist/qaReview.js +24 -14
  42. package/dist/sessionAvailability.d.ts +15 -0
  43. package/dist/sessionAvailability.js +57 -0
  44. package/dist/sessionIdentity.d.ts +33 -0
  45. package/dist/sessionIdentity.js +132 -0
  46. package/dist/sessionLifecycle.d.ts +198 -5
  47. package/dist/sessionLifecycle.js +1373 -20
  48. package/dist/statusReporter.d.ts +84 -8
  49. package/dist/statusReporter.js +270 -14
  50. package/dist/ticketPlanning.js +8 -0
  51. package/dist/ticketPopulation.js +88 -12
  52. package/dist/tickets/commands.js +20 -12
  53. package/dist/tickets/groupReset.d.ts +25 -0
  54. package/dist/tickets/groupReset.js +279 -0
  55. package/dist/tickets/groups.d.ts +36 -0
  56. package/dist/tickets/groups.js +68 -0
  57. package/dist/tickets/importer.js +27 -3
  58. package/dist/tickets/recommendations.js +31 -7
  59. package/dist/tickets/reset.d.ts +1 -0
  60. package/dist/tickets/reset.js +1 -0
  61. package/dist/tickets/setupConfig.d.ts +1 -0
  62. package/dist/tickets/setupConfig.js +10 -2
  63. package/dist/tickets/stateDb.d.ts +34 -0
  64. package/dist/tickets/stateDb.js +207 -0
  65. package/dist/tickets/ticketLoader.js +5 -2
  66. package/dist/tickets/validate.js +4 -0
  67. package/dist/workflowDb.d.ts +145 -3
  68. package/dist/workflowDb.js +571 -10
  69. package/package.json +32 -4
@@ -1,7 +1,8 @@
1
- import type { BuilderAdapter } from "./adapters/types.js";
1
+ import type { ContextSample, SessionUsageSample } from "rafi-spec";
2
+ import type { BuilderAdapter, BuilderEvent, CompactResult, ContextUsage, ProviderSessionUsage, ProviderSettingSwitch, TurnResult } from "./adapters/types.js";
2
3
  export interface AgentStatusSnapshot {
3
- role: string;
4
- provider: string;
4
+ role: "builder" | "qa";
5
+ provider: "claude" | "codex";
5
6
  model: string;
6
7
  reasoning: string;
7
8
  fast: boolean;
@@ -11,12 +12,22 @@ export interface AgentStatusSnapshot {
11
12
  total: number;
12
13
  phase: string;
13
14
  qaCycle?: number;
15
+ /** Compatibility projection. New consumers should use contextSample. */
14
16
  context?: {
15
17
  used: number;
16
18
  maximum?: number;
17
19
  percentage?: number;
18
20
  };
21
+ contextSample: ContextSample;
22
+ sessionUsage?: SessionUsageSample;
19
23
  sessionTransition: string;
24
+ lifecycleState?: string;
25
+ configuredCeilingPercent?: number;
26
+ installedNativeTokenLimit?: number;
27
+ installedNativePercent?: number;
28
+ compactionCount?: number;
29
+ compactMaximum?: number;
30
+ settingsRevision?: number;
20
31
  at: string;
21
32
  }
22
33
  export interface StatusClock {
@@ -24,17 +35,82 @@ export interface StatusClock {
24
35
  setInterval(callback: () => void, milliseconds: number): unknown;
25
36
  clearInterval(handle: unknown): void;
26
37
  }
27
- /** Non-blocking five-minute status with provider-truthful context occupancy. */
38
+ export interface AgentStatusReporterInput {
39
+ runId: string;
40
+ role: "builder" | "qa";
41
+ provider: "claude" | "codex";
42
+ model: string;
43
+ reasoning: string;
44
+ fast: boolean;
45
+ ticket?: string;
46
+ stack?: string;
47
+ step: number;
48
+ total: number;
49
+ phase: string;
50
+ qaCycle?: number;
51
+ sessionTransition: string;
52
+ settingsRevision: number;
53
+ displaySessionCost: boolean;
54
+ adapter: BuilderAdapter | (() => BuilderAdapter);
55
+ compactionCount?: number | (() => number);
56
+ handoffGeneration?: number | (() => number);
57
+ /** Shared enforcement snapshot. When supplied, status never polls occupancy independently. */
58
+ contextSnapshot?: () => {
59
+ role: "builder" | "qa";
60
+ provider: "claude" | "codex";
61
+ model: string;
62
+ lifecycleState: string;
63
+ contextSample: ContextSample;
64
+ configuredCeilingPercent: number;
65
+ installedNativeTokenLimit?: number;
66
+ installedNativePercent?: number;
67
+ compactionCount: number;
68
+ compactMaximum: number;
69
+ settingsRevision: number;
70
+ } | undefined;
71
+ }
72
+ /**
73
+ * A non-blocking ten-second reporter. It never invents occupancy or cost: an
74
+ * absent provider sample is rendered as measuring, stale, or unavailable.
75
+ */
28
76
  export declare class AgentStatusReporter {
29
77
  private readonly input;
30
78
  private readonly emit;
31
79
  private readonly clock;
32
80
  private readonly intervalMs;
33
81
  private handle?;
34
- constructor(input: Omit<AgentStatusSnapshot, "context" | "at"> & {
35
- adapter: BuilderAdapter | (() => BuilderAdapter);
36
- }, emit: (line: string, snapshot: AgentStatusSnapshot) => void, clock?: StatusClock, intervalMs?: number);
82
+ private lastGoodContext?;
83
+ private readonly lastGoodByRole;
84
+ constructor(input: AgentStatusReporterInput, emit: (line: string, snapshot: AgentStatusSnapshot) => void, clock?: StatusClock, intervalMs?: number);
37
85
  start(): void;
38
86
  tick(): Promise<void>;
39
- stop(): void;
87
+ stop(finalPhase?: string): void;
88
+ updateState(patch: Partial<Omit<AgentStatusReporterInput, "runId">>): void;
89
+ private emitSnapshot;
90
+ private measuringSample;
91
+ private contextSample;
92
+ private adapter;
93
+ }
94
+ /** Switches the shared live status to a role for the lifetime of its adapter. */
95
+ export declare class RoleStatusAdapter implements BuilderAdapter {
96
+ private readonly adapter;
97
+ private readonly onActive;
98
+ private readonly onClose;
99
+ readonly agent: "claude" | "codex";
100
+ constructor(adapter: BuilderAdapter, onActive: (adapter: BuilderAdapter) => void, onClose: () => void);
101
+ sendTurn(text: string): Promise<TurnResult>;
102
+ sessionId(): string | undefined;
103
+ sessionRef(): import("rafi-spec").ProviderSessionRefV1 | undefined;
104
+ adoptSessionRef(ref: import("rafi-spec").ProviderSessionRefV1): void;
105
+ validateSession(): Promise<import("rafi-spec").SessionAvailabilityV1>;
106
+ compact(): Promise<CompactResult>;
107
+ prepareContextManagement(policy: import("./adapters/types.js").ContextManagementPolicy): Promise<import("./adapters/types.js").PreparedContextManagement>;
108
+ updateContextManagement(policy: import("./adapters/types.js").ContextManagementPolicy): Promise<import("./adapters/types.js").PreparedContextManagement>;
109
+ interruptTurnAtCompactionBoundary(providerEventId?: string): Promise<import("./adapters/types.js").InterruptResult>;
110
+ installManagedTurnDispatcher(dispatcher: import("./adapters/types.js").ManagedTurnDispatcher): void;
111
+ contextUsage(): Promise<ContextUsage | undefined>;
112
+ sessionUsage(): Promise<ProviderSessionUsage | undefined>;
113
+ switchSettings(settings: ProviderSettingSwitch): Promise<CompactResult>;
114
+ events(): AsyncIterable<BuilderEvent>;
115
+ close(): Promise<void>;
40
116
  }
@@ -1,31 +1,287 @@
1
+ import { providerSessionKey } from "./sessionIdentity.js";
1
2
  const systemClock = {
2
3
  now: () => new Date(),
3
- setInterval: (callback, milliseconds) => { const handle = setInterval(callback, milliseconds); handle.unref(); return handle; },
4
+ setInterval: (callback, milliseconds) => {
5
+ const handle = setInterval(callback, milliseconds);
6
+ handle.unref();
7
+ return handle;
8
+ },
4
9
  clearInterval: (handle) => clearInterval(handle),
5
10
  };
6
- /** Non-blocking five-minute status with provider-truthful context occupancy. */
11
+ /**
12
+ * A non-blocking ten-second reporter. It never invents occupancy or cost: an
13
+ * absent provider sample is rendered as measuring, stale, or unavailable.
14
+ */
7
15
  export class AgentStatusReporter {
8
16
  input;
9
17
  emit;
10
18
  clock;
11
19
  intervalMs;
12
20
  handle;
13
- constructor(input, emit, clock = systemClock, intervalMs = 300_000) {
21
+ lastGoodContext;
22
+ lastGoodByRole = new Map();
23
+ constructor(input, emit, clock = systemClock, intervalMs = 10_000) {
14
24
  this.input = input;
15
25
  this.emit = emit;
16
26
  this.clock = clock;
17
27
  this.intervalMs = intervalMs;
18
28
  }
19
- start() { if (this.handle !== undefined)
20
- return; this.handle = this.clock.setInterval(() => void this.tick(), this.intervalMs); }
29
+ start() {
30
+ if (this.handle !== undefined)
31
+ return;
32
+ this.emitSnapshot(this.measuringSample(), undefined);
33
+ this.handle = this.clock.setInterval(() => void this.tick(), this.intervalMs);
34
+ }
21
35
  async tick() {
22
- const adapter = typeof this.input.adapter === "function" ? this.input.adapter() : this.input.adapter;
23
- const context = await adapter.contextUsage?.();
24
- const { adapter: _adapter, ...base } = this.input;
25
- const snapshot = { ...base, ...(context ? { context } : {}), at: this.clock.now().toISOString() };
26
- const occupancy = context ? `context: ${context.used}/${context.maximum ?? "unknown"}${context.percentage === undefined ? "" : ` (${context.percentage.toFixed(1)}%)`}` : "context: unavailable";
27
- this.emit(`[${snapshot.at}] ${snapshot.role} ${snapshot.provider}/${snapshot.model} reasoning=${snapshot.reasoning} fast=${snapshot.fast}; ${snapshot.ticket ? `ticket=${snapshot.ticket}; ` : ""}${snapshot.stack ? `stack=${snapshot.stack}; ` : ""}step=${snapshot.step}/${snapshot.total}; phase=${snapshot.phase}${snapshot.qaCycle ? `; qa_cycle=${snapshot.qaCycle}` : ""}; ${occupancy}; session=${snapshot.sessionTransition}`, snapshot);
28
- }
29
- stop() { if (this.handle !== undefined)
30
- this.clock.clearInterval(this.handle); this.handle = undefined; }
36
+ const adapter = this.adapter();
37
+ const now = this.clock.now();
38
+ let contextSample;
39
+ const hasCoordinator = this.input.contextSnapshot !== undefined;
40
+ const coordinated = this.input.contextSnapshot?.();
41
+ if (coordinated && coordinated.role === this.input.role) {
42
+ contextSample = coordinated.contextSample;
43
+ this.input.provider = coordinated.provider;
44
+ this.input.model = coordinated.model;
45
+ this.input.settingsRevision = coordinated.settingsRevision;
46
+ this.input.compactionCount = coordinated.compactionCount;
47
+ if (contextSample.freshness === "fresh") {
48
+ this.lastGoodContext = contextSample;
49
+ this.lastGoodByRole.set(this.input.role, contextSample);
50
+ }
51
+ }
52
+ else if (!hasCoordinator) {
53
+ let observed;
54
+ try {
55
+ observed = await adapter.contextUsage?.();
56
+ }
57
+ catch {
58
+ observed = undefined;
59
+ }
60
+ if (observed && Number.isFinite(observed.used))
61
+ contextSample = this.contextSample(observed, now);
62
+ else if (this.lastGoodContext)
63
+ contextSample = { ...this.lastGoodContext, freshness: "stale" };
64
+ else
65
+ contextSample = { ...this.measuringSample(now), freshness: "unavailable" };
66
+ }
67
+ else {
68
+ const prior = this.lastGoodByRole.get(this.input.role);
69
+ contextSample = prior
70
+ ? { ...prior, freshness: "stale" }
71
+ : { ...this.measuringSample(now), freshness: "unavailable" };
72
+ }
73
+ if (contextSample.freshness === "fresh") {
74
+ this.lastGoodContext = contextSample;
75
+ this.lastGoodByRole.set(this.input.role, contextSample);
76
+ }
77
+ let sessionUsage;
78
+ if (this.input.displaySessionCost) {
79
+ try {
80
+ const usage = await adapter.sessionUsage?.();
81
+ const ref = adapter.sessionRef?.();
82
+ sessionUsage = usage ? {
83
+ version: 1,
84
+ runId: this.input.runId,
85
+ role: this.input.role,
86
+ provider: this.input.provider,
87
+ providerSessionId: adapter.sessionId(),
88
+ ...(ref ? { sessionRef: ref, sessionKey: providerSessionKey(ref) } : {}),
89
+ observedAt: usage.observedAt,
90
+ source: usage.source,
91
+ cumulativeInputTokens: usage.inputTokens,
92
+ cumulativeOutputTokens: usage.outputTokens,
93
+ cumulativeTotalTokens: usage.totalTokens,
94
+ authoritativeCostUsd: usage.authoritativeCostUsd,
95
+ } : unavailableUsage(this.input, adapter, now);
96
+ }
97
+ catch {
98
+ sessionUsage = unavailableUsage(this.input, adapter, now);
99
+ }
100
+ }
101
+ this.emitSnapshot(contextSample, sessionUsage, now);
102
+ }
103
+ stop(finalPhase = "completed") {
104
+ if (this.handle === undefined)
105
+ return;
106
+ this.clock.clearInterval(this.handle);
107
+ this.handle = undefined;
108
+ this.input.phase = finalPhase;
109
+ this.emitSnapshot(this.lastGoodContext ?? { ...this.measuringSample(), freshness: "unavailable" }, undefined);
110
+ }
111
+ updateState(patch) {
112
+ const priorRole = this.input.role;
113
+ const priorAdapter = this.adapter();
114
+ const priorSessionId = priorAdapter.sessionId();
115
+ if (this.lastGoodContext)
116
+ this.lastGoodByRole.set(priorRole, this.lastGoodContext);
117
+ Object.assign(this.input, patch);
118
+ if (priorRole !== this.input.role
119
+ || priorAdapter !== this.adapter()
120
+ || priorSessionId !== this.adapter().sessionId()
121
+ || patch.sessionTransition !== undefined)
122
+ this.lastGoodContext = this.lastGoodByRole.get(this.input.role);
123
+ this.emitSnapshot(this.measuringSample(), undefined);
124
+ }
125
+ emitSnapshot(contextSample, sessionUsage, now = this.clock.now()) {
126
+ const coordinated = this.input.contextSnapshot?.();
127
+ const { adapter: _adapter, displaySessionCost: _display, compactionCount: _compactions, handoffGeneration: _handoffs, contextSnapshot: _contextSnapshot, ...base } = this.input;
128
+ const context = contextSample.used === undefined ? undefined : {
129
+ used: contextSample.used,
130
+ maximum: contextSample.maximum,
131
+ percentage: contextSample.percentage,
132
+ };
133
+ const snapshot = {
134
+ ...base,
135
+ ...(context ? { context } : {}),
136
+ contextSample,
137
+ ...(sessionUsage ? { sessionUsage } : {}),
138
+ ...(coordinated && coordinated.role === this.input.role ? {
139
+ lifecycleState: coordinated.lifecycleState,
140
+ configuredCeilingPercent: coordinated.configuredCeilingPercent,
141
+ installedNativeTokenLimit: coordinated.installedNativeTokenLimit,
142
+ installedNativePercent: coordinated.installedNativePercent,
143
+ compactionCount: coordinated.compactionCount,
144
+ compactMaximum: coordinated.compactMaximum,
145
+ settingsRevision: coordinated.settingsRevision,
146
+ } : {
147
+ compactionCount: contextSample.compactionCount,
148
+ settingsRevision: contextSample.settingsRevision,
149
+ }),
150
+ at: now.toISOString(),
151
+ };
152
+ const line = [
153
+ `${snapshot.role} ${snapshot.provider}/${snapshot.model}`,
154
+ `activity=${snapshot.phase}`,
155
+ formatContext(contextSample),
156
+ ...(snapshot.configuredCeilingPercent === undefined ? [] : [`ceiling=${snapshot.configuredCeilingPercent}%`]),
157
+ ...(snapshot.installedNativePercent === undefined ? [] : [`native=${snapshot.installedNativePercent.toFixed(1)}%${snapshot.installedNativeTokenLimit === undefined ? "" : `/${snapshot.installedNativeTokenLimit}`}`]),
158
+ ...(snapshot.lifecycleState ? [`state=${formatLifecycle(snapshot.lifecycleState)}`] : []),
159
+ `compactions=${snapshot.compactionCount ?? contextSample.compactionCount}${snapshot.compactMaximum === undefined ? "" : `/${snapshot.compactMaximum}`}`,
160
+ `revision=${snapshot.settingsRevision ?? contextSample.settingsRevision}`,
161
+ `handoff=${contextSample.handoffGeneration}`,
162
+ `session=${snapshot.sessionTransition}`,
163
+ ...(this.input.displaySessionCost ? [formatSessionUsage(sessionUsage)] : []),
164
+ ].join("; ");
165
+ this.emit(`[${snapshot.at}] ${line}`, snapshot);
166
+ }
167
+ measuringSample(now = this.clock.now()) {
168
+ const ref = this.adapter().sessionRef?.();
169
+ return {
170
+ version: 1,
171
+ runId: this.input.runId,
172
+ role: this.input.role,
173
+ provider: this.input.provider,
174
+ providerSessionId: this.adapter().sessionId(),
175
+ ...(ref ? { sessionRef: ref, sessionKey: providerSessionKey(ref) } : {}),
176
+ model: this.input.model,
177
+ observedAt: now.toISOString(),
178
+ source: "provider-query",
179
+ freshness: "measuring",
180
+ settingsRevision: this.input.settingsRevision,
181
+ compactionCount: numeric(this.input.compactionCount),
182
+ handoffGeneration: numeric(this.input.handoffGeneration),
183
+ };
184
+ }
185
+ contextSample(usage, now) {
186
+ const ref = this.adapter().sessionRef?.();
187
+ return {
188
+ ...this.measuringSample(now),
189
+ providerSessionId: this.adapter().sessionId(),
190
+ ...(ref ? { sessionRef: ref, sessionKey: providerSessionKey(ref) } : {}),
191
+ observedAt: usage.observedAt ?? now.toISOString(),
192
+ source: usage.source ?? "provider-query",
193
+ freshness: "fresh",
194
+ used: usage.used,
195
+ maximum: usage.maximum,
196
+ percentage: usage.percentage ?? (usage.maximum ? (usage.used / usage.maximum) * 100 : undefined),
197
+ };
198
+ }
199
+ adapter() {
200
+ return typeof this.input.adapter === "function" ? this.input.adapter() : this.input.adapter;
201
+ }
202
+ }
203
+ /** Switches the shared live status to a role for the lifetime of its adapter. */
204
+ export class RoleStatusAdapter {
205
+ adapter;
206
+ onActive;
207
+ onClose;
208
+ agent;
209
+ constructor(adapter, onActive, onClose) {
210
+ this.adapter = adapter;
211
+ this.onActive = onActive;
212
+ this.onClose = onClose;
213
+ this.agent = adapter.agent;
214
+ }
215
+ async sendTurn(text) { this.onActive(this.adapter); return this.adapter.sendTurn(text); }
216
+ sessionId() { return this.adapter.sessionId(); }
217
+ sessionRef() { return this.adapter.sessionRef?.(); }
218
+ adoptSessionRef(ref) { this.adapter.adoptSessionRef?.(ref); }
219
+ validateSession() { return this.adapter.validateSession?.() ?? Promise.resolve({ version: 1, status: "unknown", checkedAt: new Date().toISOString(), reason: "legacy-unscoped" }); }
220
+ compact() { this.onActive(this.adapter); return this.adapter.compact?.() ?? Promise.resolve({ ok: false, error: "native compaction unavailable" }); }
221
+ prepareContextManagement(policy) { this.onActive(this.adapter); if (!this.adapter.prepareContextManagement)
222
+ return Promise.reject(new Error("native context management unavailable")); return this.adapter.prepareContextManagement(policy); }
223
+ updateContextManagement(policy) { if (!this.adapter.updateContextManagement)
224
+ return Promise.reject(new Error("native context reconfiguration unavailable")); return this.adapter.updateContextManagement(policy); }
225
+ interruptTurnAtCompactionBoundary(providerEventId) { return this.adapter.interruptTurnAtCompactionBoundary?.(providerEventId) ?? Promise.resolve({ ok: false, error: "compaction-boundary interruption unavailable", providerEventId }); }
226
+ installManagedTurnDispatcher(dispatcher) { this.adapter.installManagedTurnDispatcher?.(dispatcher); }
227
+ contextUsage() { return this.adapter.contextUsage?.() ?? Promise.resolve(undefined); }
228
+ sessionUsage() { return this.adapter.sessionUsage?.() ?? Promise.resolve(undefined); }
229
+ switchSettings(settings) { return this.adapter.switchSettings?.(settings) ?? Promise.resolve({ ok: false, error: "settings switch unavailable" }); }
230
+ events() { return this.adapter.events(); }
231
+ async close() { try {
232
+ await this.adapter.close();
233
+ }
234
+ finally {
235
+ this.onClose();
236
+ } }
237
+ }
238
+ function unavailableUsage(input, adapter, now) {
239
+ const ref = adapter.sessionRef?.();
240
+ return {
241
+ version: 1,
242
+ runId: input.runId,
243
+ role: input.role,
244
+ provider: input.provider,
245
+ providerSessionId: adapter.sessionId(),
246
+ ...(ref ? { sessionRef: ref, sessionKey: providerSessionKey(ref) } : {}),
247
+ observedAt: now.toISOString(),
248
+ source: "unavailable",
249
+ };
250
+ }
251
+ function numeric(value) {
252
+ const resolved = typeof value === "function" ? value() : value;
253
+ return Number.isSafeInteger(resolved) && (resolved ?? -1) >= 0 ? resolved : 0;
254
+ }
255
+ function formatContext(sample) {
256
+ if (sample.freshness === "measuring")
257
+ return "context measuring…";
258
+ if (sample.freshness === "unavailable")
259
+ return "context unavailable";
260
+ const occupancy = sample.percentage === undefined
261
+ ? `${sample.used ?? "unknown"}/${sample.maximum ?? "unknown"}`
262
+ : `${sample.percentage.toFixed(1)}% (${sample.used ?? "unknown"}/${sample.maximum ?? "unknown"})`;
263
+ return `context ${occupancy}${sample.freshness === "stale" ? " stale" : ""}`;
264
+ }
265
+ function formatLifecycle(state) {
266
+ if (state === "initializing")
267
+ return "measuring";
268
+ if (state === "threshold_pending")
269
+ return "pending";
270
+ if (state === "native_compacting" || state === "host_compacting")
271
+ return "compacting";
272
+ if (state === "compacted_unverified")
273
+ return "verifying";
274
+ if (state === "handoff_required" || state === "resuming")
275
+ return "handing off";
276
+ return state;
277
+ }
278
+ function formatSessionUsage(sample) {
279
+ if (!sample || sample.source === "unavailable")
280
+ return "session usage unavailable";
281
+ if (sample.authoritativeCostUsd !== undefined)
282
+ return `session cost $${sample.authoritativeCostUsd.toFixed(4)} (provider)`;
283
+ if (sample.cumulativeTotalTokens !== undefined)
284
+ return `session tokens ${sample.cumulativeTotalTokens}`;
285
+ const total = (sample.cumulativeInputTokens ?? 0) + (sample.cumulativeOutputTokens ?? 0);
286
+ return total > 0 ? `session tokens ${total}` : "session usage unavailable";
31
287
  }
@@ -203,6 +203,7 @@ export function applyApprovedTicketPlan(projectDir, proposal, opts) {
203
203
  try {
204
204
  const now = new Date().toISOString();
205
205
  db.transaction(() => {
206
+ db.ensureSyntheticLegacyGroup(existing, opts.now ?? new Date());
206
207
  const existingNext = [...db.getAllStates().values()].filter((state) => state.status === "next").map((state) => state.ticket_id);
207
208
  if (proposal.next.replace_existing)
208
209
  for (const id of existingNext)
@@ -227,6 +228,10 @@ export function applyApprovedTicketPlan(projectDir, proposal, opts) {
227
228
  throw new Error(`future work item ${disposition.id} does not exist`);
228
229
  db.updateFutureWorkDisposition(disposition.id, disposition.disposition);
229
230
  }
231
+ if (proposal.additions.length) {
232
+ const operationDigest = createHash("sha256").update(JSON.stringify(proposal.additions)).digest("hex");
233
+ db.createTicketGroup({ origin: "ticket-plan", operationId: `ticket-plan:${operationDigest}`, createdAt: now, members: proposal.additions.map((ticket) => ({ ticketId: ticket.id, definition: ticket, validatedAt: now })) });
234
+ }
230
235
  });
231
236
  const states = db.getAllStates();
232
237
  renderAndWrite({ config, projectDir: root, ticketDefs: staged, states, db });
@@ -234,6 +239,9 @@ export function applyApprovedTicketPlan(projectDir, proposal, opts) {
234
239
  const errors = validation.filter((issue) => issue.severity === "error");
235
240
  if (errors.length)
236
241
  throw new Error(`post-apply tracker validation failed:\n${errors.map((issue) => `- ${issue.message}`).join("\n")}`);
242
+ for (const ticket of staged)
243
+ if (db.getTicketGroupForTicket(ticket.id))
244
+ db.updateTicketDefinitionSnapshot(ticket.id, ticket, now);
237
245
  }
238
246
  finally {
239
247
  db.close();
@@ -2,9 +2,9 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { closeSync, copyFileSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { stringify } from "yaml";
5
- import { WorkflowDb } from "./workflowDb.js";
5
+ import { WorkflowDb, WORKFLOW_DB_FILE } from "./workflowDb.js";
6
6
  import { cmdRender, cmdValidate } from "./tickets/commands.js";
7
- import { validateTicketDefs } from "./tickets/ticketLoader.js";
7
+ import { loadTickets, validateTicketDefs } from "./tickets/ticketLoader.js";
8
8
  import { normalizeDeliveryConfig, validateDeliveryConfig } from "./tickets/delivery.js";
9
9
  import { loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
10
10
  import { StateDb } from "./tickets/stateDb.js";
@@ -106,6 +106,12 @@ export function applyTicketPopulation(projectDir, materialized, runId, now = new
106
106
  const root = resolve(projectDir);
107
107
  const config = loadTicketsConfig(root);
108
108
  const paths = resolveTicketPaths(config, root);
109
+ // Finish any older two-phase publication before resolving a new batch. This
110
+ // makes direct API use as safe as the CLI pre-action hook.
111
+ recoverTicketPublications(root);
112
+ const previousTickets = loadTickets(paths.tickets);
113
+ const previousIds = new Set(previousTickets.map((ticket) => ticket.id));
114
+ const createdTickets = materialized.tickets.filter((ticket) => !previousIds.has(ticket.id));
109
115
  const workflow = new WorkflowDb(root);
110
116
  let actualRunId = runId;
111
117
  let lease;
@@ -113,12 +119,17 @@ export function applyTicketPopulation(projectDir, materialized, runId, now = new
113
119
  if (!actualRunId)
114
120
  actualRunId = workflow.createRun({ kind: "ticket-populate", originalWork: { tickets: materialized.tickets.map((ticket) => ticket.id) }, state: {} }, now).runId;
115
121
  lease = workflow.acquireLease(actualRunId, undefined, now);
122
+ const operationId = `ticket-populate-publication:${actualRunId}`;
116
123
  const deliveryPath = join(root, ".tickets", "delivery.yaml");
117
124
  const stage = join(root, ".rafi", "staging", randomUUID());
118
125
  mkdirSync(stage, { recursive: true });
119
126
  const stagedTickets = join(stage, "tickets.yaml");
120
127
  const stagedDelivery = join(stage, "delivery.yaml");
121
- const tx = workflow.beginPublication(actualRunId, { files: [{ staged: stagedTickets, target: paths.tickets }, { staged: stagedDelivery, target: deliveryPath }], stage }, {
128
+ const tx = workflow.beginPublication(actualRunId, {
129
+ operation: "ticket-populate", operationId,
130
+ files: [{ staged: stagedTickets, target: paths.tickets }, { staged: stagedDelivery, target: deliveryPath }],
131
+ stage, managedTicketIds: materialized.tickets.map((ticket) => ticket.id),
132
+ }, {
122
133
  [paths.tickets]: fileDigest(paths.tickets), [deliveryPath]: fileDigest(deliveryPath),
123
134
  }, now);
124
135
  fsyncWrite(stagedTickets, stringify({ tickets: materialized.tickets }, { lineWidth: 120 }));
@@ -128,6 +139,7 @@ export function applyTicketPopulation(projectDir, materialized, runId, now = new
128
139
  try {
129
140
  db.transaction(() => {
130
141
  const at = now.toISOString();
142
+ db.ensureSyntheticLegacyGroup(previousTickets, now);
131
143
  for (const ticket of materialized.tickets)
132
144
  if (!db.getState(ticket.id))
133
145
  db.upsertState(ticket.id, { status: "planned", updated_by: "rafi tickets populate" }, at);
@@ -138,6 +150,16 @@ export function applyTicketPopulation(projectDir, materialized, runId, now = new
138
150
  db.upsertState(id, { status: "obsolete", completed_at: at, updated_by: "rafi tickets populate" }, at);
139
151
  db.insertEvent({ timestamp: at, actor: "rafi tickets populate", ticket_id: id, event_type: "obsolete", old_status: prior?.status ?? "planned", new_status: "obsolete", summary: "Removed by approved plan revision", validation: null, evidence: null, payload_json: JSON.stringify({ runId: actualRunId }) });
140
152
  }
153
+ if (createdTickets.length)
154
+ db.createTicketGroup({ origin: "ticket-populate", operationId: `ticket-populate:${actualRunId}`, createdAt: at, members: createdTickets.map((ticket) => ({ ticketId: ticket.id, definition: ticket, validatedAt: at })) });
155
+ db.recordOperationReceipt({
156
+ operation_id: operationId,
157
+ operation_type: "ticket-populate-publication",
158
+ ticket_id: null,
159
+ run_id: actualRunId,
160
+ completed_at: at,
161
+ payload_json: JSON.stringify({ transactionId: tx.transactionId, tickets: materialized.tickets.map((ticket) => ticket.id) }),
162
+ });
141
163
  });
142
164
  }
143
165
  finally {
@@ -151,6 +173,15 @@ export function applyTicketPopulation(projectDir, materialized, runId, now = new
151
173
  const validation = cmdValidate(root);
152
174
  if (!validation.clean)
153
175
  throw new Error(`published ticket population failed validation: ${validation.issues.map((issue) => issue.message).join("; ")}`);
176
+ const snapshotDb = new StateDb(paths.stateDb);
177
+ try {
178
+ for (const ticket of materialized.tickets)
179
+ if (snapshotDb.getTicketGroupForTicket(ticket.id))
180
+ snapshotDb.updateTicketDefinitionSnapshot(ticket.id, ticket, now.toISOString());
181
+ }
182
+ finally {
183
+ snapshotDb.close();
184
+ }
154
185
  workflow.updatePublication(tx.transactionId, "committed", now);
155
186
  workflow.transition(actualRunId, { status: "completed", checkpoint: "population-committed", remainingWork: {}, state: { transactionId: tx.transactionId } }, now);
156
187
  rmSync(stage, { recursive: true, force: true });
@@ -164,25 +195,70 @@ export function applyTicketPopulation(projectDir, materialized, runId, now = new
164
195
  }
165
196
  /** Startup recovery deterministically finishes publications after tracker commit. */
166
197
  export function recoverTicketPublications(projectDir) {
198
+ if (!existsSync(join(resolve(projectDir), WORKFLOW_DB_FILE)))
199
+ return [];
167
200
  const workflow = new WorkflowDb(projectDir);
168
201
  const recovered = [];
169
202
  try {
170
203
  for (const tx of workflow.incompletePublications()) {
171
204
  const intent = tx.intent;
172
- if (["tracker_committed", "published"].includes(tx.status)) {
173
- if (tx.status === "tracker_committed")
174
- for (const file of intent.files)
175
- if (existsSync(file.staged))
176
- publishStaged(file.staged, file.target);
177
- workflow.updatePublication(tx.transactionId, "committed");
178
- recovered.push(tx.transactionId);
179
- rmSync(intent.stage, { recursive: true, force: true });
205
+ const run = workflow.getRun(tx.runId);
206
+ const isLegacyPopulation = !intent.operation && Array.isArray(intent.files) && run?.kind === "ticket-populate";
207
+ if (intent.operation !== "ticket-populate" && !isLegacyPopulation)
208
+ continue;
209
+ if (!intent.files || !intent.stage)
210
+ throw new Error(`ticket population ${tx.transactionId} has an invalid durable publication intent`);
211
+ const root = resolve(projectDir);
212
+ const config = loadTicketsConfig(root);
213
+ const paths = resolveTicketPaths(config, root);
214
+ let trackerCommitted = ["tracker_committed", "published"].includes(tx.status);
215
+ if (!trackerCommitted && intent.operationId) {
216
+ const receiptDb = new StateDb(paths.stateDb);
217
+ try {
218
+ trackerCommitted = Boolean(receiptDb.getOperationReceipt(intent.operationId));
219
+ }
220
+ finally {
221
+ receiptDb.close();
222
+ }
180
223
  }
181
- else {
224
+ if (!trackerCommitted) {
182
225
  rmSync(intent.stage, { recursive: true, force: true });
183
226
  workflow.updatePublication(tx.transactionId, "rolled_back");
227
+ if (run && run.status !== "failed")
228
+ workflow.transition(run.runId, { status: "failed", checkpoint: "population-rolled-back-before-tracker-commit", state: { ...run.state, recoveredPublication: tx.transactionId } });
184
229
  recovered.push(tx.transactionId);
230
+ continue;
231
+ }
232
+ if (tx.status !== "published") {
233
+ for (const file of intent.files) {
234
+ if (!existsSync(file.staged))
235
+ throw new Error(`ticket population ${tx.transactionId} cannot recover because staged file is missing: ${file.staged}`);
236
+ publishStaged(file.staged, file.target);
237
+ }
238
+ workflow.updatePublication(tx.transactionId, "published");
239
+ }
240
+ cmdRender(root);
241
+ const validation = cmdValidate(root);
242
+ if (!validation.clean)
243
+ throw new Error(`recovered ticket population failed validation: ${validation.issues.map((issue) => issue.message).join("; ")}`);
244
+ const definitions = loadTickets(paths.tickets);
245
+ const managedIds = new Set(intent.managedTicketIds ?? definitions.map((ticket) => ticket.id));
246
+ const snapshotDb = new StateDb(paths.stateDb);
247
+ try {
248
+ snapshotDb.transaction(() => {
249
+ for (const ticket of definitions)
250
+ if (managedIds.has(ticket.id) && snapshotDb.getTicketGroupForTicket(ticket.id))
251
+ snapshotDb.updateTicketDefinitionSnapshot(ticket.id, ticket);
252
+ });
253
+ }
254
+ finally {
255
+ snapshotDb.close();
185
256
  }
257
+ workflow.updatePublication(tx.transactionId, "committed");
258
+ if (run && run.status !== "completed")
259
+ workflow.transition(run.runId, { status: "completed", checkpoint: "population-recovered", remainingWork: {}, state: { ...run.state, recoveredPublication: tx.transactionId } });
260
+ recovered.push(tx.transactionId);
261
+ rmSync(intent.stage, { recursive: true, force: true });
186
262
  }
187
263
  return recovered;
188
264
  }
@@ -385,19 +385,27 @@ export function cmdAcceptFutureWork(projectDir, futureWorkId, opts) {
385
385
  rollback: null,
386
386
  notes: fw.rationale ?? null,
387
387
  };
388
- ctx.db.transaction(() => {
389
- ctx.db.updateFutureWorkDisposition(futureWorkId, "accepted");
390
- logEvent(ctx.db, {
391
- timestamp: now,
392
- actor: opts.actor ?? null,
393
- ticketId: opts.ticketId,
394
- eventType: "accept-future-work",
395
- oldStatus: null,
396
- newStatus: "planned",
397
- summary: `Accepted future work ${futureWorkId} as ticket ${opts.ticketId}`,
398
- });
399
- });
388
+ ctx.db.ensureSyntheticLegacyGroup(ctx.tickets);
400
389
  saveTickets(ctx.paths.tickets, [...ctx.tickets, newTicket]);
390
+ try {
391
+ ctx.db.transaction(() => {
392
+ ctx.db.updateFutureWorkDisposition(futureWorkId, "accepted");
393
+ logEvent(ctx.db, {
394
+ timestamp: now,
395
+ actor: opts.actor ?? null,
396
+ ticketId: opts.ticketId,
397
+ eventType: "accept-future-work",
398
+ oldStatus: null,
399
+ newStatus: "planned",
400
+ summary: `Accepted future work ${futureWorkId} as ticket ${opts.ticketId}`,
401
+ });
402
+ ctx.db.createTicketGroup({ origin: "future-work", operationId: `future-work:${futureWorkId}`, members: [{ ticketId: newTicket.id, definition: newTicket, validatedAt: now }] });
403
+ });
404
+ }
405
+ catch (error) {
406
+ saveTickets(ctx.paths.tickets, ctx.tickets);
407
+ throw error;
408
+ }
401
409
  // Re-open context to pick up new ticket list
402
410
  const newCtx = openContext(projectDir);
403
411
  try {