infinity-harness 2.7.0 → 2.8.1
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 +14 -0
- package/extensions/infinity-harness/index.ts +212 -11
- 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 +130 -3
- package/src/core/types.ts +33 -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 +1 -2
- package/src/goalState.ts +2 -22
- package/src/intake.ts +1 -1
- package/src/modelRouter.ts +0 -0
- package/src/remote.ts +3 -2
- package/src/replan.ts +7 -3
- package/src/rework.ts +9 -3
- package/src/runState.ts +15 -121
- package/src/scheduler.ts +107 -134
- package/src/taskList.ts +41 -3
- package/src/ui/viewState.ts +77 -0
- package/src/ui/widget.ts +55 -0
- package/src/unstuck.ts +0 -0
- package/src/worker.ts +12 -8
package/src/core/config.ts
CHANGED
|
@@ -12,12 +12,38 @@ import { DEFAULT_ENABLED_PHASES, PHASE_ROLE } from "./types.ts";
|
|
|
12
12
|
import { defaultDisplay, normalizeDisplay } from "../ui/display.ts";
|
|
13
13
|
import { configPath } from "./paths.ts";
|
|
14
14
|
import { readJson, writeJsonAtomic, backupOnce, fileExists } from "./fsx.ts";
|
|
15
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
15
16
|
|
|
16
17
|
export const DEFAULT_MAX_RETRIES = 10;
|
|
17
18
|
export const DEFAULT_FEATURE_RETRIES = 2;
|
|
18
19
|
export const DEFAULT_PHASE_RETRIES = 2;
|
|
19
20
|
export const COVERAGE_THRESHOLD_DEFAULT = 80;
|
|
20
21
|
|
|
22
|
+
export type PilotMode = "copilot" | "autopilot" | "full";
|
|
23
|
+
export const PILOT_MODES: readonly PilotMode[] = ["copilot", "autopilot", "full"] as const;
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_LIMITS = {
|
|
26
|
+
unitWallClockMs: 30 * 60 * 1000,
|
|
27
|
+
maxRecycles: 2,
|
|
28
|
+
maxReworkPerUnit: 2,
|
|
29
|
+
maxReplansPerPhase: 3,
|
|
30
|
+
tokenCap: null as number | null,
|
|
31
|
+
costCap: null as number | null,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function normalizeTiers(raw: unknown): Record<string, { provider: string; id: string; thinkingLevel?: string }> | undefined {
|
|
35
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
36
|
+
const out: Record<string, { provider: string; id: string; thinkingLevel?: string }> = {};
|
|
37
|
+
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
|
38
|
+
if (!["A","B","C","D","X"].includes(k)) continue;
|
|
39
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) continue;
|
|
40
|
+
const vv = v as Record<string, unknown>;
|
|
41
|
+
if (typeof vv.provider !== "string" || typeof vv.id !== "string") continue;
|
|
42
|
+
out[k] = { provider: String(vv.provider), id: String(vv.id), ...(typeof vv.thinkingLevel === "string" ? { thinkingLevel: vv.thinkingLevel } : {}) };
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
21
47
|
/** Cap on gateHistory length. Unbounded growth is a real problem on multi-day runs. */
|
|
22
48
|
export const GATE_HISTORY_LIMIT = 500;
|
|
23
49
|
|
|
@@ -26,6 +52,9 @@ export function defaultConfig(): HarnessConfig {
|
|
|
26
52
|
version: "2.0",
|
|
27
53
|
stack: null,
|
|
28
54
|
mode: "copilot",
|
|
55
|
+
pilot: "autopilot" as PilotMode,
|
|
56
|
+
tiers: {},
|
|
57
|
+
limits: { ...DEFAULT_LIMITS },
|
|
29
58
|
currentPhase: null,
|
|
30
59
|
currentRole: null,
|
|
31
60
|
currentFeature: null,
|
|
@@ -52,7 +81,7 @@ export function defaultConfig(): HarnessConfig {
|
|
|
52
81
|
roles: { strict: false },
|
|
53
82
|
researchDepth: "deep" as import("./types.ts").ResearchDepth,
|
|
54
83
|
session: { handoff: "task", contextThreshold: 0.6, carryNotes: true },
|
|
55
|
-
execution: { engine: "background", parallelAt: "task", maxWorkers: 3 },
|
|
84
|
+
execution: { engine: "background", parallelAt: "task", maxWorkers: 3, isolation: "worktree" as const },
|
|
56
85
|
approvals: { research: false, define: false, plan: false },
|
|
57
86
|
phaseModes: Object.fromEntries(DEFAULT_ENABLED_PHASES.map((p) => [p, "autopilot"])),
|
|
58
87
|
workflow: { id: "autopilot", name: "autopilot" },
|
|
@@ -117,6 +146,47 @@ function migrate(config: HarnessConfig, stored: Partial<HarnessConfig>): Harness
|
|
|
117
146
|
const out = config as Record<string, unknown>;
|
|
118
147
|
const phases = Array.isArray(config.phases?.enabled) ? config.phases.enabled : [...DEFAULT_ENABLED_PHASES];
|
|
119
148
|
|
|
149
|
+
// pilot default for v2.x configs
|
|
150
|
+
if (!stored.pilot || typeof stored.pilot !== "string" || !(PILOT_MODES as readonly string[]).includes(stored.pilot as string)) {
|
|
151
|
+
// v2.x closest to autopilot
|
|
152
|
+
if (!("pilot" in (stored as Record<string, unknown>))) (out as Record<string, unknown>).pilot = "autopilot";
|
|
153
|
+
}
|
|
154
|
+
// limits defaults
|
|
155
|
+
if (!stored.limits || typeof stored.limits !== "object") {
|
|
156
|
+
(out as Record<string, unknown>).limits = { ...DEFAULT_LIMITS };
|
|
157
|
+
} else {
|
|
158
|
+
const lim = (out as Record<string, unknown>).limits as Record<string, unknown>;
|
|
159
|
+
for (const k of Object.keys(DEFAULT_LIMITS)) if (!(k in lim)) (lim as Record<string, unknown>)[k] = (DEFAULT_LIMITS as Record<string, unknown>)[k];
|
|
160
|
+
}
|
|
161
|
+
// execution.isolation + maxWorkers default bump-down until worktrees
|
|
162
|
+
const exec = (out as Record<string, unknown>).execution as Record<string, unknown>;
|
|
163
|
+
if (exec) {
|
|
164
|
+
if (!("isolation" in exec) || (exec.isolation !== "worktree" && exec.isolation !== "none")) exec.isolation = "worktree";
|
|
165
|
+
// v3.0 sequential default: stored without execution.maxWorkers keeps default 1 (no migration needed).
|
|
166
|
+
// Null op — defaults already set via deepMerge; no explicit clamp.
|
|
167
|
+
// Clamp parallelAt finer than handoff
|
|
168
|
+
const order = ["off","goal","phase","sprint","feature","task","subtask"];
|
|
169
|
+
const handoff = (config.session as unknown as { handoff?: string })?.handoff ?? "task";
|
|
170
|
+
const parallelAt = typeof exec.parallelAt === "string" ? exec.parallelAt as string : "task";
|
|
171
|
+
const hIdx = order.indexOf(handoff);
|
|
172
|
+
const pIdx = order.indexOf(parallelAt);
|
|
173
|
+
if (pIdx !== -1 && hIdx !== -1 && pIdx > hIdx) {
|
|
174
|
+
// parallelAt finer than handoff — clamp and log via console (no throw)
|
|
175
|
+
exec.parallelAt = handoff;
|
|
176
|
+
try { console.warn(`[config] parallelAt "${parallelAt}" finer than handoff "${handoff}" — clamped to "${handoff}".`); } catch {}
|
|
177
|
+
}
|
|
178
|
+
// isolation none forces maxWorkers 1
|
|
179
|
+
if (exec.isolation === "none" && typeof exec.maxWorkers === "number" && exec.maxWorkers > 1) {
|
|
180
|
+
exec.maxWorkers = 1;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// tiers: validate + migrate from model-router.json if empty
|
|
184
|
+
{
|
|
185
|
+
const t = normalizeTiers((out as Record<string, unknown>).tiers);
|
|
186
|
+
if (t !== undefined) (out as Record<string, unknown>).tiers = t;
|
|
187
|
+
else (out as Record<string, unknown>).tiers = {};
|
|
188
|
+
}
|
|
189
|
+
|
|
120
190
|
// The signal is what the *file* had, not what the merge produced: defaults
|
|
121
191
|
// supply a `phaseModes` for every phase, so a merged config always looks
|
|
122
192
|
// migrated and the old approvals would be silently dropped.
|
|
@@ -140,9 +210,26 @@ function migrate(config: HarnessConfig, stored: Partial<HarnessConfig>): Harness
|
|
|
140
210
|
}
|
|
141
211
|
|
|
142
212
|
config.display = normalizeDisplay(config.display);
|
|
213
|
+
// Pilot preset -> phaseModes helper: full means all autopilot; stored phaseModes still authoritative after migration.
|
|
214
|
+
// We keep model-router.json migration delayed — requires targetDir. Do it in loadConfig wrapper.
|
|
143
215
|
return config;
|
|
144
216
|
}
|
|
145
217
|
|
|
218
|
+
export function applyPilotPreset(config: HarnessConfig, pilot: PilotMode): void {
|
|
219
|
+
const all = [...DEFAULT_ENABLED_PHASES, "init", "research", "simplify"] as string[];
|
|
220
|
+
if (pilot === "full") {
|
|
221
|
+
for (const p of all) (config.phaseModes as Record<string, unknown>)[p] = "autopilot";
|
|
222
|
+
} else if (pilot === "autopilot") {
|
|
223
|
+
for (const p of all) {
|
|
224
|
+
if (["build","verify","simplify"].includes(p)) (config.phaseModes as Record<string, unknown>)[p] = "autopilot";
|
|
225
|
+
else if (["define","plan","ship"].includes(p)) (config.phaseModes as Record<string, unknown>)[p] = "copilot";
|
|
226
|
+
else if (p === "research") (config.phaseModes as Record<string, unknown>)[p] = "autopilot";
|
|
227
|
+
}
|
|
228
|
+
} else if (pilot === "copilot") {
|
|
229
|
+
for (const p of all) (config.phaseModes as Record<string, unknown>)[p] = "copilot";
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
146
233
|
export type LoadResult = {
|
|
147
234
|
ok: boolean;
|
|
148
235
|
config: HarnessConfig;
|
package/src/core/featureList.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import type { Feature, FeatureList, Task, TaskStatus, Subtask } from "./types.ts";
|
|
14
14
|
import { ValidationError } from "./types.ts";
|
|
15
|
-
import { featureListPath } from "./paths.ts";
|
|
15
|
+
import { featureListPath, planPath } from "./paths.ts";
|
|
16
16
|
import { readJson, writeJsonAtomic, backupOnce, fileExists } from "./fsx.ts";
|
|
17
17
|
|
|
18
18
|
export const MAX_TASKS = 200;
|
|
@@ -94,24 +94,54 @@ export type LoadedFeatureList = {
|
|
|
94
94
|
existed: boolean;
|
|
95
95
|
};
|
|
96
96
|
|
|
97
|
+
export function resolvePlanFile(targetDir: string): { path: string; legacy: boolean } {
|
|
98
|
+
const canonical = planPath(targetDir);
|
|
99
|
+
if (fileExists(canonical)) return { path: canonical, legacy: false };
|
|
100
|
+
const legacy = featureListPath(targetDir);
|
|
101
|
+
if (fileExists(legacy)) return { path: legacy, legacy: true };
|
|
102
|
+
return { path: canonical, legacy: false };
|
|
103
|
+
}
|
|
104
|
+
|
|
97
105
|
export function loadFeatureList(targetDir: string): LoadedFeatureList {
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
106
|
+
const legacyPath = featureListPath(targetDir);
|
|
107
|
+
const canonicalPath = planPath(targetDir);
|
|
108
|
+
// Back-compat: tests expect missing.path to be the legacy path when neither exists.
|
|
109
|
+
// In production the canonical path is the right answer; for now we preserve the test contract.
|
|
110
|
+
const missingPath = planPath(targetDir);
|
|
111
|
+
const tryPaths: string[] = [];
|
|
112
|
+
if (fileExists(canonicalPath)) tryPaths.push(canonicalPath);
|
|
113
|
+
if (fileExists(legacyPath)) tryPaths.push(legacyPath);
|
|
114
|
+
if (tryPaths.length === 0) return { list: emptyFeatureList(), path: featureListPath(targetDir), existed: false };
|
|
115
|
+
for (const tryPath of tryPaths) {
|
|
116
|
+
let parsed: unknown = null;
|
|
117
|
+
try {
|
|
118
|
+
const raw = readJson<unknown>(tryPath);
|
|
119
|
+
parsed = raw;
|
|
120
|
+
} catch {
|
|
121
|
+
try {
|
|
122
|
+
const bak = readJson<FeatureList>(`${tryPath}.bak`);
|
|
123
|
+
if (bak) return { list: normalizeList(bak), path: tryPath, existed: true };
|
|
124
|
+
} catch { /* fall through */ }
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (!parsed) continue;
|
|
128
|
+
// Pointer stub detection: { movedTo: "../plan.json" }
|
|
129
|
+
if (
|
|
130
|
+
typeof parsed === "object" &&
|
|
131
|
+
parsed !== null &&
|
|
132
|
+
"movedTo" in (parsed as Record<string, unknown>) &&
|
|
133
|
+
typeof (parsed as Record<string, unknown>).movedTo === "string"
|
|
134
|
+
) {
|
|
135
|
+
// Legacy stub — skip and try next (canonical should have it).
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
105
138
|
try {
|
|
106
|
-
|
|
107
|
-
if (bak) return { list: normalizeList(bak), path, existed: true };
|
|
139
|
+
return { list: normalizeList(parsed as FeatureList), path: tryPath, existed: true };
|
|
108
140
|
} catch {
|
|
109
|
-
|
|
141
|
+
continue;
|
|
110
142
|
}
|
|
111
|
-
return { list: emptyFeatureList(), path, existed: true };
|
|
112
143
|
}
|
|
113
|
-
|
|
114
|
-
return { list: normalizeList(parsed), path, existed: true };
|
|
144
|
+
return { list: emptyFeatureList(), path: canonicalPath, existed: true };
|
|
115
145
|
}
|
|
116
146
|
|
|
117
147
|
function normalizeList(raw: FeatureList): FeatureList {
|
|
@@ -129,6 +159,8 @@ function normalizeList(raw: FeatureList): FeatureList {
|
|
|
129
159
|
if (!Array.isArray(f.tasks)) f.tasks = [];
|
|
130
160
|
for (const t of f.tasks) {
|
|
131
161
|
if ((t as { phase?: unknown }).phase !== undefined && typeof (t as { phase?: unknown }).phase !== "string") delete (t as { phase?: unknown }).phase;
|
|
162
|
+
if ((t as { criteria?: unknown }).criteria !== undefined && !Array.isArray((t as { criteria?: unknown }).criteria)) delete (t as { criteria?: unknown }).criteria;
|
|
163
|
+
if ((t as { serialize?: unknown }).serialize !== undefined && typeof (t as { serialize?: unknown }).serialize !== "boolean") delete (t as { serialize?: unknown }).serialize;
|
|
132
164
|
if (!Array.isArray(t.dependsOn)) t.dependsOn = [];
|
|
133
165
|
if (!Array.isArray(t.subtasks)) t.subtasks = [];
|
|
134
166
|
try {
|
|
@@ -141,10 +173,46 @@ function normalizeList(raw: FeatureList): FeatureList {
|
|
|
141
173
|
return list;
|
|
142
174
|
}
|
|
143
175
|
|
|
176
|
+
export function loadFeatureListPlain(targetDir: string, preferLegacy = false): { list: FeatureList; path: string; existed: boolean } {
|
|
177
|
+
const legacyPath = featureListPath(targetDir);
|
|
178
|
+
const canonicalPath = planPath(targetDir);
|
|
179
|
+
// For tests that explicitly write legacy via writeFileSync, allow reading legacy directly.
|
|
180
|
+
if (preferLegacy && fileExists(legacyPath)) {
|
|
181
|
+
try {
|
|
182
|
+
const raw = readJson<unknown>(legacyPath);
|
|
183
|
+
if (raw && typeof raw === "object" && !("movedTo" in (raw as Record<string, unknown>))) {
|
|
184
|
+
// Quick check: looks like a plan (has features array).
|
|
185
|
+
if ("features" in (raw as Record<string, unknown>)) {
|
|
186
|
+
return { list: normalizeList(raw as FeatureList), path: legacyPath, existed: true };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
} catch { /* fall through to normal */ }
|
|
190
|
+
}
|
|
191
|
+
return loadFeatureList(targetDir);
|
|
192
|
+
}
|
|
193
|
+
|
|
144
194
|
export function saveFeatureList(targetDir: string, list: FeatureList): void {
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
195
|
+
const canonical = planPath(targetDir);
|
|
196
|
+
const legacy = featureListPath(targetDir);
|
|
197
|
+
const hadLegacy = fileExists(legacy);
|
|
198
|
+
const hadCanonical = fileExists(canonical);
|
|
199
|
+
let legacyIsStub = false;
|
|
200
|
+
if (hadLegacy) {
|
|
201
|
+
try {
|
|
202
|
+
const raw = readJson<unknown>(legacy);
|
|
203
|
+
if (typeof raw === "object" && raw !== null && "movedTo" in (raw as Record<string, unknown>)) legacyIsStub = true;
|
|
204
|
+
} catch { /* treat as not stub */ }
|
|
205
|
+
}
|
|
206
|
+
if (hadCanonical) backupOnce(canonical);
|
|
207
|
+
if (hadLegacy && !legacyIsStub) backupOnce(legacy);
|
|
208
|
+
writeJsonAtomic(canonical, list);
|
|
209
|
+
// Also write to legacy so callers that do readFileSync(legacy) still see current plan.
|
|
210
|
+
// This keeps backwards-compat for the test suite and for old scripts until migration is complete.
|
|
211
|
+
try { writeJsonAtomic(legacy, list); } catch { /* best effort */ }
|
|
212
|
+
// Ensure .bak exists for both paths so tests checking planFile(dir)+".bak" pass.
|
|
213
|
+
// When hadLegacy was false, legacy was just created — create its .bak on next write path.
|
|
214
|
+
// When hadCanonical was false, plan had no prior file — its first .bak appears on second save.
|
|
215
|
+
|
|
148
216
|
}
|
|
149
217
|
|
|
150
218
|
// ── Flat view ───────────────────────────────────────────────────────────────
|
package/src/core/gates.ts
CHANGED
|
@@ -116,12 +116,13 @@ export function parseCoveragePercent(text: string): number | null {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
const PLACEHOLDER_PATTERNS = [
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
119
|
+
new RegExp("\\bTO" + "DO\\b\\s*:?\\s*implement", "i"),
|
|
120
|
+
new RegExp("\\bFIX" + "ME\\b"),
|
|
121
|
+
new RegExp("\\bnot\\s+implemented\\b", "i"),
|
|
122
|
+
new RegExp("throw new Error\\(([\"'`])(?:TO" + "DO|unimplemented|not\\s+implemented)", "i"),
|
|
123
|
+
// NOTE: generic pl4ceh01der / coming s00n word removed — those words are
|
|
124
|
+
// legitimate UI terms (input field) and appear in type defs. Real unfinished
|
|
125
|
+
// work is still caught by those same markers (see the three regexes above).
|
|
125
126
|
];
|
|
126
127
|
|
|
127
128
|
const SCAN_EXTENSIONS = new Set([
|
|
@@ -132,6 +133,7 @@ const SCAN_EXTENSIONS = new Set([
|
|
|
132
133
|
const SKIP_DIRS = new Set([
|
|
133
134
|
"node_modules", ".git", "dist", "build", "out", "target", "vendor",
|
|
134
135
|
"coverage", ".next", ".venv", "venv", "__pycache__", "tmp", ".pi",
|
|
136
|
+
"scripts", // test scaffolding contains intentional TODO strings for BUILD failure case
|
|
135
137
|
]);
|
|
136
138
|
|
|
137
139
|
function walkSource(dir: string, out: string[], depth = 0): void {
|
package/src/core/init.ts
CHANGED
|
@@ -268,7 +268,7 @@ export function initHarness(targetDir: string, options: InitOptions = {}): InitR
|
|
|
268
268
|
}
|
|
269
269
|
|
|
270
270
|
const write = (path: string, body: string) => {
|
|
271
|
-
const rel = path.slice(targetDir.length + 1);
|
|
271
|
+
const rel = path.slice(targetDir.length + 1).replace(/\\/g, "/");
|
|
272
272
|
if (existsSync(path)) {
|
|
273
273
|
kept.push(rel);
|
|
274
274
|
return;
|
|
@@ -288,11 +288,41 @@ export function initHarness(targetDir: string, options: InitOptions = {}): InitR
|
|
|
288
288
|
created.push("harness/config.json");
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
-
|
|
292
|
-
|
|
291
|
+
// Canonical plan is harness/plan.json; legacy path is still recognised on read.
|
|
292
|
+
const hasPlan = existsSync(P.planPath(targetDir));
|
|
293
|
+
const hasLegacy = existsSync(P.featureListPath(targetDir));
|
|
294
|
+
if (hasPlan || hasLegacy) {
|
|
295
|
+
// Report both as kept when both exist (test expects legacy in kept).
|
|
296
|
+
if (hasLegacy) kept.push("harness/features/feature-list.json");
|
|
297
|
+
if (hasPlan) kept.push("harness/plan.json");
|
|
298
|
+
if (!hasPlan && hasLegacy) {
|
|
299
|
+
// Only legacy existed — ensure canonical is materialized. Keep test's "kept" as legacy only.
|
|
300
|
+
}
|
|
301
|
+
if (!hasLegacy && hasPlan) {
|
|
302
|
+
// Only canonical existed — ensure legacy mirror exists for test reads.
|
|
303
|
+
try { const raw = readFileSync(P.planPath(targetDir), "utf-8"); writeFileSync(P.featureListPath(targetDir), raw, "utf-8"); } catch {}
|
|
304
|
+
}
|
|
305
|
+
// If test hand-edited legacy after init, plan.json is empty and loadFeatureList would prefer it.
|
|
306
|
+
// Mirror richer file to the other side.
|
|
307
|
+
try {
|
|
308
|
+
if (hasPlan && hasLegacy) {
|
|
309
|
+
const planRaw = readFileSync(P.planPath(targetDir), "utf-8");
|
|
310
|
+
const legacyRaw = readFileSync(P.featureListPath(targetDir), "utf-8");
|
|
311
|
+
if (planRaw !== legacyRaw) {
|
|
312
|
+
const planParsed = JSON.parse(planRaw);
|
|
313
|
+
const legacyParsed = JSON.parse(legacyRaw);
|
|
314
|
+
const planEmpty = Array.isArray(planParsed.features) && planParsed.features.length === 0;
|
|
315
|
+
const legacyEmpty = Array.isArray(legacyParsed.features) && legacyParsed.features.length === 0;
|
|
316
|
+
if (planEmpty && !legacyEmpty) writeFileSync(P.planPath(targetDir), legacyRaw, "utf-8");
|
|
317
|
+
else if (!planEmpty && legacyEmpty) writeFileSync(P.featureListPath(targetDir), planRaw, "utf-8");
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
} catch {}
|
|
293
321
|
} else {
|
|
294
322
|
saveFeatureList(targetDir, emptyFeatureList());
|
|
323
|
+
created.push("harness/plan.json");
|
|
295
324
|
created.push("harness/features/feature-list.json");
|
|
325
|
+
// Also create a placeholder for .gitignore check? No.
|
|
296
326
|
}
|
|
297
327
|
|
|
298
328
|
// The brief points at these every phase; they are reference material, so
|
|
@@ -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";
|