ai-foreman 1.7.16 → 1.7.18
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/activity.js +14 -1
- package/dist/adapters/claude.d.ts +5 -0
- package/dist/adapters/claude.js +81 -15
- package/dist/adapters/codex.d.ts +2 -0
- package/dist/adapters/codex.js +28 -11
- package/dist/adapters/types.d.ts +7 -0
- package/dist/branch/git.js +3 -1
- package/dist/branch/github.js +2 -2
- package/dist/buildRuns.js +4 -23
- package/dist/cli/events.js +21 -1
- package/dist/cli/start.js +2 -2
- package/dist/cli/tickets.d.ts +13 -1
- package/dist/cli/tickets.js +140 -27
- package/dist/diagnostics.js +2 -7
- package/dist/observability.js +1 -6
- package/dist/processIdentity.d.ts +8 -0
- package/dist/processIdentity.js +40 -0
- package/dist/projectDiagnostics.js +3 -16
- package/dist/providerQuestions.js +5 -1
- package/dist/qaProtocolV2.d.ts +2 -1
- package/dist/qaReview.js +3 -2
- package/dist/qaRuntime.js +6 -2
- package/dist/workflowDb.js +2 -13
- package/package.json +2 -2
package/dist/activity.js
CHANGED
|
@@ -188,9 +188,16 @@ export class ActivityReporter {
|
|
|
188
188
|
this.lastSemanticKey = semanticKey;
|
|
189
189
|
return;
|
|
190
190
|
}
|
|
191
|
+
const semanticKey = semanticStatusKey(body);
|
|
192
|
+
// Append-only outputs cannot replace an earlier line. Emit the first
|
|
193
|
+
// state and meaningful state changes, but do not turn a changing counter
|
|
194
|
+
// or elapsed time into an endless log stream.
|
|
195
|
+
if (semanticKey === this.lastSemanticKey)
|
|
196
|
+
return;
|
|
191
197
|
if (force || now - this.lastHeartbeatAt >= this.heartbeatMs) {
|
|
192
198
|
this.lastHeartbeatAt = now;
|
|
193
199
|
this.output.write(`[${new Date(now).toISOString()}] rafi working: ${body} (${formatDuration(now - this.commandStartedAt)})\n`);
|
|
200
|
+
this.lastSemanticKey = semanticKey;
|
|
194
201
|
}
|
|
195
202
|
}
|
|
196
203
|
clearLine() {
|
|
@@ -277,7 +284,13 @@ function clean(value, maximum) {
|
|
|
277
284
|
return value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim().slice(0, maximum);
|
|
278
285
|
}
|
|
279
286
|
function semanticStatusKey(body) {
|
|
280
|
-
return body
|
|
287
|
+
return body
|
|
288
|
+
// Counters, percentages, ticket suffixes, and model version numbers.
|
|
289
|
+
.replace(/\p{Number}+(?:[.,]\p{Number}+)*/gu, "#")
|
|
290
|
+
// Commit IDs, request IDs, and generated hexadecimal identifiers.
|
|
291
|
+
.replace(/\b[\da-f]{7,}\b/giu, "#")
|
|
292
|
+
.replace(/\s+/g, " ")
|
|
293
|
+
.trim();
|
|
281
294
|
}
|
|
282
295
|
function resolveTtyMode(requested) {
|
|
283
296
|
const configured = requested ?? configuredTtyMode(process.env.RAFI_ACTIVITY_RENDER_MODE);
|
|
@@ -102,5 +102,10 @@ export declare class ClaudeAdapter implements BuilderAdapter {
|
|
|
102
102
|
private failSessionIdentity;
|
|
103
103
|
private observeSession;
|
|
104
104
|
private settlePending;
|
|
105
|
+
private touchPendingTurn;
|
|
106
|
+
private beginProviderQuestionWait;
|
|
107
|
+
private endProviderQuestionWait;
|
|
108
|
+
private armPendingTurn;
|
|
109
|
+
private providerIdleTimeoutMs;
|
|
105
110
|
private streamFailureResult;
|
|
106
111
|
}
|
package/dist/adapters/claude.js
CHANGED
|
@@ -17,6 +17,7 @@ import { normalizeRuntimeErrorText } from "../runtimeAuth.js";
|
|
|
17
17
|
import { classifyClaudeSdkFailure, resolveExecutablePath, sanitizeDiagnostics, } from "../runtimeReadiness.js";
|
|
18
18
|
import { createProviderSessionRef, validateProviderSessionScope, canonicalSessionPath } from "../sessionIdentity.js";
|
|
19
19
|
import { SessionUnavailableError, sessionUnavailableResult } from "./sessionFailure.js";
|
|
20
|
+
const DEFAULT_PROVIDER_IDLE_TIMEOUT_MS = 30 * 60_000;
|
|
20
21
|
/**
|
|
21
22
|
* Pure function: build the `options` object passed to `query()`.
|
|
22
23
|
* Extracted so tests can assert on the shape without making a live SDK call.
|
|
@@ -33,7 +34,10 @@ export function buildClaudeQueryOptions(opts) {
|
|
|
33
34
|
permissionMode: qaReadOnly ? "default" : "acceptEdits",
|
|
34
35
|
effort: opts.effort,
|
|
35
36
|
extraArgs: opts.fast ? { fast: null } : undefined,
|
|
36
|
-
|
|
37
|
+
// Read-only roles still need the user's Claude authentication settings (for
|
|
38
|
+
// example, an enterprise apiKeyHelper). Do not load repository-controlled
|
|
39
|
+
// settings in those roles.
|
|
40
|
+
settingSources: qaReadOnly ? ["user"] : ["user", "project", "local"],
|
|
37
41
|
...(qaReadOnly ? { disallowedTools: ["Write", "Edit", "NotebookEdit"] } : {}),
|
|
38
42
|
...(qaReadOnly ? { sandbox: {
|
|
39
43
|
enabled: true,
|
|
@@ -56,7 +60,7 @@ export function buildClaudeQueryOptions(opts) {
|
|
|
56
60
|
return base;
|
|
57
61
|
}
|
|
58
62
|
function qaEnvironment(source, cwd) {
|
|
59
|
-
const allowed = ["PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "LANG", "LC_ALL", "TERM", "NO_COLOR", "FORCE_COLOR", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS"];
|
|
63
|
+
const allowed = ["PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "LANG", "LC_ALL", "TERM", "NO_COLOR", "FORCE_COLOR", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", "CLAUDE_CODE_API_KEY_HELPER_TTL_MS", "HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS"];
|
|
60
64
|
return { ...Object.fromEntries(allowed.flatMap((key) => source[key] === undefined ? [] : [[key, source[key]]])), TMPDIR: join(dirname(cwd), "scratch") };
|
|
61
65
|
}
|
|
62
66
|
export async function probeClaudeSession(ref, input) {
|
|
@@ -255,18 +259,30 @@ export class ClaudeAdapter {
|
|
|
255
259
|
}] }] },
|
|
256
260
|
stderr: (data) => this.captureStderr(data),
|
|
257
261
|
canUseTool: async (toolName, input, requestOptions = {}) => {
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
262
|
+
const isProviderQuestion = toolName === "AskUserQuestion";
|
|
263
|
+
if (isProviderQuestion)
|
|
264
|
+
this.beginProviderQuestionWait();
|
|
265
|
+
try {
|
|
266
|
+
const decision = await opts.permission({
|
|
267
|
+
toolName,
|
|
268
|
+
input,
|
|
269
|
+
signal: requestOptions.signal,
|
|
270
|
+
title: requestOptions.title,
|
|
271
|
+
displayName: requestOptions.displayName,
|
|
272
|
+
description: requestOptions.description,
|
|
273
|
+
decisionReason: requestOptions.decisionReason,
|
|
274
|
+
blockedPath: requestOptions.blockedPath,
|
|
275
|
+
toolUseID: requestOptions.toolUseID,
|
|
276
|
+
});
|
|
277
|
+
if (isProviderQuestion)
|
|
278
|
+
this.endProviderQuestionWait(decision.behavior === "allow");
|
|
279
|
+
return permissionDecisionToClaudeResult(decision, requestOptions.toolUseID);
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
if (isProviderQuestion)
|
|
283
|
+
this.endProviderQuestionWait(false);
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
270
286
|
},
|
|
271
287
|
},
|
|
272
288
|
});
|
|
@@ -310,6 +326,7 @@ export class ClaudeAdapter {
|
|
|
310
326
|
}
|
|
311
327
|
}
|
|
312
328
|
handle(msg) {
|
|
329
|
+
this.touchPendingTurn();
|
|
313
330
|
if ("session_id" in msg && typeof msg.session_id === "string") {
|
|
314
331
|
this.observeSession(msg.session_id, "cwd" in msg && typeof msg.cwd === "string" ? msg.cwd : undefined);
|
|
315
332
|
}
|
|
@@ -510,7 +527,8 @@ export class ClaudeAdapter {
|
|
|
510
527
|
this.turnSignals = [];
|
|
511
528
|
this.structuredError = undefined;
|
|
512
529
|
this.apiErrorStatus = undefined;
|
|
513
|
-
this.pending = { resolve, reject, instruction: text, turnId: this.activeProviderTurnId ?? randomUUID() };
|
|
530
|
+
const pending = this.pending = { resolve, reject, instruction: text, turnId: this.activeProviderTurnId ?? randomUUID() };
|
|
531
|
+
this.armPendingTurn(pending);
|
|
514
532
|
this.inbox.push({
|
|
515
533
|
type: "user",
|
|
516
534
|
message: { role: "user", content: text },
|
|
@@ -770,6 +788,8 @@ export class ClaudeAdapter {
|
|
|
770
788
|
if (!pending)
|
|
771
789
|
return;
|
|
772
790
|
this.pending = undefined;
|
|
791
|
+
if (pending.idleTimer)
|
|
792
|
+
clearTimeout(pending.idleTimer);
|
|
773
793
|
if (!result.turnId) {
|
|
774
794
|
result.turnId = pending.turnId;
|
|
775
795
|
result.hostInstruction = pending.instruction;
|
|
@@ -782,6 +802,52 @@ export class ClaudeAdapter {
|
|
|
782
802
|
this.eventQueue.push({ kind: "turn-complete", result, turnId: result.turnId });
|
|
783
803
|
pending.resolve(result);
|
|
784
804
|
}
|
|
805
|
+
touchPendingTurn() {
|
|
806
|
+
if (this.pending)
|
|
807
|
+
this.armPendingTurn(this.pending);
|
|
808
|
+
}
|
|
809
|
+
beginProviderQuestionWait() {
|
|
810
|
+
const pending = this.pending;
|
|
811
|
+
if (!pending)
|
|
812
|
+
return;
|
|
813
|
+
pending.providerQuestionWaits = (pending.providerQuestionWaits ?? 0) + 1;
|
|
814
|
+
if (pending.idleTimer)
|
|
815
|
+
clearTimeout(pending.idleTimer);
|
|
816
|
+
pending.idleTimer = undefined;
|
|
817
|
+
}
|
|
818
|
+
endProviderQuestionWait(answered) {
|
|
819
|
+
const pending = this.pending;
|
|
820
|
+
if (!pending)
|
|
821
|
+
return;
|
|
822
|
+
pending.providerQuestionWaits = Math.max(0, (pending.providerQuestionWaits ?? 1) - 1);
|
|
823
|
+
pending.providerQuestionAnswered ||= answered;
|
|
824
|
+
if (pending.providerQuestionWaits === 0)
|
|
825
|
+
this.armPendingTurn(pending);
|
|
826
|
+
}
|
|
827
|
+
armPendingTurn(pending) {
|
|
828
|
+
if (pending.idleTimer)
|
|
829
|
+
clearTimeout(pending.idleTimer);
|
|
830
|
+
if ((pending.providerQuestionWaits ?? 0) > 0)
|
|
831
|
+
return;
|
|
832
|
+
const timeoutMs = this.providerIdleTimeoutMs();
|
|
833
|
+
pending.idleTimer = setTimeout(() => {
|
|
834
|
+
if (this.pending !== pending || this.closed)
|
|
835
|
+
return;
|
|
836
|
+
const context = pending.providerQuestionAnswered ? " after your answer was sent" : "";
|
|
837
|
+
const message = `Claude provider was silent for ${Math.round(timeoutMs / 60_000)} minutes${context}; the turn may have been dispatched and will not be retried automatically`;
|
|
838
|
+
const result = this.streamFailureResult(message, false);
|
|
839
|
+
this.eventQueue.push({ kind: "error", message });
|
|
840
|
+
this.settlePending(result);
|
|
841
|
+
// Do not leave a hidden stream running after the host has declared its
|
|
842
|
+
// result uncertain. close() is intentionally not awaited here.
|
|
843
|
+
void this.close();
|
|
844
|
+
}, timeoutMs);
|
|
845
|
+
pending.idleTimer.unref();
|
|
846
|
+
}
|
|
847
|
+
providerIdleTimeoutMs() {
|
|
848
|
+
const configured = this.opts.providerIdleTimeoutMs;
|
|
849
|
+
return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_PROVIDER_IDLE_TIMEOUT_MS;
|
|
850
|
+
}
|
|
785
851
|
streamFailureResult(message, sessionUnavailable) {
|
|
786
852
|
if (sessionUnavailable) {
|
|
787
853
|
const availability = {
|
package/dist/adapters/codex.d.ts
CHANGED
|
@@ -71,6 +71,8 @@ export declare class CodexAdapter implements BuilderAdapter {
|
|
|
71
71
|
private handle;
|
|
72
72
|
private observationContext;
|
|
73
73
|
private waitFor;
|
|
74
|
+
private touchWaiters;
|
|
75
|
+
private providerIdleTimeoutMs;
|
|
74
76
|
private disconnect;
|
|
75
77
|
private restartForAutoCompaction;
|
|
76
78
|
private buildSkillsAppendix;
|
package/dist/adapters/codex.js
CHANGED
|
@@ -24,6 +24,8 @@ export function parseCodexLine(raw) {
|
|
|
24
24
|
return { events: [{ kind: "error", message: String(raw.error?.message ?? JSON.stringify(raw)) }] };
|
|
25
25
|
return { events: [] };
|
|
26
26
|
}
|
|
27
|
+
/** A provider can legitimately take a long time, but not without any event. */
|
|
28
|
+
const DEFAULT_PROVIDER_IDLE_TIMEOUT_MS = 30 * 60_000;
|
|
27
29
|
/** Persistent JSON-RPC controller for one live Codex thread. */
|
|
28
30
|
export class CodexAdapter {
|
|
29
31
|
opts;
|
|
@@ -129,7 +131,7 @@ export class CodexAdapter {
|
|
|
129
131
|
await this.ensureThread();
|
|
130
132
|
this.activeText = [];
|
|
131
133
|
this.lastTurnTokens = undefined;
|
|
132
|
-
const completion = this.waitFor("turn/completed", (params) => params.threadId === this._sessionId);
|
|
134
|
+
const completion = this.waitFor("turn/completed", (params) => params.threadId === this._sessionId, this.providerIdleTimeoutMs(), true);
|
|
133
135
|
turnStartDispatched = true;
|
|
134
136
|
await this.request("turn/start", {
|
|
135
137
|
threadId: this._sessionId,
|
|
@@ -478,6 +480,7 @@ export class CodexAdapter {
|
|
|
478
480
|
this.process.stdin.write(`${JSON.stringify(message)}\n`);
|
|
479
481
|
}
|
|
480
482
|
handle(message) {
|
|
483
|
+
this.touchWaiters();
|
|
481
484
|
if (message.id !== undefined) {
|
|
482
485
|
const pending = this.pending.get(message.id);
|
|
483
486
|
if (pending) {
|
|
@@ -614,28 +617,42 @@ export class CodexAdapter {
|
|
|
614
617
|
ticketId: this.opts.ticketId ?? inherited.ticketId, deliveryUnitId: this.opts.deliveryUnitId ?? inherited.deliveryUnitId,
|
|
615
618
|
parentSpanId: this.activeProviderTurnSpanId ?? inherited.parentSpanId, providerTurnId: this.activeProviderTurnId ?? inherited.providerTurnId };
|
|
616
619
|
}
|
|
617
|
-
waitFor(method, predicate, timeoutMs) {
|
|
620
|
+
waitFor(method, predicate, timeoutMs, resetOnProviderActivity = false) {
|
|
618
621
|
return new Promise((resolve, reject) => {
|
|
619
622
|
let timeout;
|
|
623
|
+
const clear = () => { if (timeout)
|
|
624
|
+
clearTimeout(timeout); timeout = undefined; };
|
|
620
625
|
const waiter = {
|
|
621
626
|
predicate,
|
|
622
|
-
resolve: (params) => {
|
|
623
|
-
|
|
624
|
-
reject: (error) => { if (timeout)
|
|
625
|
-
clearTimeout(timeout); reject(error); },
|
|
627
|
+
resolve: (params) => { clear(); resolve(params); },
|
|
628
|
+
reject: (error) => { clear(); reject(error); },
|
|
626
629
|
};
|
|
627
630
|
const waiters = this.notificationWaiters.get(method) ?? [];
|
|
628
631
|
waiters.push(waiter);
|
|
629
632
|
this.notificationWaiters.set(method, waiters);
|
|
630
633
|
if (timeoutMs !== undefined) {
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
634
|
+
const arm = () => {
|
|
635
|
+
timeout = setTimeout(() => {
|
|
636
|
+
const active = this.notificationWaiters.get(method) ?? [];
|
|
637
|
+
this.notificationWaiters.set(method, active.filter((candidate) => candidate !== waiter));
|
|
638
|
+
reject(new Error(`Codex provider was silent for ${Math.round(timeoutMs / 60_000)} minutes while waiting for ${method}; the turn may have been dispatched and will not be retried automatically`));
|
|
639
|
+
}, timeoutMs);
|
|
640
|
+
};
|
|
641
|
+
if (resetOnProviderActivity)
|
|
642
|
+
waiter.touch = () => { clear(); arm(); };
|
|
643
|
+
arm();
|
|
636
644
|
}
|
|
637
645
|
});
|
|
638
646
|
}
|
|
647
|
+
touchWaiters() {
|
|
648
|
+
for (const waiters of this.notificationWaiters.values())
|
|
649
|
+
for (const waiter of waiters)
|
|
650
|
+
waiter.touch?.();
|
|
651
|
+
}
|
|
652
|
+
providerIdleTimeoutMs() {
|
|
653
|
+
const configured = this.opts.providerIdleTimeoutMs;
|
|
654
|
+
return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_PROVIDER_IDLE_TIMEOUT_MS;
|
|
655
|
+
}
|
|
639
656
|
disconnect(error) {
|
|
640
657
|
const child = this.process;
|
|
641
658
|
this.process = undefined;
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -246,6 +246,13 @@ export interface BuilderAdapterOptions {
|
|
|
246
246
|
allowAutoCompactionSetupTurn?: boolean;
|
|
247
247
|
/** Raw-adapter observability; wrapper adapters must not persist re-emitted events. */
|
|
248
248
|
observer?: RunObserver;
|
|
249
|
+
/**
|
|
250
|
+
* Maximum time a dispatched provider turn may remain completely silent.
|
|
251
|
+
* This is an idle timeout, not a total turn limit: every provider message
|
|
252
|
+
* resets it. A timeout is intentionally terminal and has unknown dispatch
|
|
253
|
+
* state, so callers must never replay the instruction automatically.
|
|
254
|
+
*/
|
|
255
|
+
providerIdleTimeoutMs?: number;
|
|
249
256
|
}
|
|
250
257
|
export interface BuilderAdapter {
|
|
251
258
|
readonly agent: "claude" | "codex";
|
package/dist/branch/git.js
CHANGED
|
@@ -129,7 +129,9 @@ export function mergeBranchToLocalBase(projectDir, branch, baseBranch, message,
|
|
|
129
129
|
runGit(projectDir, ["checkout", baseBranch]);
|
|
130
130
|
}
|
|
131
131
|
if (method === "squash") {
|
|
132
|
-
|
|
132
|
+
// A user's global `merge.ff=only` conflicts with `--squash`; pin the
|
|
133
|
+
// ordinary merge policy so direct delivery remains independent of it.
|
|
134
|
+
runGit(projectDir, ["-c", "merge.ff=true", "merge", "--squash", branch]);
|
|
133
135
|
runGit(projectDir, ["commit", "-m", message]);
|
|
134
136
|
}
|
|
135
137
|
else if (method === "merge") {
|
package/dist/branch/github.js
CHANGED
|
@@ -53,10 +53,10 @@ export function originLooksLikeGitHub(cwd) {
|
|
|
53
53
|
export function checkGitHubReadiness(cwd) {
|
|
54
54
|
const ghVersion = runCommand(cwd, "gh", ["--version"], 5_000);
|
|
55
55
|
if (!ghVersion.ok) {
|
|
56
|
-
return
|
|
56
|
+
return classifyCommandFailure(ghVersion, "gh_missing", "GitHub CLI is not installed or is not on PATH.", [
|
|
57
57
|
"Install GitHub CLI: https://cli.github.com/",
|
|
58
58
|
"gh --version",
|
|
59
|
-
]
|
|
59
|
+
]);
|
|
60
60
|
}
|
|
61
61
|
const remote = inspectGitHubRemote(cwd);
|
|
62
62
|
if (!remote.ok)
|
package/dist/buildRuns.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync,
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { hostname } from "node:os";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import { execFileSync } from "node:child_process";
|
|
@@ -12,6 +12,7 @@ import { captureCurrentWorkflowSessionIdentity, currentWorkflowIdentityKey } fro
|
|
|
12
12
|
import { isTicketsInitialized, loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
|
|
13
13
|
import { loadTickets } from "./tickets/ticketLoader.js";
|
|
14
14
|
import { loadProjectAutonomyConfig, resolveAutonomyPolicy } from "./recoveryPolicy.js";
|
|
15
|
+
import { isLiveProcessIdentity, processStartIdentity } from "./processIdentity.js";
|
|
15
16
|
export const BUILD_RUN_VERSION = 3;
|
|
16
17
|
export const BUILD_RUN_DIRECTORY = ".foreman/runs";
|
|
17
18
|
export const BUILD_HEARTBEAT_MS = 10_000;
|
|
@@ -280,13 +281,7 @@ function workflowLeaseLooksLive(lease, now) {
|
|
|
280
281
|
return false;
|
|
281
282
|
if (lease.host !== hostname())
|
|
282
283
|
return true;
|
|
283
|
-
|
|
284
|
-
process.kill(lease.pid, 0);
|
|
285
|
-
return lease.processStart === processStartIdentity(lease.pid);
|
|
286
|
-
}
|
|
287
|
-
catch {
|
|
288
|
-
return false;
|
|
289
|
-
}
|
|
284
|
+
return isLiveProcessIdentity(lease.pid, lease.processStart);
|
|
290
285
|
}
|
|
291
286
|
/**
|
|
292
287
|
* Current-branch runs created before ticket checkpointing did not retain their
|
|
@@ -331,13 +326,7 @@ export function isLeaseActive(run, now = new Date()) {
|
|
|
331
326
|
return false;
|
|
332
327
|
if (run.lease.hostname !== hostname())
|
|
333
328
|
return true;
|
|
334
|
-
|
|
335
|
-
process.kill(run.lease.pid, 0);
|
|
336
|
-
return run.lease.processStart === processStartIdentity(run.lease.pid);
|
|
337
|
-
}
|
|
338
|
-
catch {
|
|
339
|
-
return false;
|
|
340
|
-
}
|
|
329
|
+
return isLiveProcessIdentity(run.lease.pid, run.lease.processStart);
|
|
341
330
|
}
|
|
342
331
|
export function buildRecoveryPreview(run) {
|
|
343
332
|
const completed = Object.keys(run.receipts).sort();
|
|
@@ -477,14 +466,6 @@ export function formatBuildRecoveryProjection(projection) {
|
|
|
477
466
|
function currentLease(now) {
|
|
478
467
|
return { hostname: hostname(), pid: process.pid, processStart: processStartIdentity(process.pid), heartbeatAt: now.toISOString() };
|
|
479
468
|
}
|
|
480
|
-
function processStartIdentity(pid) {
|
|
481
|
-
try {
|
|
482
|
-
return statSync(`/proc/${pid}`).birthtimeMs.toString();
|
|
483
|
-
}
|
|
484
|
-
catch {
|
|
485
|
-
return pid === process.pid ? `${Date.now() - Math.round(process.uptime() * 1000)}` : "unknown";
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
469
|
function validateBuildRun(run) {
|
|
489
470
|
if (![1, 2, 3].includes(run.version) || !run.runId || !run.repository?.root || !run.repository.worktree || !run.createdAt || !run.updatedAt) {
|
|
490
471
|
throw new Error("invalid build run record");
|
package/dist/cli/events.js
CHANGED
|
@@ -2,6 +2,7 @@ import { currentActivity } from "../activity.js";
|
|
|
2
2
|
/** Print a compact live feed of builder activity. */
|
|
3
3
|
export async function printEvents(events) {
|
|
4
4
|
let atLineStart = true;
|
|
5
|
+
const startedTools = new Set();
|
|
5
6
|
for await (const ev of events) {
|
|
6
7
|
if (ev.kind === "text") {
|
|
7
8
|
if (ev.text) {
|
|
@@ -17,11 +18,30 @@ export async function printEvents(events) {
|
|
|
17
18
|
}
|
|
18
19
|
}
|
|
19
20
|
else if (ev.kind === "tool") {
|
|
21
|
+
const key = ev.callId ?? `${ev.name}\u0000${briefInput(ev.input)}`;
|
|
22
|
+
// A tool normally arrives as started, progress, then completed. The
|
|
23
|
+
// activity reporter owns that mutable live state; keeping only its
|
|
24
|
+
// initial permanent line avoids printing the same operation three times.
|
|
25
|
+
if (ev.lifecycle === "progress")
|
|
26
|
+
continue;
|
|
27
|
+
if (ev.lifecycle === "completed" && startedTools.delete(key) && ev.status !== "failed" && (ev.exitCode === undefined || ev.exitCode === 0))
|
|
28
|
+
continue;
|
|
29
|
+
if (ev.lifecycle === "started") {
|
|
30
|
+
startedTools.add(key);
|
|
31
|
+
// The activity display is mutable, so a started tool is shown there
|
|
32
|
+
// and then replaced by its next state instead of becoming a durable
|
|
33
|
+
// line in the feed.
|
|
34
|
+
if (currentActivity())
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
20
37
|
if (!atLineStart) {
|
|
21
38
|
process.stdout.write("\n");
|
|
22
39
|
atLineStart = true;
|
|
23
40
|
}
|
|
24
|
-
|
|
41
|
+
const outcome = ev.lifecycle === "completed"
|
|
42
|
+
? ev.status === "failed" || (ev.exitCode !== undefined && ev.exitCode !== 0) ? " failed" : " completed"
|
|
43
|
+
: "";
|
|
44
|
+
writeLine(` -> ${ev.name} ${briefInput(ev.input)}${outcome}`.trimEnd());
|
|
25
45
|
}
|
|
26
46
|
else if (ev.kind === "turn-complete") {
|
|
27
47
|
if (!atLineStart) {
|
package/dist/cli/start.js
CHANGED
|
@@ -1857,7 +1857,7 @@ export function buildStartCommand() {
|
|
|
1857
1857
|
} },
|
|
1858
1858
|
}, (line, snapshot) => {
|
|
1859
1859
|
const activity = currentActivity();
|
|
1860
|
-
if (activity
|
|
1860
|
+
if (activity)
|
|
1861
1861
|
activity.setAgentStatus(line.replace(/^\[[^\]]+\]\s*/, ""));
|
|
1862
1862
|
else
|
|
1863
1863
|
console.log(line);
|
|
@@ -2153,7 +2153,7 @@ export function buildStartCommand() {
|
|
|
2153
2153
|
} },
|
|
2154
2154
|
}, (line, snapshot) => {
|
|
2155
2155
|
const activity = currentActivity();
|
|
2156
|
-
if (activity
|
|
2156
|
+
if (activity)
|
|
2157
2157
|
activity.setAgentStatus(line.replace(/^\[[^\]]+\]\s*/, ""));
|
|
2158
2158
|
else
|
|
2159
2159
|
console.log(line);
|
package/dist/cli/tickets.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
+
import type { StructuredPlanV1 } from "rafi-spec";
|
|
2
3
|
import { Log } from "../log.js";
|
|
3
4
|
import type { EffortLevel } from "../adapters/types.js";
|
|
4
5
|
import { type RoleBuilderOptions } from "../agentRun.js";
|
|
5
6
|
import type { TicketsConfig } from "../tickets/config.js";
|
|
6
|
-
export declare function buildPopulateInstruction(
|
|
7
|
+
export declare function buildPopulateInstruction(approvedPlanPath: string, contextSources?: string[], progressDoc?: string): string;
|
|
7
8
|
export declare function resolvePopulateSources(projectDir: string, explicitSources: string[] | undefined, ticketsConfig: TicketsConfig): string[] | undefined;
|
|
8
9
|
export declare function buildPopulateAgentRunOptions(opts: {
|
|
9
10
|
projectDir: string;
|
|
@@ -26,6 +27,17 @@ interface PopulateCommandOptions {
|
|
|
26
27
|
}
|
|
27
28
|
export declare function combineRequirementSelections(selected: string[], additions: string): string[];
|
|
28
29
|
export declare function cmdPopulateCli(opts: PopulateCommandOptions): Promise<void>;
|
|
30
|
+
export interface PopulationPlanResolution {
|
|
31
|
+
/** Repository-relative path when possible, for both agent instructions and diagnostics. */
|
|
32
|
+
path: string;
|
|
33
|
+
plan: StructuredPlanV1;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Locate the plan that Rafi approved independently of the documents the user
|
|
37
|
+
* wants Ticket Maker to use as context. In particular, FEATURES.md is not a
|
|
38
|
+
* promise that FEATURES.json is a Rafi plan.
|
|
39
|
+
*/
|
|
40
|
+
export declare function resolvePopulationPlan(projectDir: string, contextSources: string[], ticketsConfig: TicketsConfig): PopulationPlanResolution;
|
|
29
41
|
export interface TicketOwnershipReceipt {
|
|
30
42
|
path: string;
|
|
31
43
|
category: "tickets";
|
package/dist/cli/tickets.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
-
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
4
|
import { parse as parseYaml } from "yaml";
|
|
5
5
|
import { select, text, confirm, isCancel, multiselect } from "@clack/prompts";
|
|
6
6
|
import { loadConfig } from "../config.js";
|
|
@@ -64,23 +64,27 @@ function validateEffort(effort) {
|
|
|
64
64
|
fail(err instanceof Error ? err.message : String(err));
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
|
-
export function buildPopulateInstruction(
|
|
68
|
-
const sources =
|
|
67
|
+
export function buildPopulateInstruction(approvedPlanPath, contextSources, progressDoc = "docs/ticket-progress.md") {
|
|
68
|
+
const sources = contextSources ?? [];
|
|
69
69
|
const sourceHintBlock = sources.length > 0
|
|
70
70
|
? `
|
|
71
|
-
|
|
71
|
+
Original context source hints:
|
|
72
72
|
${sources.map((source) => `- ${source}`).join("\n")}
|
|
73
73
|
|
|
74
74
|
Treat these as files, folders, or globs to check first. Any reasonable project-planning format is acceptable because you are responsible for interpreting it. If a hinted source does not exist or is ambiguous, continue scanning the repository before asking for help.
|
|
75
75
|
`
|
|
76
76
|
: `
|
|
77
|
-
No
|
|
77
|
+
No original context sources were provided. Use the approved plan and scan the repository only when it is needed to resolve its references.
|
|
78
78
|
`;
|
|
79
79
|
return `You are being run by Foreman to populate this repository's Foreman ticket tracker.
|
|
80
80
|
|
|
81
81
|
Goal:
|
|
82
82
|
- Convert every existing project ticket, task, backlog item, roadmap item, or implementation step into Foreman's structured ticket system.
|
|
83
83
|
- Do not implement product/code changes. Only update ticket-tracker files.
|
|
84
|
+
\nApproved structured Rafi plan (authoritative for plan identity and slice mapping):
|
|
85
|
+
- ${approvedPlanPath}
|
|
86
|
+
|
|
87
|
+
Read this approved plan before all other sources. It determines the exact plan_id, revision, and slice_ref values that the proposal must use. The context sources below provide supporting product and implementation detail; they do not replace or redefine the approved plan.
|
|
84
88
|
${sourceHintBlock}
|
|
85
89
|
|
|
86
90
|
Before proposing, read these tracker control files:
|
|
@@ -89,7 +93,7 @@ Before proposing, read these tracker control files:
|
|
|
89
93
|
- .tickets/tracker-rules.md
|
|
90
94
|
- ${progressDoc} if it exists
|
|
91
95
|
|
|
92
|
-
Then inspect the repository for
|
|
96
|
+
Then inspect the repository for the context required by the approved structured Rafi plan. You are proposal-only: do not edit any file, run tracker mutation commands, allocate final ticket IDs, or write delivery configuration. Rafi validates and performs all writes after approval.
|
|
93
97
|
|
|
94
98
|
Return ticket content using Foreman's schema:
|
|
95
99
|
- slice_ref: the exact approved plan slice reference. Do not allocate ticket IDs.
|
|
@@ -355,13 +359,13 @@ async function collectTicketSetupPatch(dir, opts, current) {
|
|
|
355
359
|
};
|
|
356
360
|
}
|
|
357
361
|
const section = await select({
|
|
358
|
-
message: "
|
|
362
|
+
message: "What would you like to configure for tickets?",
|
|
359
363
|
options: [
|
|
360
|
-
{ value: "sources", label: "
|
|
361
|
-
{ value: "populate", label: "
|
|
362
|
-
{ value: "build", label: "
|
|
363
|
-
{ value: "limits", label: "
|
|
364
|
-
{ value: "all", label: "
|
|
364
|
+
{ value: "sources", label: "Where tickets come from", hint: "Choose planning files, issue trackers, or other sources." },
|
|
365
|
+
{ value: "populate", label: "How Rafi creates tickets from those sources", hint: "Set import and enrichment behavior." },
|
|
366
|
+
{ value: "build", label: "How Rafi implements tickets", hint: "Set branches, validation, and completion behavior." },
|
|
367
|
+
{ value: "limits", label: "Ticket tracker limits", hint: "Set how many tickets Rafi can show or work on at once." },
|
|
368
|
+
{ value: "all", label: "Configure everything", hint: "Walk through every ticket setting." },
|
|
365
369
|
],
|
|
366
370
|
});
|
|
367
371
|
if (isCancel(section))
|
|
@@ -387,6 +391,15 @@ async function collectTicketSetupPatch(dir, opts, current) {
|
|
|
387
391
|
};
|
|
388
392
|
}
|
|
389
393
|
if (section === "populate" || section === "all") {
|
|
394
|
+
console.log([
|
|
395
|
+
"Recommended population settings:",
|
|
396
|
+
" • Use the saved ticket sources by default.",
|
|
397
|
+
" • Use the project's configured agent when both runtimes are available.",
|
|
398
|
+
` • Import up to ${DEFAULT_TICKET_SETUP.populate.import_cap} tickets per run.`,
|
|
399
|
+
` • Import up to ${DEFAULT_TICKET_SETUP.populate.comment_limit} comments per external ticket.`,
|
|
400
|
+
" • Generate deterministic recommendations during population.",
|
|
401
|
+
" • Recommend splitting XL tickets.",
|
|
402
|
+
].join("\n"));
|
|
390
403
|
const defaults = await confirm({ message: "Use the recommended population settings?", initialValue: true });
|
|
391
404
|
if (isCancel(defaults))
|
|
392
405
|
cancelTicketInterview();
|
|
@@ -492,7 +505,7 @@ async function collectTicketSetupPatch(dir, opts, current) {
|
|
|
492
505
|
buildPatch.cleanup = true;
|
|
493
506
|
buildPatch.auto_merge_wait = false;
|
|
494
507
|
buildPatch.auto_merge_timeout_minutes = null;
|
|
495
|
-
buildPatch.validation_checklist = await
|
|
508
|
+
buildPatch.validation_checklist = await promptValidationChecklist(current.build.validation_checklist);
|
|
496
509
|
return {
|
|
497
510
|
sources,
|
|
498
511
|
limits,
|
|
@@ -540,7 +553,7 @@ async function collectTicketSetupPatch(dir, opts, current) {
|
|
|
540
553
|
description_sections: await promptRequirementList("required PR/MR description sections", current.build.review.description_sections),
|
|
541
554
|
};
|
|
542
555
|
}
|
|
543
|
-
buildPatch.validation_checklist = await
|
|
556
|
+
buildPatch.validation_checklist = await promptValidationChecklist(current.build.validation_checklist);
|
|
544
557
|
if (completion === "auto-merge") {
|
|
545
558
|
const wait = await confirm({
|
|
546
559
|
message: "Wait for dependency PR/MRs to merge before starting dependent tickets?",
|
|
@@ -596,6 +609,44 @@ async function promptNonNegativeInteger(message, initial) {
|
|
|
596
609
|
cancelTicketInterview();
|
|
597
610
|
return Number(answer);
|
|
598
611
|
}
|
|
612
|
+
async function promptValidationChecklist(initial) {
|
|
613
|
+
console.log([
|
|
614
|
+
"Rafi gives these project-wide checks to its QA reviewer for every ticket.",
|
|
615
|
+
"Current validation checklist:",
|
|
616
|
+
...(initial.length ? initial.map((item) => ` • ${item}`) : [" • No checks configured."]),
|
|
617
|
+
].join("\n"));
|
|
618
|
+
const mode = await select({
|
|
619
|
+
message: "Which project-wide checks should Rafi's QA reviewer verify for every ticket?",
|
|
620
|
+
initialValue: "keep",
|
|
621
|
+
options: [
|
|
622
|
+
{ value: "keep", label: "Keep all of these checks for every ticket (Recommended)" },
|
|
623
|
+
{ value: "choose", label: "Choose from these checks and add others" },
|
|
624
|
+
{ value: "replace", label: "Replace these with my own checks" },
|
|
625
|
+
],
|
|
626
|
+
});
|
|
627
|
+
if (isCancel(mode))
|
|
628
|
+
cancelTicketInterview();
|
|
629
|
+
if (mode === "keep")
|
|
630
|
+
return [...initial];
|
|
631
|
+
if (mode === "replace") {
|
|
632
|
+
const replacement = await text({ message: "Project-wide checks for QA to verify on every ticket (comma-separated):", defaultValue: "" });
|
|
633
|
+
if (isCancel(replacement))
|
|
634
|
+
cancelTicketInterview();
|
|
635
|
+
return splitCommaList(String(replacement));
|
|
636
|
+
}
|
|
637
|
+
const selected = initial.length ? await multiselect({
|
|
638
|
+
message: "Select the project-wide checks QA should verify for every ticket:",
|
|
639
|
+
options: initial.map((value) => ({ value, label: value })),
|
|
640
|
+
initialValues: initial,
|
|
641
|
+
required: false,
|
|
642
|
+
}) : [];
|
|
643
|
+
if (isCancel(selected))
|
|
644
|
+
cancelTicketInterview();
|
|
645
|
+
const additions = await text({ message: "Additional project-wide QA checks (comma-separated, optional):", defaultValue: "" });
|
|
646
|
+
if (isCancel(additions))
|
|
647
|
+
cancelTicketInterview();
|
|
648
|
+
return combineRequirementSelections(selected, String(additions));
|
|
649
|
+
}
|
|
599
650
|
async function promptRequirementList(label, initial) {
|
|
600
651
|
console.log(`Current recommended ${label}: ${initial.join("; ") || "none"}`);
|
|
601
652
|
const mode = await select({
|
|
@@ -996,7 +1047,8 @@ export async function cmdPopulateCli(opts) {
|
|
|
996
1047
|
return;
|
|
997
1048
|
}
|
|
998
1049
|
}
|
|
999
|
-
const
|
|
1050
|
+
const approvedPlan = resolvePopulationPlan(dir, sourceHints, ticketsConfig);
|
|
1051
|
+
const contextSources = sourceHints.filter((source) => !isApprovedPlanArtifact(dir, source, approvedPlan.path));
|
|
1000
1052
|
if (!opts.yes) {
|
|
1001
1053
|
const action = await select({
|
|
1002
1054
|
message: "Ask the read-only Ticket Maker for a structured population proposal?",
|
|
@@ -1032,10 +1084,11 @@ export async function cmdPopulateCli(opts) {
|
|
|
1032
1084
|
console.log(`foreman tickets: project ${dir}`);
|
|
1033
1085
|
console.log(`foreman tickets: role ${TICKET_POPULATE_ROLE}`);
|
|
1034
1086
|
console.log(`foreman tickets: log ${logPath}\n`);
|
|
1035
|
-
|
|
1036
|
-
|
|
1087
|
+
console.log(`foreman tickets: approved plan ${approvedPlan.path}`);
|
|
1088
|
+
if (contextSources.length) {
|
|
1089
|
+
console.log(`foreman tickets: context sources ${contextSources.join(" ")}`);
|
|
1037
1090
|
}
|
|
1038
|
-
const turn = await foreman.runInstruction(buildPopulateInstruction(
|
|
1091
|
+
const turn = await foreman.runInstruction(buildPopulateInstruction(approvedPlan.path, contextSources, ticketsConfig.paths.progressDoc));
|
|
1039
1092
|
await builder.close();
|
|
1040
1093
|
await viewer;
|
|
1041
1094
|
log.write("ticket-populate", {
|
|
@@ -1056,7 +1109,7 @@ export async function cmdPopulateCli(opts) {
|
|
|
1056
1109
|
}
|
|
1057
1110
|
const existing = loadTickets(resolveTicketPaths(ticketsConfig, dir).tickets);
|
|
1058
1111
|
const proposal = extractTicketPopulationProposal(turn.result.text);
|
|
1059
|
-
const materialized = materializeTicketPopulation(proposal,
|
|
1112
|
+
const materialized = materializeTicketPopulation(proposal, approvedPlan.plan, existing);
|
|
1060
1113
|
let retirementsConfirmed = false;
|
|
1061
1114
|
if (!opts.yes) {
|
|
1062
1115
|
console.log(`foreman tickets: proposal maps ${materialized.sliceToTicket.size} slice(s) and retires ${materialized.retirements.join(", ") || "none"}`);
|
|
@@ -1099,16 +1152,76 @@ async function prepareDocumentSources(projectDir, sources) {
|
|
|
1099
1152
|
}
|
|
1100
1153
|
return unique(prepared);
|
|
1101
1154
|
}
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1155
|
+
/**
|
|
1156
|
+
* Locate the plan that Rafi approved independently of the documents the user
|
|
1157
|
+
* wants Ticket Maker to use as context. In particular, FEATURES.md is not a
|
|
1158
|
+
* promise that FEATURES.json is a Rafi plan.
|
|
1159
|
+
*/
|
|
1160
|
+
export function resolvePopulationPlan(projectDir, contextSources, ticketsConfig) {
|
|
1161
|
+
const explicit = unique(contextSources.filter((source) => source.toLowerCase().endsWith(".json")));
|
|
1162
|
+
const explicitResolution = findPopulationPlan(projectDir, explicit);
|
|
1163
|
+
if (explicitResolution)
|
|
1164
|
+
return explicitResolution;
|
|
1165
|
+
const activeCandidates = activePopulationPlanCandidates(projectDir, ticketsConfig);
|
|
1166
|
+
const activeResolution = findPopulationPlan(projectDir, activeCandidates);
|
|
1167
|
+
if (activeResolution)
|
|
1168
|
+
return activeResolution;
|
|
1169
|
+
const locations = unique([...explicit, ...activeCandidates]);
|
|
1170
|
+
throw new Error(`ticket population could not find a valid approved Rafi plan. Looked for: ${locations.join(", ") || "configured Rafi plan locations"}. `
|
|
1171
|
+
+ "Run `rafi plan --validate` (or run `rafi plan` first) and retry.");
|
|
1172
|
+
}
|
|
1173
|
+
function activePopulationPlanCandidates(projectDir, ticketsConfig) {
|
|
1174
|
+
const configured = RAFI_CONFIG_FILES.map((file) => resolveConfiguredRafiPlanPath(projectDir, file)).filter((path) => Boolean(path));
|
|
1175
|
+
return unique([
|
|
1176
|
+
...configured,
|
|
1177
|
+
join(dirname(ticketsConfig.paths.progressDoc), "rafi-plan.json"),
|
|
1178
|
+
"docs/rafi-plan.json",
|
|
1179
|
+
]);
|
|
1180
|
+
}
|
|
1181
|
+
function resolveConfiguredRafiPlanPath(projectDir, file) {
|
|
1182
|
+
const configPath = join(projectDir, file);
|
|
1183
|
+
if (!existsSync(configPath))
|
|
1184
|
+
return undefined;
|
|
1185
|
+
const raw = parseYaml(readFileSync(configPath, "utf8"));
|
|
1186
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
1187
|
+
throw new Error(`${file}: expected a YAML object`);
|
|
1188
|
+
}
|
|
1189
|
+
const docs = raw.docs;
|
|
1190
|
+
if (typeof docs?.root !== "string")
|
|
1191
|
+
return undefined;
|
|
1192
|
+
return `${validateDocsRoot(projectDir, docs.root)}/rafi-plan.json`;
|
|
1193
|
+
}
|
|
1194
|
+
function findPopulationPlan(projectDir, candidates) {
|
|
1195
|
+
for (const candidate of candidates) {
|
|
1196
|
+
const absolute = resolve(projectDir, candidate);
|
|
1197
|
+
if (!existsSync(absolute))
|
|
1106
1198
|
continue;
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1199
|
+
try {
|
|
1200
|
+
const plan = JSON.parse(readFileSync(absolute, "utf8"));
|
|
1201
|
+
if (plan.version === 1 && typeof plan.plan_id === "string" && plan.plan_id && Number.isInteger(plan.revision) && typeof plan.content_digest === "string" && plan.content_digest) {
|
|
1202
|
+
return { path: displayPath(projectDir, absolute), plan };
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
catch {
|
|
1206
|
+
// A JSON context document is not necessarily a Rafi plan. Keep looking
|
|
1207
|
+
// for the active approved plan and report every location if none exists.
|
|
1208
|
+
}
|
|
1110
1209
|
}
|
|
1111
|
-
|
|
1210
|
+
return undefined;
|
|
1211
|
+
}
|
|
1212
|
+
function displayPath(projectDir, absolutePath) {
|
|
1213
|
+
const path = relative(projectDir, absolutePath).replace(/\\/g, "/");
|
|
1214
|
+
return path && !path.startsWith("../") ? path : absolutePath;
|
|
1215
|
+
}
|
|
1216
|
+
function samePath(projectDir, first, second) {
|
|
1217
|
+
return resolve(projectDir, first) === resolve(projectDir, second);
|
|
1218
|
+
}
|
|
1219
|
+
function isApprovedPlanArtifact(projectDir, source, approvedDataPath) {
|
|
1220
|
+
if (samePath(projectDir, source, approvedDataPath))
|
|
1221
|
+
return true;
|
|
1222
|
+
const dataPath = resolve(projectDir, approvedDataPath);
|
|
1223
|
+
return source.toLowerCase().endsWith(".md")
|
|
1224
|
+
&& resolve(projectDir, source) === dataPath.replace(/\.json$/i, ".md");
|
|
1112
1225
|
}
|
|
1113
1226
|
function ticketOwnershipReceipts(projectDir, commandName, _afterWrite) {
|
|
1114
1227
|
const candidates = [
|
package/dist/diagnostics.js
CHANGED
|
@@ -7,6 +7,7 @@ import { diagnosticDigest, ObservabilityReader, sanitizeDiagnosticValue } from "
|
|
|
7
7
|
import { WorkflowReader } from "./workflowReader.js";
|
|
8
8
|
import { isTicketsInitialized, loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
|
|
9
9
|
import { loadTickets } from "./tickets/ticketLoader.js";
|
|
10
|
+
import { isLiveProcessIdentity } from "./processIdentity.js";
|
|
10
11
|
const WAIT_KINDS = new Set(["user_wait", "dependency_wait", "external_ci_wait"]);
|
|
11
12
|
const ALLOWED_GIT = new Set(["status", "rev-parse", "rev-list", "diff"]);
|
|
12
13
|
export function selectDiagnosticRun(projectDir, requestedRunId) {
|
|
@@ -317,13 +318,7 @@ function evaluateLease(lease, now) {
|
|
|
317
318
|
catch {
|
|
318
319
|
exists = false;
|
|
319
320
|
}
|
|
320
|
-
|
|
321
|
-
try {
|
|
322
|
-
sameStart = readFileSync(`/proc/${lease.pid}/stat`, "utf8").split(" ")[21] === lease.processStart;
|
|
323
|
-
}
|
|
324
|
-
catch {
|
|
325
|
-
sameStart = false;
|
|
326
|
-
}
|
|
321
|
+
const sameStart = isLiveProcessIdentity(lease.pid, lease.processStart);
|
|
327
322
|
const live = age <= 45_000 && exists && sameStart;
|
|
328
323
|
return { summary: live ? `Lease heartbeat is fresh (${formatDuration(age)} old) and PID/start identity match.` : `Lease health failed: heartbeat age ${formatDuration(age)}, process exists=${exists}, process-start matches=${sameStart}.`, live, failureObserved: !live && (!exists || !sameStart) };
|
|
329
324
|
}
|
package/dist/observability.js
CHANGED
|
@@ -5,6 +5,7 @@ import { gzipSync } from "node:zlib";
|
|
|
5
5
|
import { hostname, loadavg } from "node:os";
|
|
6
6
|
import { dirname, join, resolve, sep } from "node:path";
|
|
7
7
|
import Database from "better-sqlite3";
|
|
8
|
+
import { processStartIdentity } from "./processIdentity.js";
|
|
8
9
|
export const OBSERVABILITY_DB_FILE = ".rafi/observability.sqlite3";
|
|
9
10
|
export const OBSERVABILITY_SCHEMA_VERSION = 2;
|
|
10
11
|
export const DEFAULT_OBSERVABILITY_CONFIG = {
|
|
@@ -963,12 +964,6 @@ function stableJson(value) {
|
|
|
963
964
|
return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`).join(",")}}`;
|
|
964
965
|
return JSON.stringify(value ?? null);
|
|
965
966
|
}
|
|
966
|
-
function processStartIdentity(pid) { try {
|
|
967
|
-
return readFileSync(`/proc/${pid}/stat`, "utf8").split(" ")[21] ?? "unavailable";
|
|
968
|
-
}
|
|
969
|
-
catch {
|
|
970
|
-
return "unavailable";
|
|
971
|
-
} }
|
|
972
967
|
function databaseBytes(path) { return [path, `${path}-wal`, `${path}-shm`].reduce((sum, item) => sum + (existsSync(item) ? statSync(item).size : 0), 0); }
|
|
973
968
|
function fileSize(path) { try {
|
|
974
969
|
return statSync(path).size;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A stable, host-local process incarnation marker. Linux exposes it through
|
|
3
|
+
* procfs; macOS does not, so use `ps lstart` instead. The value is persisted
|
|
4
|
+
* only to detect PID reuse and is never treated as a portable identifier.
|
|
5
|
+
*/
|
|
6
|
+
export declare function processStartIdentity(pid?: number): string;
|
|
7
|
+
/** True only when the PID exists and represents the recorded incarnation. */
|
|
8
|
+
export declare function isLiveProcessIdentity(pid: number, expectedStart: string): boolean;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
/**
|
|
4
|
+
* A stable, host-local process incarnation marker. Linux exposes it through
|
|
5
|
+
* procfs; macOS does not, so use `ps lstart` instead. The value is persisted
|
|
6
|
+
* only to detect PID reuse and is never treated as a portable identifier.
|
|
7
|
+
*/
|
|
8
|
+
export function processStartIdentity(pid = process.pid) {
|
|
9
|
+
try {
|
|
10
|
+
const value = readFileSync(`/proc/${pid}/stat`, "utf8").split(" ")[21];
|
|
11
|
+
// Preserve the historical Linux representation so an upgrade does not
|
|
12
|
+
// invalidate a lease that was created by the prior release.
|
|
13
|
+
if (value)
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// procfs is intentionally absent on macOS and some constrained hosts.
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
const value = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
21
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 1_000,
|
|
22
|
+
}).trim();
|
|
23
|
+
if (value)
|
|
24
|
+
return `ps:${value}`;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// A caller will treat unavailable identity as unverified, never matching.
|
|
28
|
+
}
|
|
29
|
+
return "unavailable";
|
|
30
|
+
}
|
|
31
|
+
/** True only when the PID exists and represents the recorded incarnation. */
|
|
32
|
+
export function isLiveProcessIdentity(pid, expectedStart) {
|
|
33
|
+
try {
|
|
34
|
+
process.kill(pid, 0);
|
|
35
|
+
return expectedStart !== "unavailable" && processStartIdentity(pid) === expectedStart;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { hostname } from "node:os";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
2
|
import { resolve } from "node:path";
|
|
4
3
|
import { BUILD_LEASE_STALE_MS, readBuildRuns } from "./buildRuns.js";
|
|
5
4
|
import { collectManagerDiagnostics } from "./diagnostics.js";
|
|
6
5
|
import { diagnosticDigest, ObservabilityReader } from "./observability.js";
|
|
7
6
|
import { WorkflowReader } from "./workflowReader.js";
|
|
7
|
+
import { isLiveProcessIdentity } from "./processIdentity.js";
|
|
8
8
|
export const MANAGER_CATALOG_LIMIT = 30;
|
|
9
9
|
export const MANAGER_LOOKUP_MAX_ROUNDS = 2;
|
|
10
10
|
export const MANAGER_LOOKUP_MAX_OPERATIONS = 6;
|
|
@@ -261,26 +261,13 @@ function verifiedActiveRunId(lease, now) {
|
|
|
261
261
|
return undefined;
|
|
262
262
|
if (lease.host !== hostname())
|
|
263
263
|
return undefined;
|
|
264
|
-
|
|
265
|
-
process.kill(lease.pid, 0);
|
|
266
|
-
const start = readFileSync(`/proc/${lease.pid}/stat`, "utf8").split(" ")[21];
|
|
267
|
-
return start === lease.processStart ? lease.runId : undefined;
|
|
268
|
-
}
|
|
269
|
-
catch {
|
|
270
|
-
return undefined;
|
|
271
|
-
}
|
|
264
|
+
return isLiveProcessIdentity(lease.pid, lease.processStart) ? lease.runId : undefined;
|
|
272
265
|
}
|
|
273
266
|
function verifiedSnapshotLease(run, now) {
|
|
274
267
|
const lease = run.lease;
|
|
275
268
|
if (!lease || now.getTime() - Date.parse(lease.heartbeatAt) > BUILD_LEASE_STALE_MS || lease.hostname !== hostname())
|
|
276
269
|
return false;
|
|
277
|
-
|
|
278
|
-
process.kill(lease.pid, 0);
|
|
279
|
-
return readFileSync(`/proc/${lease.pid}/stat`, "utf8").split(" ")[21] === lease.processStart;
|
|
280
|
-
}
|
|
281
|
-
catch {
|
|
282
|
-
return false;
|
|
283
|
-
}
|
|
270
|
+
return isLiveProcessIdentity(lease.pid, lease.processStart);
|
|
284
271
|
}
|
|
285
272
|
function runUpdatedAt(runId, builds, stored, workflows, rollups) { return builds.get(runId)?.updatedAt ?? workflows.get(runId)?.updatedAt ?? stored.get(runId)?.updatedAt ?? rollups.get(runId)?.completedAt ?? rollups.get(runId)?.createdAt ?? ""; }
|
|
286
273
|
function ticketsFromWorkflow(run) { const value = run?.originalWork; return Array.isArray(value?.tickets) ? value.tickets.filter((item) => typeof item === "string") : []; }
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isCancel, multiselect, select, text } from "@clack/prompts";
|
|
2
|
+
import { currentActivity, pauseActivityForInput } from "./activity.js";
|
|
2
3
|
const ASK_USER_QUESTION_TOOL = "AskUserQuestion";
|
|
3
4
|
const CUSTOM_VALUE = "__rafi_custom_response__";
|
|
4
5
|
export const GRILL_ME_STOP_CHOICE = "Stop questions and make the plan now";
|
|
@@ -24,7 +25,9 @@ export async function handleProviderQuestionTool(req, opts) {
|
|
|
24
25
|
const answers = recordFromUnknown(req.input.answers);
|
|
25
26
|
const annotations = annotationRecordFromUnknown(req.input.annotations);
|
|
26
27
|
for (const question of questions) {
|
|
27
|
-
|
|
28
|
+
// A native provider question is a local terminal interaction, not provider
|
|
29
|
+
// work. Keep the live activity renderer from overwriting Clack's prompt.
|
|
30
|
+
const answered = await pauseActivityForInput(() => askOneQuestion(question, prompts, req.signal));
|
|
28
31
|
if (answered.cancelled) {
|
|
29
32
|
return {
|
|
30
33
|
behavior: "deny",
|
|
@@ -35,6 +38,7 @@ export async function handleProviderQuestionTool(req, opts) {
|
|
|
35
38
|
answers[question.question] = answered.answer;
|
|
36
39
|
if (answered.annotation)
|
|
37
40
|
annotations[question.question] = answered.annotation;
|
|
41
|
+
currentActivity()?.update("answer sent; waiting for Claude");
|
|
38
42
|
if (answered.answer.trim()) {
|
|
39
43
|
opts.onAnsweredQuestion?.({
|
|
40
44
|
toolName: ASK_USER_QUESTION_TOOL,
|
package/dist/qaProtocolV2.d.ts
CHANGED
|
@@ -28,7 +28,8 @@ export interface QaConfinementV2 {
|
|
|
28
28
|
sourceMode: "read-only";
|
|
29
29
|
scratchMode: "isolated";
|
|
30
30
|
networkMode: "disabled" | "provider-required";
|
|
31
|
-
|
|
31
|
+
/** "user" permits the machine owner's Claude authentication settings, but no project settings. */
|
|
32
|
+
settingsSources: "none" | "user";
|
|
32
33
|
environmentDigest: string;
|
|
33
34
|
policyDigest: string;
|
|
34
35
|
}
|
package/dist/qaReview.js
CHANGED
|
@@ -8,6 +8,7 @@ import { randomUUID } from "node:crypto";
|
|
|
8
8
|
import { execFileSync } from "node:child_process";
|
|
9
9
|
import { resolve } from "node:path";
|
|
10
10
|
import { WorkflowDb } from "./workflowDb.js";
|
|
11
|
+
import { canonicalSessionPath } from "./sessionIdentity.js";
|
|
11
12
|
import { loadProjectAutonomyConfig, resolveAutonomyPolicy } from "./recoveryPolicy.js";
|
|
12
13
|
import { appendQaRecoveryAttempt, appendQaRecoveryResource, appendQaRecoveryReviewedState, compareQaRecoveryReviewedState, createQaRecoveryPacket, ensureQaRecoveryExcluded, loadQaRecoveryPacket, materializeQaRecoveryContext, qaRecoveryInventory, qaReportDigest, renderQaRecoveryAcknowledgementInstruction, updateQaRecoveryPosition, updateQaRecoveryReviewIdentity, validateManualQaReport, validateQaRecoveryAcknowledgement, } from "./qaRecovery.js";
|
|
13
14
|
import { canonicalJson, createQaFindingRefs, qaDigest } from "./qaProtocolV2.js";
|
|
@@ -1445,14 +1446,14 @@ function validateQaSessionHandle(handle, reviewedSnapshotPath) {
|
|
|
1445
1446
|
if (identity.version !== 1 || identity.role !== "qa" || identity.stream !== "qa" || identity.provider !== handle.adapter.agent
|
|
1446
1447
|
|| !identity.sessionId || /^(?:unavailable|unknown)$/i.test(identity.sessionId.trim())
|
|
1447
1448
|
|| !Number.isSafeInteger(identity.generation) || identity.generation < 0
|
|
1448
|
-
||
|
|
1449
|
+
|| canonicalSessionPath(identity.cwd) !== canonicalSessionPath(reviewedSnapshotPath) || !identity.configRoot
|
|
1449
1450
|
|| Number.isNaN(Date.parse(identity.createdAt)) || !identity.validatedAt || Number.isNaN(Date.parse(identity.validatedAt))
|
|
1450
1451
|
|| handle.adapter.sessionId() !== identity.sessionId || !adapterIdentity
|
|
1451
1452
|
|| stableProviderSessionIdentity(adapterIdentity) !== stableProviderSessionIdentity(identity)) {
|
|
1452
1453
|
throw new Error("QA session handle has an invalid or mismatched scoped identity");
|
|
1453
1454
|
}
|
|
1454
1455
|
if (!handle.effectiveRoleInstructions.trim() || confinement.version !== 2 || confinement.sourceMode !== "read-only"
|
|
1455
|
-
|| confinement.scratchMode !== "isolated" || confinement.settingsSources
|
|
1456
|
+
|| confinement.scratchMode !== "isolated" || !["none", "user"].includes(confinement.settingsSources)
|
|
1456
1457
|
|| !["disabled", "provider-required"].includes(confinement.networkMode)
|
|
1457
1458
|
|| !/^[a-f0-9]{64}$/.test(confinement.environmentDigest) || !/^[a-f0-9]{64}$/.test(confinement.policyDigest)
|
|
1458
1459
|
|| digest !== qaDigest("qa-confinement", confinementFields)) {
|
package/dist/qaRuntime.js
CHANGED
|
@@ -9,10 +9,14 @@ export function frozenQaRuntimeSettings(adapter, metadata) {
|
|
|
9
9
|
}
|
|
10
10
|
export function describeQaRuntimeHandle(adapter, metadata, handoffReceipt) {
|
|
11
11
|
const settings = frozenQaRuntimeSettings(adapter, metadata);
|
|
12
|
-
|
|
12
|
+
// `settingSources` is a Claude Agent SDK control. Claude read-only sessions
|
|
13
|
+
// need the machine owner's authentication settings; Codex does not receive
|
|
14
|
+
// that SDK option.
|
|
15
|
+
const settingsSources = adapter.agent === "claude" ? "user" : "none";
|
|
16
|
+
const confinementPolicy = { sandboxMode: "read-only", settingsSources, permissions: readOnlyPermissionConfig(), dependencyProjection: "read-only-symlink", scratch: "sibling-temp-directory" };
|
|
13
17
|
const confinementBase = {
|
|
14
18
|
version: 2, sourceMode: "read-only", scratchMode: "isolated",
|
|
15
|
-
settingsSources
|
|
19
|
+
settingsSources, networkMode: "provider-required",
|
|
16
20
|
environmentDigest: qaDigest("qa-confinement-environment", { runtime: settings.make, model: settings.model, reasoning: settings.reasoning, fast: settings.fast, platform: process.platform, arch: process.arch }),
|
|
17
21
|
policyDigest: qaDigest("qa-confinement-policy", confinementPolicy),
|
|
18
22
|
};
|
package/dist/workflowDb.js
CHANGED
|
@@ -4,6 +4,7 @@ import { hostname } from "node:os";
|
|
|
4
4
|
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import Database from "better-sqlite3";
|
|
6
6
|
import { providerSessionKey } from "./sessionIdentity.js";
|
|
7
|
+
import { isLiveProcessIdentity, processStartIdentity } from "./processIdentity.js";
|
|
7
8
|
import { initialQaReducerState, qaDigest, reduceQaState, } from "./qaProtocolV2.js";
|
|
8
9
|
export const WORKFLOW_DB_FILE = ".rafi/recovery.sqlite3";
|
|
9
10
|
/** Read the current project lease without creating or migrating recovery state. */
|
|
@@ -1523,24 +1524,12 @@ function sanitizeContinuityValue(value, depth = 0) {
|
|
|
1523
1524
|
return Object.fromEntries(Object.entries(value).filter(([key]) => !/^(credential|secret|hidden_reasoning|raw_transcript|api_key|token|password)$/i.test(key)).slice(0, 500).map(([key, entry]) => [key, sanitizeContinuityValue(entry, depth + 1)]));
|
|
1524
1525
|
return value;
|
|
1525
1526
|
}
|
|
1526
|
-
function processStartIdentity(pid = process.pid) { try {
|
|
1527
|
-
return readFileSync(`/proc/${pid}/stat`, "utf8").split(" ")[21] ?? "unavailable";
|
|
1528
|
-
}
|
|
1529
|
-
catch {
|
|
1530
|
-
return "unavailable";
|
|
1531
|
-
} }
|
|
1532
1527
|
function leaseVerifiedLive(lease, now, staleMs) {
|
|
1533
1528
|
if (now.getTime() - new Date(lease.heartbeatAt).getTime() > staleMs)
|
|
1534
1529
|
return false;
|
|
1535
1530
|
if (lease.host !== hostname())
|
|
1536
1531
|
return true;
|
|
1537
|
-
|
|
1538
|
-
process.kill(lease.pid, 0);
|
|
1539
|
-
return processStartIdentity(lease.pid) === lease.processStart;
|
|
1540
|
-
}
|
|
1541
|
-
catch {
|
|
1542
|
-
return false;
|
|
1543
|
-
}
|
|
1532
|
+
return isLiveProcessIdentity(lease.pid, lease.processStart);
|
|
1544
1533
|
}
|
|
1545
1534
|
function ensureRecoveryGitignore(projectDir) {
|
|
1546
1535
|
const localExclude = join(projectDir, ".git", "info", "exclude");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-foreman",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.18",
|
|
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",
|
|
@@ -195,7 +195,7 @@
|
|
|
195
195
|
"scripts": {
|
|
196
196
|
"build": "tsc -p tsconfig.build.json",
|
|
197
197
|
"dev": "tsx src/index.ts",
|
|
198
|
-
"test": "tsx --test test/*.test.ts",
|
|
198
|
+
"test": "tsx --test --test-concurrency=1 test/*.test.ts",
|
|
199
199
|
"typecheck": "tsc --noEmit",
|
|
200
200
|
"test:live-providers": "RAFI_LIVE_PROVIDER_SESSIONS=1 tsx --test test/providerSmoke.test.ts",
|
|
201
201
|
"prepublishOnly": "tsc -p ../spec/tsconfig.build.json && tsc -p tsconfig.build.json && pnpm --filter ai-foreman test && tsc --noEmit"
|