infinity-harness 2.6.6 → 2.8.0
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/CHANGELOG.md +80 -0
- package/README.md +68 -15
- package/extensions/infinity-harness/index.ts +600 -26
- package/harness/docs/ARCHITECTURE.md +13 -7
- package/harness/docs/CONSTRAINTS.md +13 -5
- package/harness/docs/DECISIONS.md +44 -0
- package/harness/docs/DOMAIN.md +44 -8
- package/package.json +1 -1
- package/src/core/config.ts +88 -1
- package/src/core/featureList.ts +85 -17
- package/src/core/gates.ts +8 -6
- package/src/core/init.ts +33 -3
- package/src/core/modelRouter.ts +149 -0
- package/src/core/paths.ts +29 -0
- package/src/core/plan.ts +39 -0
- package/src/core/runState.ts +151 -0
- package/src/core/settings.ts +138 -4
- package/src/core/types.ts +49 -0
- package/src/daemon/budget.ts +94 -0
- package/src/daemon/guard.ts +113 -0
- package/src/daemon/index.ts +421 -0
- package/src/daemon/isolation.ts +95 -0
- package/src/daemon/preflight.ts +132 -0
- package/src/daemon/server.ts +153 -0
- package/src/daemon/supervisorState.ts +83 -0
- package/src/daemon/worker.ts +239 -0
- package/src/daemon/worktree.ts +95 -0
- package/src/exec/piWorker.ts +706 -0
- package/src/goalState.ts +2 -22
- package/src/intake.ts +4 -1
- package/src/loop.ts +35 -34
- package/src/modelRouter.ts +0 -0
- package/src/remote.ts +28 -7
- package/src/replan.ts +7 -3
- package/src/rework.ts +9 -3
- package/src/runState.ts +15 -121
- package/src/scheduler.ts +115 -135
- package/src/supervisor.ts +955 -0
- package/src/taskList.ts +41 -3
- package/src/ui/dashboard.ts +127 -0
- package/src/ui/viewState.ts +77 -0
- package/src/ui/widget.ts +189 -0
- package/src/ui/wizard.ts +43 -7
- package/src/unstuck.ts +0 -0
- package/src/worker.ts +12 -8
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infinity-harness — modelRouter: which model for which unit (Core, pi-free).
|
|
3
|
+
*
|
|
4
|
+
* Pure decision: difficulty + tiers + baseModel -> asked {provider,id}.
|
|
5
|
+
* Never verifies — tier preflight + servedModel + budget live in the Daemon.
|
|
6
|
+
*
|
|
7
|
+
* Tiers live in `config.tiers` (A/B/C/D/X). Legacy `harness/model-router.json`
|
|
8
|
+
* is still migrated on read for one release (byDifficulty Master ladder).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { TierMap, TierSpec, HarnessConfig, FeatureList, Phase, Difficulty } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
export type ThinkingLevel = import("./types.ts").ThinkingLevel;
|
|
14
|
+
export type TierId = import("./types.ts").TierId;
|
|
15
|
+
|
|
16
|
+
const DIFF_TO_TIER: Record<Difficulty, import("./types.ts").TierId> = {
|
|
17
|
+
easy: "B",
|
|
18
|
+
moderate: "C",
|
|
19
|
+
difficult: "D",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function effectiveDifficultyForUnitFromTasks(tasks: Array<{ difficulty?: Difficulty }>): Difficulty | undefined {
|
|
23
|
+
const rank: Record<string, number> = { easy: 1, moderate: 2, difficult: 3 };
|
|
24
|
+
let best: Difficulty | undefined;
|
|
25
|
+
let bestRank = -1;
|
|
26
|
+
for (const t of tasks) {
|
|
27
|
+
const d = t.difficulty;
|
|
28
|
+
if (!d) continue;
|
|
29
|
+
const r = rank[d] ?? -1;
|
|
30
|
+
if (r > bestRank) { bestRank = r; best = d as Difficulty; }
|
|
31
|
+
}
|
|
32
|
+
return best;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function tiersOf(config: HarnessConfig): TierMap {
|
|
36
|
+
const t = (config as unknown as { tiers?: TierMap }).tiers;
|
|
37
|
+
if (t && typeof t === "object") return t;
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function baseModelOf(runState: { baseModel?: { provider: string; id: string } | string | null } | null | undefined): { provider: string; id: string } | null {
|
|
42
|
+
if (!runState?.baseModel) return null;
|
|
43
|
+
const bm: unknown = runState.baseModel;
|
|
44
|
+
if (typeof bm === "string") {
|
|
45
|
+
const parts = String(bm).split("/");
|
|
46
|
+
if (parts.length >= 2) return { provider: parts[0]!, id: parts.slice(1).join("/") };
|
|
47
|
+
return { provider: "anthropic", id: String(bm) };
|
|
48
|
+
}
|
|
49
|
+
if (typeof bm === "object" && bm !== null && typeof (bm as { provider: unknown }).provider === "string" && typeof (bm as { id: unknown }).id === "string") {
|
|
50
|
+
return { provider: String((bm as { provider: unknown }).provider), id: String((bm as { id: unknown }).id) };
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type RouteInput = {
|
|
56
|
+
difficulty?: Difficulty | string;
|
|
57
|
+
tiers?: TierMap;
|
|
58
|
+
baseModel?: { provider: string; id: string } | string | null;
|
|
59
|
+
// Convenience: pass config + runState instead of tiers+baseModel
|
|
60
|
+
config?: HarnessConfig;
|
|
61
|
+
runState?: { baseModel?: { provider: string; id: string } | string | null } | null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type RouteResult = { provider: string; id: string; tier: TierId; askedTier: import("./types.ts").TierId | null };
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve the asked model for one unit.
|
|
68
|
+
* Empty slot -> baseModel, never pi's default. Throws when no model can be resolved.
|
|
69
|
+
*/
|
|
70
|
+
export function routeModel(input: RouteInput): RouteResult {
|
|
71
|
+
const tiers: TierMap = input.tiers ?? (input.config ? tiersOf(input.config) : {});
|
|
72
|
+
const base = baseModelOf(input.runState ?? (input.baseModel ? { baseModel: input.baseModel } : null));
|
|
73
|
+
const diff = (input.difficulty as Difficulty | undefined) ?? undefined;
|
|
74
|
+
let tier: TierId | null = null;
|
|
75
|
+
if (diff && diff in DIFF_TO_TIER) tier = DIFF_TO_TIER[diff as Difficulty];
|
|
76
|
+
if (!tier) tier = "A"; // general work -> A
|
|
77
|
+
const spec: TierSpec | undefined = (tiers as Record<string, TierSpec | undefined>)[tier];
|
|
78
|
+
if (spec && spec.provider && spec.id) return { provider: spec.provider, id: spec.id, tier: tier!, askedTier: tier };
|
|
79
|
+
if (base) return { provider: base.provider, id: base.id, tier: tier!, askedTier: null };
|
|
80
|
+
throw new Error(`no model for tier ${tier}: tiers empty and no baseModel`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Unit-level difficulty for handoff buckets.
|
|
85
|
+
* When handoff is coarser than `task`, the bucket's hardest difficulty wins.
|
|
86
|
+
*/
|
|
87
|
+
export function effectiveDifficultyForTask(
|
|
88
|
+
plan: FeatureList,
|
|
89
|
+
taskId: string | { id?: string; key?: string; compositeKey?: string },
|
|
90
|
+
handoff?: string,
|
|
91
|
+
): Difficulty | undefined {
|
|
92
|
+
const needle = typeof taskId === "string" ? taskId : (taskId?.key ?? taskId?.compositeKey ?? taskId?.id ?? "");
|
|
93
|
+
const hh = (handoff ?? "task") as string;
|
|
94
|
+
// Find the task and its feature
|
|
95
|
+
let target: { task: import("./types.ts").Task; featureId: string; effectivePhase?: string } | null = null;
|
|
96
|
+
for (const f of plan.features ?? []) {
|
|
97
|
+
for (const t of f.tasks ?? []) {
|
|
98
|
+
const comp = t.key ?? `${f.id}/${t.id}`;
|
|
99
|
+
if (t.id === needle || t.key === needle || comp === needle) {
|
|
100
|
+
const featPhase = (f as { phase?: string }).phase as string | undefined;
|
|
101
|
+
const taskPhase = (t as { phase?: string }).phase as string | undefined;
|
|
102
|
+
const eff = taskPhase ?? featPhase ?? "build";
|
|
103
|
+
target = { task: t, featureId: f.id, effectivePhase: eff };
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (target) break;
|
|
108
|
+
}
|
|
109
|
+
if (!target) {
|
|
110
|
+
// Fallback: global hardest when target not found and handoff is coarse
|
|
111
|
+
if (hh === "off") return effectiveDifficultyForUnitFromTasks((plan.features ?? []).flatMap(f => f.tasks ?? []) as Array<{ difficulty?: Difficulty }>);
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
const own = (target.task as { difficulty?: Difficulty }).difficulty;
|
|
115
|
+
if (hh === "task" || hh === "subtask" || hh === "off") {
|
|
116
|
+
if (hh === "off") {
|
|
117
|
+
const all = (plan.features ?? []).flatMap(f => f.tasks ?? []) as Array<{ difficulty?: Difficulty }>;
|
|
118
|
+
return effectiveDifficultyForUnitFromTasks(all) ?? own;
|
|
119
|
+
}
|
|
120
|
+
return own;
|
|
121
|
+
}
|
|
122
|
+
let bucket: Array<{ difficulty?: Difficulty }> = [];
|
|
123
|
+
const all = (plan.features ?? []).flatMap(f => f.tasks ?? []) as Array<{ difficulty?: Difficulty } & { effectivePhase?: string; featureId?: string }>;
|
|
124
|
+
if (hh === "phase") {
|
|
125
|
+
const phase = target.effectivePhase ?? "build";
|
|
126
|
+
for (const f of plan.features ?? []) {
|
|
127
|
+
const fp = (f as { phase?: string }).phase as string | undefined;
|
|
128
|
+
for (const t of f.tasks ?? []) {
|
|
129
|
+
const tp = (t as { phase?: string }).phase as string | undefined;
|
|
130
|
+
const eff = tp ?? fp ?? "build";
|
|
131
|
+
if (eff === phase) bucket.push(t as { difficulty?: Difficulty });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
} else if (hh === "feature") {
|
|
135
|
+
const feat = plan.features.find(f => f.id === target!.featureId);
|
|
136
|
+
bucket = (feat?.tasks ?? []) as Array<{ difficulty?: Difficulty }>;
|
|
137
|
+
} else if (hh === "sprint") {
|
|
138
|
+
const feat = plan.features.find(f => f.id === target!.featureId) as { sprintId?: string } | undefined;
|
|
139
|
+
const sid = feat?.sprintId;
|
|
140
|
+
if (!sid) return own;
|
|
141
|
+
for (const f of plan.features ?? []) if ((f as { sprintId?: string }).sprintId === sid) bucket.push(...((f.tasks ?? []) as Array<{ difficulty?: Difficulty }>));
|
|
142
|
+
} else if (hh === "goal") {
|
|
143
|
+
// Simplify: goal = global hardest (goals span features via sprint)
|
|
144
|
+
const all2 = (plan.features ?? []).flatMap(f => f.tasks ?? []) as Array<{ difficulty?: Difficulty }>;
|
|
145
|
+
return effectiveDifficultyForUnitFromTasks(all2) ?? own;
|
|
146
|
+
}
|
|
147
|
+
return effectiveDifficultyForUnitFromTasks(bucket) ?? own;
|
|
148
|
+
}
|
|
149
|
+
|
package/src/core/paths.ts
CHANGED
|
@@ -46,6 +46,26 @@ export function featureListPath(targetDir: string): string {
|
|
|
46
46
|
return resolve(harnessDir(targetDir), "features", "feature-list.json");
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
export function planPath(targetDir: string): string {
|
|
50
|
+
return resolve(harnessDir(targetDir), "plan.json");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function daemonPath(targetDir: string): string {
|
|
54
|
+
return resolve(harnessDir(targetDir), "daemon.json");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function supervisorPath(targetDir: string): string {
|
|
58
|
+
return resolve(harnessDir(targetDir), "supervisor.json");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function activityPath(targetDir: string): string {
|
|
62
|
+
return resolve(harnessDir(targetDir), "activity.json");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function sessionsDir(targetDir: string): string {
|
|
66
|
+
return resolve(harnessDir(targetDir), "sessions");
|
|
67
|
+
}
|
|
68
|
+
|
|
49
69
|
export function progressPath(targetDir: string): string {
|
|
50
70
|
return resolve(harnessDir(targetDir), "progress.md");
|
|
51
71
|
}
|
|
@@ -124,6 +144,15 @@ export function agentDocPath(targetDir: string, role: string): string {
|
|
|
124
144
|
return resolve(docsDir(targetDir), "agents", `${role}.md`);
|
|
125
145
|
}
|
|
126
146
|
|
|
147
|
+
/** Per-worker worktree root; git worktree per concurrent worker when isolation=worktree. */
|
|
148
|
+
export function worktreesDir(targetDir: string): string {
|
|
149
|
+
return resolve(harnessDir(targetDir), "worktrees");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function worktreePath(targetDir: string, branch: string): string {
|
|
153
|
+
return resolve(worktreesDir(targetDir), branch.replace(/[^a-zA-Z0-9._-]/g, "-"));
|
|
154
|
+
}
|
|
155
|
+
|
|
127
156
|
/** Root for per-run worker isolation. Always inside the project, always ignorable. */
|
|
128
157
|
export function runRoot(targetDir: string): string {
|
|
129
158
|
return resolve(targetDir, "tmp", "infinity-harness");
|
package/src/core/plan.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infinity-harness — plan.ts (canonical) — re-export / wrapper around featureList.ts.
|
|
3
|
+
*
|
|
4
|
+
* v3 canonical path is `harness/plan.json`. The module `featureList.ts` already
|
|
5
|
+
* implements the canonical load/save with legacy fallback and stub handling;
|
|
6
|
+
* this file is the name the architecture calls "plan.ts" — one file, one name,
|
|
7
|
+
* matching the 5-level hierarchy.
|
|
8
|
+
*
|
|
9
|
+
* Keeping `featureList.ts` as the real implementation avoids churning every
|
|
10
|
+
* import in one commit; this shim means `import { loadPlan } from "./plan.ts"`
|
|
11
|
+
* and `import { loadFeatureList } from "./featureList.ts"` both work and
|
|
12
|
+
* point at the same truth.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
emptyFeatureList,
|
|
17
|
+
validateKey,
|
|
18
|
+
normalizeStatus,
|
|
19
|
+
normalizeSubtaskStatus,
|
|
20
|
+
isDone,
|
|
21
|
+
resolvePlanFile,
|
|
22
|
+
loadFeatureList,
|
|
23
|
+
loadFeatureList as loadPlan,
|
|
24
|
+
saveFeatureList,
|
|
25
|
+
saveFeatureList as savePlan,
|
|
26
|
+
flattenTasks,
|
|
27
|
+
findTask,
|
|
28
|
+
findFeature,
|
|
29
|
+
tasksForPhase,
|
|
30
|
+
featuresForPhase,
|
|
31
|
+
computeProgress,
|
|
32
|
+
nextActionableTask,
|
|
33
|
+
detectCycle,
|
|
34
|
+
MAX_TASKS,
|
|
35
|
+
MAX_DEPENDS_ON,
|
|
36
|
+
MAX_SUBJECT_LEN,
|
|
37
|
+
MAX_DESCRIPTION_LEN,
|
|
38
|
+
} from "./featureList.ts";
|
|
39
|
+
export type { LoadedFeatureList, FlatTask, Progress } from "./featureList.ts";
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infinity-harness — core/runState.ts (Core owns type + path, Daemon owns writes).
|
|
3
|
+
*
|
|
4
|
+
* v2.7's `src/runState.ts` had { armed, runId, startedAt, sessions, stoppedAt, stopReason }.
|
|
5
|
+
* v3 extends it with: baseModel, tiers (preflight results), budget (byTier UsageTotals + caps).
|
|
6
|
+
* Existing fields are kept; the file stays `harness/run.json`.
|
|
7
|
+
*
|
|
8
|
+
* Core owns the type, the path spelling, and the read helpers used by Interfaces.
|
|
9
|
+
* Daemon owns the writes (arm, heartbeat, budget, preflight) — but the types
|
|
10
|
+
* are here so Interfaces and Core can read the same truth without importing
|
|
11
|
+
* the Daemon.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { runStatePath } from "./paths.ts";
|
|
15
|
+
import { readJsonSafe, writeJsonAtomic, removeFile } from "./fsx.ts";
|
|
16
|
+
|
|
17
|
+
export type ProviderModel = { provider: string; id: string; thinkingLevel?: string };
|
|
18
|
+
|
|
19
|
+
export type TierPreflight = { provider: string; id: string; preflight: "ok" | "fail"; servedModel?: string; reason?: string };
|
|
20
|
+
|
|
21
|
+
export type TierResults = Partial<Record<"A" | "B" | "C" | "D" | "X", TierPreflight>>;
|
|
22
|
+
|
|
23
|
+
export type UsageTotals = { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number; calls: number };
|
|
24
|
+
|
|
25
|
+
export function createUsageTotals(): UsageTotals {
|
|
26
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, calls: 0 };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type Budget = {
|
|
30
|
+
byTier: Partial<Record<"A" | "B" | "C" | "D" | "X", UsageTotals>>;
|
|
31
|
+
cap: { totalTokens?: number | null; costUsd?: number | null; wallClockMs?: number | null };
|
|
32
|
+
stopOnExhaustion?: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type RunState = {
|
|
36
|
+
armed: boolean;
|
|
37
|
+
runId: string;
|
|
38
|
+
startedAt: string;
|
|
39
|
+
sessions: number;
|
|
40
|
+
stoppedAt: string | null;
|
|
41
|
+
stopReason: string | null;
|
|
42
|
+
/** Captured from ctx.model at arm time. The detached Daemon has no ctx. */
|
|
43
|
+
baseModel: ProviderModel | null;
|
|
44
|
+
/** Preflight outcome per tier. A failing tier blocks arming. */
|
|
45
|
+
tiers: TierResults;
|
|
46
|
+
/** Per-tier spend and caps. X outside consultation is a defect signal, not a budget. */
|
|
47
|
+
budget: Budget;
|
|
48
|
+
wallClockMs?: number;
|
|
49
|
+
escalation?: { level: string | null; since: string | null };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function newRunState(runId: string, now = new Date()): RunState {
|
|
53
|
+
return {
|
|
54
|
+
armed: true,
|
|
55
|
+
runId,
|
|
56
|
+
startedAt: now.toISOString(),
|
|
57
|
+
sessions: 1,
|
|
58
|
+
stoppedAt: null,
|
|
59
|
+
stopReason: null,
|
|
60
|
+
baseModel: null,
|
|
61
|
+
tiers: {},
|
|
62
|
+
budget: { byTier: {}, cap: {}, stopOnExhaustion: true },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function loadRunState(targetDir: string): RunState | null {
|
|
67
|
+
const raw = readJsonSafe<Record<string, unknown> | null>(runStatePath(targetDir), null);
|
|
68
|
+
if (!raw || typeof raw.runId !== "string" || !raw.runId) return null;
|
|
69
|
+
const sessions = typeof raw.sessions === "number" && (raw.sessions as number) > 0 ? (raw.sessions as number) : 1;
|
|
70
|
+
// Back-compat: older file had no baseModel/tiers/budget — treat as null/empty.
|
|
71
|
+
const baseModel = (() => {
|
|
72
|
+
const bm = (raw as Record<string, unknown>).baseModel;
|
|
73
|
+
if (!bm || typeof bm !== "object" || Array.isArray(bm)) return null;
|
|
74
|
+
const b = bm as Record<string, unknown>;
|
|
75
|
+
if (typeof b.provider === "string" && typeof b.id === "string") return { provider: String(b.provider), id: String(b.id), ...(typeof b.thinkingLevel === "string" ? { thinkingLevel: b.thinkingLevel } : {}) } as ProviderModel;
|
|
76
|
+
// legacy: baseModel was a string "provider/id"
|
|
77
|
+
if (typeof raw.baseModel === "string" && String(raw.baseModel).trim()) {
|
|
78
|
+
const s = String(raw.baseModel).trim();
|
|
79
|
+
const parts = s.split("/");
|
|
80
|
+
if (parts.length >= 2) return { provider: parts[0]!, id: parts.slice(1).join("/") };
|
|
81
|
+
return { provider: "anthropic", id: s };
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
})();
|
|
85
|
+
const tiersRaw = (raw as Record<string, unknown>).tiers;
|
|
86
|
+
const tiers: TierResults = (tiersRaw && typeof tiersRaw === "object" && !Array.isArray(tiersRaw) ? tiersRaw : {}) as TierResults;
|
|
87
|
+
const budgetRaw = (raw as Record<string, unknown>).budget;
|
|
88
|
+
const budget: Budget = (budgetRaw && typeof budgetRaw === "object" && !Array.isArray(budgetRaw)
|
|
89
|
+
? budgetRaw as Budget
|
|
90
|
+
: { byTier: {}, cap: {}, stopOnExhaustion: true });
|
|
91
|
+
if (!budget.byTier || typeof budget.byTier !== "object") budget.byTier = {};
|
|
92
|
+
if (!budget.cap || typeof budget.cap !== "object") budget.cap = {};
|
|
93
|
+
return {
|
|
94
|
+
armed: raw.armed === true,
|
|
95
|
+
runId: raw.runId as string,
|
|
96
|
+
startedAt: typeof raw.startedAt === "string" ? (raw.startedAt as string) : new Date(0).toISOString(),
|
|
97
|
+
sessions,
|
|
98
|
+
stoppedAt: typeof raw.stoppedAt === "string" ? (raw.stoppedAt as string) : null,
|
|
99
|
+
stopReason: typeof raw.stopReason === "string" ? (raw.stopReason as string) : null,
|
|
100
|
+
baseModel,
|
|
101
|
+
tiers,
|
|
102
|
+
budget,
|
|
103
|
+
...(typeof (raw as Record<string, unknown>).wallClockMs === "number" ? { wallClockMs: (raw as Record<string, unknown>).wallClockMs as number } : {}),
|
|
104
|
+
...(typeof (raw as Record<string, unknown>).escalation === "object" ? { escalation: (raw as Record<string, unknown>).escalation as RunState["escalation"] } : {}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function saveRunState(targetDir: string, state: RunState): void {
|
|
109
|
+
try { writeJsonAtomic(runStatePath(targetDir), state); } catch { /* run bookkeeping must not kill the session */ }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function armRun(targetDir: string, runId: string, now = new Date()): RunState {
|
|
113
|
+
const existing = loadRunState(targetDir);
|
|
114
|
+
const state = existing && existing.armed ? { ...existing, stoppedAt: null, stopReason: null } : newRunState(runId, now);
|
|
115
|
+
saveRunState(targetDir, state);
|
|
116
|
+
return state;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function disarmRun(targetDir: string, reason: string, now = new Date()): RunState | null {
|
|
120
|
+
const existing = loadRunState(targetDir);
|
|
121
|
+
if (!existing) return null;
|
|
122
|
+
const state: RunState = { ...existing, armed: false, stoppedAt: now.toISOString(), stopReason: reason };
|
|
123
|
+
saveRunState(targetDir, state);
|
|
124
|
+
return state;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function countSession(targetDir: string): RunState | null {
|
|
128
|
+
const existing = loadRunState(targetDir);
|
|
129
|
+
if (!existing) return null;
|
|
130
|
+
const state = { ...existing, sessions: existing.sessions + 1 };
|
|
131
|
+
saveRunState(targetDir, state);
|
|
132
|
+
return state;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function clearRunState(targetDir: string): void {
|
|
136
|
+
try { removeFile(runStatePath(targetDir)); } catch { /* nothing to clear */ }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function runIdFor(targetDir: string, fallback: string): string {
|
|
140
|
+
const state = loadRunState(targetDir);
|
|
141
|
+
return state && state.armed ? state.runId : fallback;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Parse a legacy baseModel string "provider/id" into a ProviderModel. */
|
|
145
|
+
export function parseBaseModelString(s: string | null | undefined): ProviderModel | null {
|
|
146
|
+
if (!s || !String(s).trim()) return null;
|
|
147
|
+
const str = String(s).trim();
|
|
148
|
+
const parts = str.split("/");
|
|
149
|
+
if (parts.length >= 2) return { provider: parts[0]!, id: parts.slice(1).join("/") };
|
|
150
|
+
return { provider: "anthropic", id: str };
|
|
151
|
+
}
|
package/src/core/settings.ts
CHANGED
|
@@ -188,6 +188,125 @@ export const SETTINGS: SettingsGroup[] = [
|
|
|
188
188
|
help: "A paused pipeline refuses to advance and stops the continuous run.",
|
|
189
189
|
type: { kind: "boolean" },
|
|
190
190
|
},
|
|
191
|
+
{
|
|
192
|
+
path: "pilot",
|
|
193
|
+
file: "config",
|
|
194
|
+
label: "Pilot",
|
|
195
|
+
help: "Run-level preset over per-phase modes. copilot every phase stops, autopilot builds without you, full is hands-off intake->SHIP.",
|
|
196
|
+
type: { kind: "choice", choices: ["copilot", "autopilot", "full"] },
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
path: "tiers.A.provider",
|
|
200
|
+
file: "config",
|
|
201
|
+
label: "Tier A provider",
|
|
202
|
+
help: "Provider for general harness work (orchestration, short-lived A workers).",
|
|
203
|
+
type: { kind: "text", placeholder: "anthropic", allowEmpty: true },
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
path: "tiers.A.id",
|
|
207
|
+
file: "config",
|
|
208
|
+
label: "Tier A model",
|
|
209
|
+
help: "Model id for tier A general work.",
|
|
210
|
+
type: { kind: "text", placeholder: "claude-sonnet-4-5", allowEmpty: true },
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
path: "tiers.B.provider",
|
|
214
|
+
file: "config",
|
|
215
|
+
label: "Tier B provider",
|
|
216
|
+
help: "Provider for easy tasks.",
|
|
217
|
+
type: { kind: "text", placeholder: "anthropic", allowEmpty: true },
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
path: "tiers.B.id",
|
|
221
|
+
file: "config",
|
|
222
|
+
label: "Tier B model",
|
|
223
|
+
help: "Model id for B (easy). Empty means use baseModel X.",
|
|
224
|
+
type: { kind: "text", placeholder: "claude-sonnet-4-5", allowEmpty: true },
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
path: "tiers.C.provider",
|
|
228
|
+
file: "config",
|
|
229
|
+
label: "Tier C provider",
|
|
230
|
+
help: "Provider for moderate tasks.",
|
|
231
|
+
type: { kind: "text", placeholder: "anthropic", allowEmpty: true },
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
path: "tiers.C.id",
|
|
235
|
+
file: "config",
|
|
236
|
+
label: "Tier C model",
|
|
237
|
+
help: "Model id for C (moderate).",
|
|
238
|
+
type: { kind: "text", placeholder: "claude-opus-4-5", allowEmpty: true },
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
path: "tiers.D.provider",
|
|
242
|
+
file: "config",
|
|
243
|
+
label: "Tier D provider",
|
|
244
|
+
help: "Provider for difficult tasks.",
|
|
245
|
+
type: { kind: "text", placeholder: "anthropic", allowEmpty: true },
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
path: "tiers.D.id",
|
|
249
|
+
file: "config",
|
|
250
|
+
label: "Tier D model",
|
|
251
|
+
help: "Model id for D (difficult).",
|
|
252
|
+
type: { kind: "text", placeholder: "claude-opus-4-5", allowEmpty: true },
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
path: "tiers.X.provider",
|
|
256
|
+
file: "config",
|
|
257
|
+
label: "Tier X provider",
|
|
258
|
+
help: "Provider for consultation / escalation (strongest).",
|
|
259
|
+
type: { kind: "text", placeholder: "anthropic", allowEmpty: true },
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
path: "tiers.X.id",
|
|
263
|
+
file: "config",
|
|
264
|
+
label: "Tier X model",
|
|
265
|
+
help: "Model id for X (consultation).",
|
|
266
|
+
type: { kind: "text", placeholder: "claude-opus-4-5", allowEmpty: true },
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
path: "limits.unitWallClockMs",
|
|
270
|
+
file: "config",
|
|
271
|
+
label: "Unit wall-clock ms",
|
|
272
|
+
help: "How long one worker may run before abort + dispose. Default 30m.",
|
|
273
|
+
type: { kind: "number", min: 60000, max: 7200000, unit: "ms" },
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
path: "limits.maxRecycles",
|
|
277
|
+
file: "config",
|
|
278
|
+
label: "Max recycles",
|
|
279
|
+
help: "How many compaction recycles one unit tolerates before stopping. Default 2.",
|
|
280
|
+
type: { kind: "number", min: 0, max: 10 },
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
path: "limits.maxReworkPerUnit",
|
|
284
|
+
file: "config",
|
|
285
|
+
label: "Max rework per unit",
|
|
286
|
+
help: "How many times a unit may go back to rework. Default 2.",
|
|
287
|
+
type: { kind: "number", min: 0, max: 10 },
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
path: "limits.maxReplansPerPhase",
|
|
291
|
+
file: "config",
|
|
292
|
+
label: "Max replans per phase",
|
|
293
|
+
help: "How many plan mutations a phase may have. Default 3.",
|
|
294
|
+
type: { kind: "number", min: 0, max: 10 },
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
path: "limits.tokenCap",
|
|
298
|
+
file: "config",
|
|
299
|
+
label: "Token cap",
|
|
300
|
+
help: "Total tokens before the run stops. Null means unconstrained.",
|
|
301
|
+
type: { kind: "number", min: 1000, max: 100000000 },
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
path: "limits.costCap",
|
|
305
|
+
file: "config",
|
|
306
|
+
label: "Cost cap USD",
|
|
307
|
+
help: "Spend ceiling across A/B/C/D/X. Null means unconstrained.",
|
|
308
|
+
type: { kind: "number", min: 0, max: 100000 },
|
|
309
|
+
},
|
|
191
310
|
],
|
|
192
311
|
},
|
|
193
312
|
{
|
|
@@ -375,22 +494,36 @@ export const SETTINGS: SettingsGroup[] = [
|
|
|
375
494
|
{
|
|
376
495
|
id: "execution",
|
|
377
496
|
label: "Execution",
|
|
378
|
-
help: "
|
|
497
|
+
help: "Who does the work, how many at once, and at which level. The session you are typing in is a control panel; background workers do the real work.",
|
|
379
498
|
settings: [
|
|
499
|
+
{
|
|
500
|
+
path: "execution.engine",
|
|
501
|
+
file: "config",
|
|
502
|
+
label: "Where work runs",
|
|
503
|
+
help: "background: every unit of work runs in its own pi session with its own model, and this session spends no tokens on it (recommended) · main-session: the old behaviour, everything runs here on your model.",
|
|
504
|
+
type: { kind: "choice", choices: ["background", "main-session"] },
|
|
505
|
+
},
|
|
380
506
|
{
|
|
381
507
|
path: "execution.parallelAt",
|
|
382
508
|
file: "config",
|
|
383
509
|
label: "Parallel at",
|
|
384
|
-
help: "
|
|
510
|
+
help: "The plan-phase for parallel work. The background engine runs one worker until worktrees land, so this is only the future parallelism knob.",
|
|
385
511
|
type: { kind: "choice", choices: ["off", "goal", "phase", "sprint", "feature", "task", "subtask"] },
|
|
386
512
|
},
|
|
387
513
|
{
|
|
388
514
|
path: "execution.maxWorkers",
|
|
389
515
|
file: "config",
|
|
390
516
|
label: "Max workers",
|
|
391
|
-
help: "
|
|
517
|
+
help: "Hard cap on concurrent workers (1..16). Guarded by lock and budget; 1 until worktrees exist. Effective from Daemon.",
|
|
392
518
|
type: { kind: "number", min: 1, max: 16 },
|
|
393
519
|
},
|
|
520
|
+
{
|
|
521
|
+
path: "execution.isolation",
|
|
522
|
+
file: "config",
|
|
523
|
+
label: "Isolation",
|
|
524
|
+
help: "How concurrent workers stay out of each others way. none forces maxWorkers=1 and no worktree.",
|
|
525
|
+
type: { kind: "choice", choices: ["worktree", "none"] },
|
|
526
|
+
},
|
|
394
527
|
],
|
|
395
528
|
},
|
|
396
529
|
{
|
|
@@ -458,7 +591,7 @@ export const SETTINGS: SettingsGroup[] = [
|
|
|
458
591
|
path: "gates.antiPlaceholder.enabled",
|
|
459
592
|
file: "config",
|
|
460
593
|
label: "Reject placeholders",
|
|
461
|
-
help: "Fail the gate
|
|
594
|
+
help: "Fail the gate when source still has unfinished markers.",
|
|
462
595
|
type: { kind: "boolean" },
|
|
463
596
|
},
|
|
464
597
|
],
|
|
@@ -674,6 +807,7 @@ export function writeSetting(targetDir: string, setting: Setting, value: unknown
|
|
|
674
807
|
|
|
675
808
|
/** How a value is shown in the menu. Empty model slots read as inherited. */
|
|
676
809
|
export function formatValue(setting: Setting, value: unknown): string {
|
|
810
|
+
if (setting.path === "execution.isolation") return typeof value === "string" && value.trim() ? value : "worktree";
|
|
677
811
|
switch (setting.type.kind) {
|
|
678
812
|
case "boolean":
|
|
679
813
|
return value ? "on" : "off";
|
package/src/core/types.ts
CHANGED
|
@@ -74,6 +74,22 @@ export type SubtaskStatus = (typeof SUBTASK_STATUSES)[number];
|
|
|
74
74
|
|
|
75
75
|
export type Difficulty = "easy" | "moderate" | "difficult";
|
|
76
76
|
|
|
77
|
+
export type PilotMode = "copilot" | "autopilot" | "full";
|
|
78
|
+
|
|
79
|
+
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
80
|
+
|
|
81
|
+
export type TierSpec = {
|
|
82
|
+
provider: string;
|
|
83
|
+
id: string;
|
|
84
|
+
thinkingLevel?: ThinkingLevel | "";
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export type TierId = "A" | "B" | "C" | "D" | "X";
|
|
88
|
+
|
|
89
|
+
export type TierMap = Partial<Record<TierId, TierSpec>>;
|
|
90
|
+
|
|
91
|
+
export type ExecutionIsolation = "worktree" | "none";
|
|
92
|
+
|
|
77
93
|
// ── Feature list (the SSOT on disk) ─────────────────────────────────────────
|
|
78
94
|
|
|
79
95
|
export type Subtask = {
|
|
@@ -95,6 +111,8 @@ export type Task = {
|
|
|
95
111
|
difficulty?: Difficulty;
|
|
96
112
|
modelHint?: string;
|
|
97
113
|
criteria?: string[];
|
|
114
|
+
/** When true this task must run alone — whole-tree isolation (lockfile regen, codemod). */
|
|
115
|
+
serialize?: boolean;
|
|
98
116
|
/** Free-form extras are preserved verbatim on round-trip. */
|
|
99
117
|
[k: string]: unknown;
|
|
100
118
|
};
|
|
@@ -168,12 +186,30 @@ export type RetryLevel = (typeof RETRY_LEVELS)[number];
|
|
|
168
186
|
export type HandoffGranularity = "off" | "goal" | "phase" | "sprint" | "feature" | "task" | "subtask";
|
|
169
187
|
|
|
170
188
|
export type ExecutionPolicy = {
|
|
189
|
+
/**
|
|
190
|
+
* Who actually does the work.
|
|
191
|
+
*
|
|
192
|
+
* `background` — the default, and the reason this harness exists. Each unit
|
|
193
|
+
* of work runs in its own `pi` process with its own model and its own
|
|
194
|
+
* context window; the session the human is typing into stays a control
|
|
195
|
+
* panel and spends no tokens on the run.
|
|
196
|
+
*
|
|
197
|
+
* `main-session` — the pre-2.7 behaviour, kept because a run on a machine
|
|
198
|
+
* that cannot spawn a second pi still has to be able to work. Everything
|
|
199
|
+
* happens in the human's session, on the human's model.
|
|
200
|
+
*/
|
|
201
|
+
engine: ExecutionEngine;
|
|
171
202
|
/** Level at which parallel work is allowed. `off` = one task at a time. */
|
|
172
203
|
parallelAt: HandoffGranularity;
|
|
173
204
|
/** Max parallel workers (1..16). Guarded by lock and budget. */
|
|
174
205
|
maxWorkers: number;
|
|
206
|
+
/** How concurrent workers stay out of each other's way. */
|
|
207
|
+
isolation: ExecutionIsolation;
|
|
175
208
|
};
|
|
176
209
|
|
|
210
|
+
export type ExecutionEngine = "background" | "main-session";
|
|
211
|
+
export const EXECUTION_ENGINES: readonly ExecutionEngine[] = ["background", "main-session"] as const;
|
|
212
|
+
|
|
177
213
|
export type SessionPolicy = {
|
|
178
214
|
/**
|
|
179
215
|
* When to hand off to a fresh session.
|
|
@@ -303,6 +339,19 @@ export type HarnessConfig = {
|
|
|
303
339
|
workflow: { id: string; name: string } | null;
|
|
304
340
|
display: DisplayPolicy;
|
|
305
341
|
intake: IntakeState;
|
|
342
|
+
/** Run-level pilot preset over per-phase modes. `full` = hands-off intake→SHIP. */
|
|
343
|
+
pilot: PilotMode;
|
|
344
|
+
/** Tier definitions A/B/C/D/X. Each is provider+id+thinking. Empty means use baseModel. */
|
|
345
|
+
tiers: TierMap;
|
|
346
|
+
/** Global caps that bound a run. */
|
|
347
|
+
limits: {
|
|
348
|
+
unitWallClockMs: number;
|
|
349
|
+
maxRecycles: number;
|
|
350
|
+
maxReworkPerUnit: number;
|
|
351
|
+
maxReplansPerPhase: number;
|
|
352
|
+
tokenCap: number | null;
|
|
353
|
+
costCap: number | null;
|
|
354
|
+
};
|
|
306
355
|
/** Set when a gate passed but the phase needs a human signature first. */
|
|
307
356
|
awaitingApproval: Phase | null;
|
|
308
357
|
/** Budgets that bound an unattended continuous run. See src/loop.ts. */
|