ai-foreman 1.7.6 → 1.7.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -1
- package/dist/activity.d.ts +3 -0
- package/dist/activity.js +29 -4
- package/dist/adapters/claude.d.ts +33 -2
- package/dist/adapters/claude.js +208 -17
- package/dist/adapters/codex.d.ts +11 -1
- package/dist/adapters/codex.js +195 -20
- package/dist/adapters/recovering.d.ts +8 -2
- package/dist/adapters/recovering.js +23 -3
- package/dist/adapters/sessionFailure.d.ts +18 -0
- package/dist/adapters/sessionFailure.js +51 -0
- package/dist/adapters/types.d.ts +52 -9
- package/dist/agentRun.d.ts +4 -1
- package/dist/agentRun.js +96 -9
- package/dist/branch/currentGuard.d.ts +34 -0
- package/dist/branch/currentGuard.js +65 -0
- package/dist/branch/planner.d.ts +1 -1
- package/dist/branch/planner.js +2 -7
- package/dist/branch/prefix.d.ts +8 -0
- package/dist/branch/prefix.js +26 -0
- package/dist/branch/resume.d.ts +2 -0
- package/dist/branch/resume.js +8 -0
- package/dist/branch/runner.d.ts +14 -2
- package/dist/branch/runner.js +77 -17
- package/dist/buildRuns.d.ts +13 -4
- package/dist/buildRuns.js +85 -19
- package/dist/cli/handoffs.d.ts +2 -0
- package/dist/cli/handoffs.js +74 -0
- package/dist/cli/start.js +940 -42
- package/dist/cli/tickets.js +199 -3
- package/dist/continuity.d.ts +86 -0
- package/dist/continuity.js +365 -0
- package/dist/foreman.d.ts +5 -1
- package/dist/foreman.js +41 -6
- package/dist/handoffs.d.ts +69 -0
- package/dist/handoffs.js +326 -0
- package/dist/log.d.ts +1 -1
- package/dist/permissions/policy.d.ts +5 -1
- package/dist/permissions/policy.js +48 -1
- package/dist/qaReview.d.ts +6 -6
- package/dist/qaReview.js +20 -14
- package/dist/sessionAvailability.d.ts +15 -0
- package/dist/sessionAvailability.js +57 -0
- package/dist/sessionIdentity.d.ts +33 -0
- package/dist/sessionIdentity.js +132 -0
- package/dist/sessionLifecycle.d.ts +102 -3
- package/dist/sessionLifecycle.js +578 -20
- package/dist/statusReporter.d.ts +58 -8
- package/dist/statusReporter.js +210 -14
- package/dist/ticketPlanning.js +8 -0
- package/dist/ticketPopulation.js +88 -12
- package/dist/tickets/commands.js +20 -12
- package/dist/tickets/groupReset.d.ts +25 -0
- package/dist/tickets/groupReset.js +279 -0
- package/dist/tickets/groups.d.ts +36 -0
- package/dist/tickets/groups.js +68 -0
- package/dist/tickets/importer.js +27 -3
- package/dist/tickets/recommendations.js +31 -7
- package/dist/tickets/reset.d.ts +1 -0
- package/dist/tickets/reset.js +1 -0
- package/dist/tickets/setupConfig.d.ts +1 -0
- package/dist/tickets/setupConfig.js +10 -2
- package/dist/tickets/stateDb.d.ts +34 -0
- package/dist/tickets/stateDb.js +207 -0
- package/dist/tickets/ticketLoader.js +5 -2
- package/dist/tickets/validate.js +4 -0
- package/dist/workflowDb.d.ts +99 -2
- package/dist/workflowDb.js +434 -8
- package/package.json +31 -3
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
/** Resolve symlinks for an existing location and otherwise return an absolute normalized path. */
|
|
6
|
+
export function canonicalSessionPath(path) {
|
|
7
|
+
const absolute = resolve(path);
|
|
8
|
+
try {
|
|
9
|
+
return realpathSync.native(absolute);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return absolute;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Stable for one Git worktree lifetime. The token lives in that worktree's
|
|
17
|
+
* administrative Git directory (not in the checked-out tree), so normal edits
|
|
18
|
+
* and commits do not change it while removal/recreation cannot inherit it.
|
|
19
|
+
*/
|
|
20
|
+
export function captureWorkspaceIdentity(cwd) {
|
|
21
|
+
try {
|
|
22
|
+
const canonicalCwd = canonicalSessionPath(cwd);
|
|
23
|
+
const gitDir = canonicalSessionPath(execFileSync("git", ["rev-parse", "--absolute-git-dir"], {
|
|
24
|
+
cwd: canonicalCwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
25
|
+
}).trim());
|
|
26
|
+
const marker = join(gitDir, "rafi-workspace-id-v1");
|
|
27
|
+
let token;
|
|
28
|
+
try {
|
|
29
|
+
token = readFileSync(marker, "utf8").trim();
|
|
30
|
+
if (!/^[0-9a-f-]{36}$/i.test(token))
|
|
31
|
+
throw new Error("invalid Rafi worktree identity marker");
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
const candidate = randomUUID();
|
|
35
|
+
try {
|
|
36
|
+
writeFileSync(marker, `${candidate}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
37
|
+
}
|
|
38
|
+
catch { /* another host process may have initialized the same worktree */ }
|
|
39
|
+
token = readFileSync(marker, "utf8").trim();
|
|
40
|
+
if (!/^[0-9a-f-]{36}$/i.test(token))
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
return createHash("sha256").update(`${canonicalCwd}\0${gitDir}\0${token}`).digest("hex");
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function createProviderSessionRef(input) {
|
|
50
|
+
if (!input.sessionId.trim())
|
|
51
|
+
throw new Error("provider session ID must not be empty");
|
|
52
|
+
const generation = input.generation ?? 0;
|
|
53
|
+
if (!Number.isSafeInteger(generation) || generation < 0)
|
|
54
|
+
throw new Error("provider session generation must be a non-negative safe integer");
|
|
55
|
+
const cwd = canonicalSessionPath(input.cwd);
|
|
56
|
+
const workspaceIdentity = input.workspaceIdentity ?? captureWorkspaceIdentity(cwd);
|
|
57
|
+
return {
|
|
58
|
+
version: 1,
|
|
59
|
+
provider: input.provider,
|
|
60
|
+
sessionId: input.sessionId,
|
|
61
|
+
role: input.role ?? (input.stream === "qa" ? "qa" : "builder"),
|
|
62
|
+
stream: input.stream ?? input.role ?? "builder",
|
|
63
|
+
generation,
|
|
64
|
+
cwd,
|
|
65
|
+
configRoot: canonicalSessionPath(input.configRoot),
|
|
66
|
+
...(workspaceIdentity ? { workspaceIdentity } : {}),
|
|
67
|
+
...(input.ticketId ? { ticketId: input.ticketId } : {}),
|
|
68
|
+
...(input.deliveryUnitId ? { deliveryUnitId: input.deliveryUnitId } : {}),
|
|
69
|
+
source: input.source ?? "observed",
|
|
70
|
+
createdAt: input.createdAt ?? new Date().toISOString(),
|
|
71
|
+
...(input.validatedAt ? { validatedAt: input.validatedAt } : {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** Identity used by accounting, compaction, settings acknowledgements, and mutation leases. */
|
|
75
|
+
export function providerSessionKey(ref) {
|
|
76
|
+
const cwd = canonicalSessionPath(ref.cwd);
|
|
77
|
+
const payload = [ref.provider, ref.sessionId, ref.role, cwd, ref.workspaceIdentity ?? "unscoped"].join("\0");
|
|
78
|
+
return `psv1_${createHash("sha256").update(payload).digest("hex")}`;
|
|
79
|
+
}
|
|
80
|
+
export function validateProviderSessionScope(ref, requested, now = new Date()) {
|
|
81
|
+
const checkedAt = now.toISOString();
|
|
82
|
+
const unavailable = (reason, detail, observedCwd) => ({
|
|
83
|
+
version: 1, status: reason === "legacy-unscoped" ? "unknown" : "unavailable", checkedAt, reason, detail,
|
|
84
|
+
...(observedCwd ? { observedCwd } : {}), sessionRef: ref,
|
|
85
|
+
});
|
|
86
|
+
if (ref.provider !== requested.provider)
|
|
87
|
+
return unavailable("provider-mismatch", `stored provider ${ref.provider} does not match requested provider ${requested.provider}`);
|
|
88
|
+
if (requested.role && ref.role !== requested.role)
|
|
89
|
+
return unavailable("role-mismatch", `stored role ${ref.role} does not match requested role ${requested.role}`);
|
|
90
|
+
if (requested.stream && ref.stream !== requested.stream)
|
|
91
|
+
return unavailable("stream-mismatch", `stored stream ${ref.stream} does not match requested stream ${requested.stream}`);
|
|
92
|
+
if (!existsSync(ref.cwd) || !existsSync(requested.cwd))
|
|
93
|
+
return unavailable("not-found", "the recorded provider working directory no longer exists");
|
|
94
|
+
const storedCwd = canonicalSessionPath(ref.cwd);
|
|
95
|
+
const requestedCwd = canonicalSessionPath(requested.cwd);
|
|
96
|
+
if (storedCwd !== requestedCwd)
|
|
97
|
+
return unavailable("cwd-mismatch", `stored cwd ${storedCwd} does not match requested cwd ${requestedCwd}`, requestedCwd);
|
|
98
|
+
const storedRoot = canonicalSessionPath(ref.configRoot);
|
|
99
|
+
const requestedRoot = canonicalSessionPath(requested.configRoot);
|
|
100
|
+
if (storedRoot !== requestedRoot)
|
|
101
|
+
return unavailable("config-root-mismatch", `stored Rafi config root ${storedRoot} does not match requested root ${requestedRoot}`, requestedCwd);
|
|
102
|
+
const actualIdentity = requested.workspaceIdentity ?? captureWorkspaceIdentity(requestedCwd);
|
|
103
|
+
if (ref.workspaceIdentity && actualIdentity !== ref.workspaceIdentity) {
|
|
104
|
+
return unavailable("workspace-mismatch", "the directory at the recorded path is not the original worktree", requestedCwd);
|
|
105
|
+
}
|
|
106
|
+
if (!ref.workspaceIdentity || !actualIdentity) {
|
|
107
|
+
return unavailable("legacy-unscoped", "the stored session has no verifiable worktree identity", requestedCwd);
|
|
108
|
+
}
|
|
109
|
+
if (ref.ticketId && requested.ticketId && ref.ticketId !== requested.ticketId)
|
|
110
|
+
return unavailable("workspace-mismatch", `stored ticket ${ref.ticketId} does not match requested ticket ${requested.ticketId}`, requestedCwd);
|
|
111
|
+
if (ref.deliveryUnitId && requested.deliveryUnitId && ref.deliveryUnitId !== requested.deliveryUnitId)
|
|
112
|
+
return unavailable("workspace-mismatch", `stored delivery unit ${ref.deliveryUnitId} does not match requested unit ${requested.deliveryUnitId}`, requestedCwd);
|
|
113
|
+
const validated = { ...ref, cwd: storedCwd, configRoot: storedRoot, workspaceIdentity: actualIdentity, validatedAt: checkedAt };
|
|
114
|
+
return { version: 1, status: "available", checkedAt, observedCwd: requestedCwd, sessionRef: validated };
|
|
115
|
+
}
|
|
116
|
+
export function latestSessionBinding(bindings, role, sessionId) {
|
|
117
|
+
return [...(bindings ?? [])]
|
|
118
|
+
.filter((ref) => ref.role === role && (!sessionId || ref.sessionId === sessionId))
|
|
119
|
+
.sort((a, b) => a.generation - b.generation || a.createdAt.localeCompare(b.createdAt))
|
|
120
|
+
.at(-1);
|
|
121
|
+
}
|
|
122
|
+
export function resolveUniqueSessionBinding(bindings, sessionId) {
|
|
123
|
+
const byKey = new Map((bindings ?? []).filter((ref) => ref.sessionId === sessionId).map((ref) => [providerSessionKey(ref), ref]));
|
|
124
|
+
if (byKey.size > 1)
|
|
125
|
+
throw new Error(`provider session ID ${sessionId} is ambiguous across recorded locations`);
|
|
126
|
+
return [...byKey.values()][0];
|
|
127
|
+
}
|
|
128
|
+
export function upsertSessionBinding(bindings, ref) {
|
|
129
|
+
const key = providerSessionKey(ref);
|
|
130
|
+
return [...(bindings ?? []).filter((candidate) => providerSessionKey(candidate) !== key), ref]
|
|
131
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.generation - b.generation);
|
|
132
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ConfigurableAgentRole, ResolvedAgentSettings, WorkflowIssue } from "rafi-spec";
|
|
1
|
+
import type { ConfigurableAgentRole, ContextSample, ProviderSessionRefV1, ResolvedAgentSettings, SessionAvailabilityV1, SessionStrategy, WorkflowIssue } from "rafi-spec";
|
|
2
2
|
import type { BuilderAdapter, ContextUsage } from "./adapters/types.js";
|
|
3
3
|
export interface SessionTransition {
|
|
4
4
|
kind: "initial" | "compacted" | "fresh" | "missing" | "compaction-retry" | "compaction-fallback" | "settings-attempt" | "settings-continued" | "settings-fallback";
|
|
@@ -15,23 +15,122 @@ export interface SessionBoundary {
|
|
|
15
15
|
export interface RoleSessionControllerOptions {
|
|
16
16
|
role: ConfigurableAgentRole;
|
|
17
17
|
settings: ResolvedAgentSettings;
|
|
18
|
-
create: (settings: ResolvedAgentSettings, resumeSessionId?: string) => Promise<BuilderAdapter>;
|
|
18
|
+
create: (settings: ResolvedAgentSettings, resumeSessionId?: string, resumeSessionRef?: ProviderSessionRefV1) => Promise<BuilderAdapter>;
|
|
19
19
|
report?: (transition: SessionTransition) => void;
|
|
20
20
|
now?: () => Date;
|
|
21
21
|
initialSessionId?: string;
|
|
22
|
+
initialSessionRef?: ProviderSessionRefV1;
|
|
23
|
+
/** Publish/validate cumulative state and return the accepted fresh successor. */
|
|
24
|
+
handoff?: (input: {
|
|
25
|
+
predecessor: BuilderAdapter;
|
|
26
|
+
durableHandoff: string;
|
|
27
|
+
reason: string;
|
|
28
|
+
settings: ResolvedAgentSettings;
|
|
29
|
+
}) => Promise<BuilderAdapter>;
|
|
30
|
+
}
|
|
31
|
+
export declare class RoleSessionValidationError extends Error {
|
|
32
|
+
readonly role: ConfigurableAgentRole;
|
|
33
|
+
readonly availability: SessionAvailabilityV1;
|
|
34
|
+
constructor(role: ConfigurableAgentRole, availability: SessionAvailabilityV1);
|
|
22
35
|
}
|
|
23
36
|
/** One lifecycle implementation shared by interviews, builds, QA, and recovery. */
|
|
24
37
|
export declare class RoleSessionController {
|
|
25
38
|
private readonly options;
|
|
26
39
|
private adapter?;
|
|
27
40
|
private workSessions;
|
|
41
|
+
private thresholdController?;
|
|
28
42
|
constructor(options: RoleSessionControllerOptions);
|
|
29
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Production facade: the role owner authorizes scoped adapters and validated
|
|
45
|
+
* successors while the threshold controller remains delegated policy and
|
|
46
|
+
* accounting. Compatibility callers may still construct the threshold
|
|
47
|
+
* controller directly.
|
|
48
|
+
*/
|
|
49
|
+
static managed(options: ThresholdCompactionOptions): RoleSessionController;
|
|
50
|
+
atSafeBoundary(adapter: BuilderAdapter, frozenAction: string): Promise<ContextBoundaryResult>;
|
|
51
|
+
atWorkSessionBoundary(adapter: BuilderAdapter, frozenAction: string, strategy?: SessionStrategy): Promise<ContextBoundaryResult>;
|
|
52
|
+
effectiveSettings(): ResolvedAgentSettings;
|
|
53
|
+
effectiveThreshold(): number;
|
|
54
|
+
next(durableHandoff: string, nextSettings?: ResolvedAgentSettings): Promise<SessionBoundary>;
|
|
30
55
|
current(): BuilderAdapter | undefined;
|
|
31
56
|
count(): number;
|
|
32
57
|
usage(): Promise<ContextUsage | undefined>;
|
|
33
58
|
close(): Promise<void>;
|
|
59
|
+
private moveFresh;
|
|
60
|
+
private adoptManagedAdapter;
|
|
61
|
+
private captureManagedIdentity;
|
|
62
|
+
private acceptManagedTransition;
|
|
34
63
|
private boundary;
|
|
35
64
|
private emit;
|
|
36
65
|
private issue;
|
|
37
66
|
}
|
|
67
|
+
export declare class ContextCapabilityError extends Error {
|
|
68
|
+
readonly runId: string;
|
|
69
|
+
readonly role: "builder" | "qa";
|
|
70
|
+
constructor(runId: string, role: "builder" | "qa", message: string);
|
|
71
|
+
}
|
|
72
|
+
export interface ThresholdCompactionOptions {
|
|
73
|
+
projectDir: string;
|
|
74
|
+
runId: string;
|
|
75
|
+
role: "builder" | "qa";
|
|
76
|
+
initialSettings: ResolvedAgentSettings;
|
|
77
|
+
/** Read durable settings at every safe boundary. A newer revision wins. */
|
|
78
|
+
readSettings?: () => ResolvedAgentSettings;
|
|
79
|
+
/** Apply provider/model controls before a newer durable revision is acknowledged. */
|
|
80
|
+
settingsBoundary?: (input: {
|
|
81
|
+
adapter: BuilderAdapter;
|
|
82
|
+
current: ResolvedAgentSettings;
|
|
83
|
+
next: ResolvedAgentSettings;
|
|
84
|
+
frozenAction: string;
|
|
85
|
+
}) => Promise<BuilderAdapter>;
|
|
86
|
+
settingsAdopted?: (settings: ResolvedAgentSettings, adapter: BuilderAdapter) => void;
|
|
87
|
+
handoff?: (input: {
|
|
88
|
+
reason: string;
|
|
89
|
+
adapter: BuilderAdapter;
|
|
90
|
+
sample: ContextSample;
|
|
91
|
+
settings: ResolvedAgentSettings;
|
|
92
|
+
compactionCount: number;
|
|
93
|
+
frozenAction: string;
|
|
94
|
+
}) => Promise<BuilderAdapter>;
|
|
95
|
+
report?: (event: {
|
|
96
|
+
kind: string;
|
|
97
|
+
detail: string;
|
|
98
|
+
sample?: ContextSample;
|
|
99
|
+
}) => void;
|
|
100
|
+
contextRetries?: number;
|
|
101
|
+
historicalCountUncertain?: boolean;
|
|
102
|
+
now?: () => Date;
|
|
103
|
+
}
|
|
104
|
+
export interface ContextBoundaryResult {
|
|
105
|
+
adapter: BuilderAdapter;
|
|
106
|
+
action: "continued" | "compacted" | "handed-off" | "below-threshold";
|
|
107
|
+
sample: ContextSample;
|
|
108
|
+
effectiveThreshold: number;
|
|
109
|
+
compactionCount: number;
|
|
110
|
+
}
|
|
111
|
+
/** Threshold-triggered native compaction at host-owned safe boundaries. */
|
|
112
|
+
export declare class ThresholdCompactionController {
|
|
113
|
+
private readonly options;
|
|
114
|
+
private settings;
|
|
115
|
+
private armed;
|
|
116
|
+
private crossing;
|
|
117
|
+
private readonly seenSessions;
|
|
118
|
+
private runOnlyThreshold?;
|
|
119
|
+
private bootstrapScheduled;
|
|
120
|
+
private bootstrapRecoveryDepth;
|
|
121
|
+
private historicalCountUncertain;
|
|
122
|
+
constructor(options: ThresholdCompactionOptions);
|
|
123
|
+
atSafeBoundary(adapter: BuilderAdapter, frozenAction: string): Promise<ContextBoundaryResult>;
|
|
124
|
+
/** Apply the configured ordinary work-session boundary without bypassing handoff or compaction accounting. */
|
|
125
|
+
atWorkSessionBoundary(adapter: BuilderAdapter, frozenAction: string, strategy?: SessionStrategy): Promise<ContextBoundaryResult>;
|
|
126
|
+
effectiveSettings(): ResolvedAgentSettings;
|
|
127
|
+
effectiveThreshold(): number;
|
|
128
|
+
private compactSession;
|
|
129
|
+
private performHandoff;
|
|
130
|
+
private adoptSettings;
|
|
131
|
+
private measure;
|
|
132
|
+
private compactionCount;
|
|
133
|
+
private handoffGeneration;
|
|
134
|
+
private threshold;
|
|
135
|
+
private now;
|
|
136
|
+
}
|