infinity-harness 2.0.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/LICENSE +21 -0
  3. package/README.md +266 -0
  4. package/extensions/infinity-harness/index.ts +870 -0
  5. package/harness/docs/ARCHITECTURE.md +159 -0
  6. package/harness/docs/CONSTRAINTS.md +19 -0
  7. package/harness/docs/DECISIONS.md +107 -0
  8. package/harness/docs/DOMAIN.md +13 -0
  9. package/harness/docs/agents/evaluator.md +14 -0
  10. package/harness/docs/agents/generator.md +13 -0
  11. package/harness/docs/agents/planner.md +13 -0
  12. package/harness/docs/agents/simplifier.md +13 -0
  13. package/harness/docs/api-patterns.md +23 -0
  14. package/harness/docs/phases/build.md +47 -0
  15. package/harness/docs/phases/define.md +58 -0
  16. package/harness/docs/phases/plan.md +50 -0
  17. package/harness/docs/phases/review.md +47 -0
  18. package/harness/docs/phases/ship.md +43 -0
  19. package/harness/docs/phases/simplify.md +45 -0
  20. package/harness/docs/phases/verify.md +46 -0
  21. package/harness/model-router.json +28 -0
  22. package/harness/skills/README.md +60 -0
  23. package/harness/skills/auth-security.md +56 -0
  24. package/harness/skills/building-mcp-servers.md +70 -0
  25. package/harness/skills/building-tools.md +60 -0
  26. package/harness/skills/capability-acquisition.md +72 -0
  27. package/harness/skills/cli-design.md +55 -0
  28. package/harness/skills/code-review.md +57 -0
  29. package/harness/skills/codebase-design.md +70 -0
  30. package/harness/skills/concurrency-async.md +61 -0
  31. package/harness/skills/config-and-secrets.md +52 -0
  32. package/harness/skills/context-hygiene.md +51 -0
  33. package/harness/skills/databases.md +63 -0
  34. package/harness/skills/diagnosing-bugs.md +84 -0
  35. package/harness/skills/domain-modeling.md +65 -0
  36. package/harness/skills/error-handling-logging.md +56 -0
  37. package/harness/skills/frontend-ui.md +56 -0
  38. package/harness/skills/grilling.md +48 -0
  39. package/harness/skills/http-apis.md +60 -0
  40. package/harness/skills/performance.md +53 -0
  41. package/harness/skills/pi-todo-adapted.md +41 -0
  42. package/harness/skills/planning-tasks.md +86 -0
  43. package/harness/skills/prototype.md +39 -0
  44. package/harness/skills/research.md +32 -0
  45. package/harness/skills/resolving-merge-conflicts.md +30 -0
  46. package/harness/skills/scope-discipline.md +49 -0
  47. package/harness/skills/self-review.md +45 -0
  48. package/harness/skills/stuck-protocol.md +51 -0
  49. package/harness/skills/tdd.md +80 -0
  50. package/harness/skills/testing-infra.md +57 -0
  51. package/harness/skills/writing-skills.md +60 -0
  52. package/package.json +61 -0
  53. package/src/core/brief.ts +242 -0
  54. package/src/core/config.ts +265 -0
  55. package/src/core/exec.ts +130 -0
  56. package/src/core/featureList.ts +286 -0
  57. package/src/core/fsx.ts +119 -0
  58. package/src/core/gates.ts +444 -0
  59. package/src/core/lock.ts +192 -0
  60. package/src/core/paths.ts +95 -0
  61. package/src/core/phases.ts +143 -0
  62. package/src/core/settings.ts +445 -0
  63. package/src/core/types.ts +245 -0
  64. package/src/goalLoop.ts +628 -0
  65. package/src/goalSpec.ts +679 -0
  66. package/src/goalState.ts +338 -0
  67. package/src/loop.ts +355 -0
  68. package/src/modelRouter.ts +184 -0
  69. package/src/remote.ts +244 -0
  70. package/src/replan.ts +300 -0
  71. package/src/review.ts +53 -0
  72. package/src/rework.ts +274 -0
  73. package/src/taskList.ts +355 -0
  74. package/src/ui/config.ts +286 -0
  75. package/src/ui/dashboard.ts +1066 -0
  76. package/src/ui/theme.ts +317 -0
  77. package/src/ui/widget.ts +370 -0
  78. package/src/unstuck.ts +214 -0
  79. package/src/worker.ts +351 -0
  80. package/types/proper-lockfile.d.ts +19 -0
@@ -0,0 +1,444 @@
1
+ /**
2
+ * infinity-harness — deterministic gates.
3
+ *
4
+ * Gates are the referee. No agent marks its own work complete; a phase only
5
+ * advances when every check for that phase passes. Each check is a pure
6
+ * function of the project on disk, so the same tree always produces the same
7
+ * verdict — that determinism is what makes an unattended multi-day run safe.
8
+ *
9
+ * Checks that cannot run (no lint command configured, no upstream branch) are
10
+ * *advisory*: reported, but never a reason to fail. A gate that fails for a
11
+ * reason the agent cannot fix is a gate that deadlocks the loop.
12
+ */
13
+
14
+ import { resolve } from "node:path";
15
+ import { readdirSync, statSync } from "node:fs";
16
+ import type { CheckResult, GateResult, HarnessConfig, Phase } from "./types.ts";
17
+ import { loadConfig, saveConfig, recordGate } from "./config.ts";
18
+ import { loadFeatureList, findTask, findFeature, computeProgress, isDone } from "./featureList.ts";
19
+ import * as P from "./paths.ts";
20
+ import { readText, fileExists } from "./fsx.ts";
21
+ import {
22
+ run,
23
+ isGitRepo,
24
+ gitIsClean,
25
+ gitHasTag,
26
+ gitBehindUpstream,
27
+ gitHasUpstream,
28
+ LONG_TIMEOUT_MS,
29
+ } from "./exec.ts";
30
+
31
+ type Ctx = { targetDir: string; config: HarnessConfig };
32
+
33
+ const pass = (name: string, detail: string): CheckResult => ({ name, pass: true, detail });
34
+ const fail = (name: string, detail: string): CheckResult => ({ name, pass: false, detail });
35
+ const skip = (name: string, detail: string): CheckResult => ({
36
+ name,
37
+ pass: true,
38
+ detail,
39
+ advisory: true,
40
+ });
41
+
42
+ // ── Individual checks ───────────────────────────────────────────────────────
43
+
44
+ async function checkGitRepo({ targetDir }: Ctx): Promise<CheckResult> {
45
+ return (await isGitRepo(targetDir))
46
+ ? pass("git-repo", "inside a git work tree")
47
+ : fail("git-repo", "not a git repository — run `git init`");
48
+ }
49
+
50
+ async function checkConfigExists({ targetDir }: Ctx): Promise<CheckResult> {
51
+ return fileExists(P.configPath(targetDir))
52
+ ? pass("config-exists", "harness/config.json present")
53
+ : fail("config-exists", "harness/config.json is missing");
54
+ }
55
+
56
+ async function checkGitClean({ targetDir }: Ctx): Promise<CheckResult> {
57
+ return (await gitIsClean(targetDir))
58
+ ? pass("git-clean", "working tree clean")
59
+ : fail("git-clean", "uncommitted changes — commit or stash before advancing");
60
+ }
61
+
62
+ async function checkLint({ targetDir, config }: Ctx): Promise<CheckResult> {
63
+ const cmd = config.commands?.lint;
64
+ if (!cmd) return skip("lint", "no lint command configured (config.commands.lint)");
65
+ const r = await run(cmd, { cwd: targetDir, timeoutMs: LONG_TIMEOUT_MS });
66
+ if (r.timedOut) return fail("lint", `lint timed out after ${LONG_TIMEOUT_MS}ms`);
67
+ if (r.spawnError) return fail("lint", `lint could not start: ${r.spawnError}`);
68
+ return r.ok
69
+ ? pass("lint", "lint clean")
70
+ : fail("lint", firstLines(r.stderr || r.stdout, 6) || `lint exited ${r.code}`);
71
+ }
72
+
73
+ async function checkTests({ targetDir, config }: Ctx): Promise<CheckResult> {
74
+ const cmd = config.commands?.test;
75
+ if (!cmd) return skip("tests", "no test command configured (config.commands.test)");
76
+ const r = await run(cmd, { cwd: targetDir, timeoutMs: LONG_TIMEOUT_MS });
77
+ if (r.timedOut) return fail("tests", `tests timed out after ${LONG_TIMEOUT_MS}ms`);
78
+ if (r.spawnError) return fail("tests", `tests could not start: ${r.spawnError}`);
79
+ return r.ok
80
+ ? pass("tests", "tests pass")
81
+ : fail("tests", firstLines(r.stderr || r.stdout, 10) || `tests exited ${r.code}`);
82
+ }
83
+
84
+ async function checkCoverage({ targetDir, config }: Ctx): Promise<CheckResult> {
85
+ if (!config.gates?.coverage?.enabled) return skip("coverage", "coverage gate disabled");
86
+ const cmd = config.commands?.coverage;
87
+ if (!cmd) return skip("coverage", "no coverage command configured");
88
+ const threshold = config.gates.coverage.threshold ?? 80;
89
+ const r = await run(cmd, { cwd: targetDir, timeoutMs: LONG_TIMEOUT_MS });
90
+ if (r.timedOut) return fail("coverage", `coverage run timed out after ${LONG_TIMEOUT_MS}ms`);
91
+ if (r.spawnError) return fail("coverage", `coverage could not start: ${r.spawnError}`);
92
+ const pct = parseCoveragePercent(r.stdout + "\n" + r.stderr);
93
+ if (pct === null) {
94
+ return r.ok
95
+ ? skip("coverage", "coverage ran but no percentage could be parsed")
96
+ : fail("coverage", `coverage command exited ${r.code}`);
97
+ }
98
+ return pct >= threshold
99
+ ? pass("coverage", `${pct}% ≥ ${threshold}% threshold`)
100
+ : fail("coverage", `${pct}% is below the ${threshold}% threshold`);
101
+ }
102
+
103
+ /** Pull the highest "NN%"-looking number out of a coverage report. */
104
+ export function parseCoveragePercent(text: string): number | null {
105
+ const matches = [...text.matchAll(/(\d{1,3}(?:\.\d+)?)\s*%/g)];
106
+ if (matches.length === 0) return null;
107
+ const nums = matches
108
+ .map((m) => Number.parseFloat(m[1]!))
109
+ .filter((n) => Number.isFinite(n) && n >= 0 && n <= 100);
110
+ if (nums.length === 0) return null;
111
+ // Coverage tools print several figures (lines/branches/functions). The
112
+ // "all files" total is normally the lowest of the set, so take the minimum
113
+ // rather than an optimistic maximum.
114
+ return Math.min(...nums);
115
+ }
116
+
117
+ const PLACEHOLDER_PATTERNS = [
118
+ /\bTODO\b\s*:?\s*implement/i,
119
+ /\bFIXME\b/,
120
+ /\bnot implemented\b/i,
121
+ /throw new Error\((["'`])(?:TODO|unimplemented|not implemented)/i,
122
+ /\bplaceholder\b/i,
123
+ /\bcoming soon\b/i,
124
+ ];
125
+
126
+ const SCAN_EXTENSIONS = new Set([
127
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
128
+ ".py", ".go", ".rs", ".rb", ".java", ".kt", ".swift", ".cs", ".php",
129
+ ]);
130
+
131
+ const SKIP_DIRS = new Set([
132
+ "node_modules", ".git", "dist", "build", "out", "target", "vendor",
133
+ "coverage", ".next", ".venv", "venv", "__pycache__", "tmp", ".pi",
134
+ ]);
135
+
136
+ function walkSource(dir: string, out: string[], depth = 0): void {
137
+ if (depth > 8 || out.length > 4000) return;
138
+ let entries: string[];
139
+ try {
140
+ entries = readdirSync(dir);
141
+ } catch {
142
+ return;
143
+ }
144
+ for (const name of entries) {
145
+ if (name.startsWith(".") && name !== ".") continue;
146
+ if (SKIP_DIRS.has(name)) continue;
147
+ const full = resolve(dir, name);
148
+ let st;
149
+ try {
150
+ st = statSync(full);
151
+ } catch {
152
+ continue;
153
+ }
154
+ if (st.isDirectory()) {
155
+ walkSource(full, out, depth + 1);
156
+ } else if (SCAN_EXTENSIONS.has(name.slice(name.lastIndexOf(".")))) {
157
+ out.push(full);
158
+ }
159
+ }
160
+ }
161
+
162
+ async function checkNoPlaceholders({ targetDir, config }: Ctx): Promise<CheckResult> {
163
+ if (!config.gates?.antiPlaceholder?.enabled) {
164
+ return skip("anti-placeholder", "anti-placeholder gate disabled");
165
+ }
166
+ const extra = (config.gates.antiPlaceholder.patterns ?? [])
167
+ .map((p) => {
168
+ try {
169
+ return new RegExp(p, "i");
170
+ } catch {
171
+ return null;
172
+ }
173
+ })
174
+ .filter((r): r is RegExp => r !== null);
175
+ const patterns = [...PLACEHOLDER_PATTERNS, ...extra];
176
+
177
+ const files: string[] = [];
178
+ walkSource(targetDir, files);
179
+ const hits: string[] = [];
180
+ for (const f of files) {
181
+ const text = readText(f);
182
+ if (text === null) continue;
183
+ for (const p of patterns) {
184
+ if (p.test(text)) {
185
+ hits.push(f.replace(targetDir + "/", ""));
186
+ break;
187
+ }
188
+ }
189
+ if (hits.length >= 8) break;
190
+ }
191
+ return hits.length === 0
192
+ ? pass("anti-placeholder", `${files.length} source files, no placeholder markers`)
193
+ : fail("anti-placeholder", `placeholder markers in: ${hits.join(", ")}`);
194
+ }
195
+
196
+ function docCheck(name: string, path: string, minChars: number, hint: string): CheckResult {
197
+ const text = readText(path);
198
+ if (text === null) return fail(name, `${hint} is missing`);
199
+ const body = text.replace(/^#.*$/gm, "").trim();
200
+ return body.length >= minChars
201
+ ? pass(name, `${hint} present (${body.length} chars)`)
202
+ : fail(name, `${hint} exists but is essentially empty (${body.length} chars, need ${minChars})`);
203
+ }
204
+
205
+ async function checkReadme({ targetDir }: Ctx): Promise<CheckResult> {
206
+ return docCheck("readme", resolve(targetDir, "README.md"), 200, "README.md");
207
+ }
208
+ async function checkLicense({ targetDir }: Ctx): Promise<CheckResult> {
209
+ return fileExists(resolve(targetDir, "LICENSE")) || fileExists(resolve(targetDir, "LICENSE.md"))
210
+ ? pass("license", "LICENSE present")
211
+ : fail("license", "LICENSE is missing");
212
+ }
213
+ async function checkChangelog({ targetDir }: Ctx): Promise<CheckResult> {
214
+ return docCheck("changelog", resolve(targetDir, "CHANGELOG.md"), 100, "CHANGELOG.md");
215
+ }
216
+ async function checkArchitectureDoc({ targetDir }: Ctx): Promise<CheckResult> {
217
+ return docCheck("architecture-doc", P.architecturePath(targetDir), 200, "harness/docs/ARCHITECTURE.md");
218
+ }
219
+ async function checkDecisionsLogged({ targetDir }: Ctx): Promise<CheckResult> {
220
+ return docCheck("decisions-logged", P.decisionsPath(targetDir), 100, "harness/docs/DECISIONS.md");
221
+ }
222
+ async function checkRubricContent({ targetDir }: Ctx): Promise<CheckResult> {
223
+ return docCheck("rubric-content", P.rubricPath(targetDir), 100, "harness/evaluator-rubric.md");
224
+ }
225
+
226
+ async function checkTagged({ targetDir }: Ctx): Promise<CheckResult> {
227
+ return (await gitHasTag(targetDir))
228
+ ? pass("tagged", "HEAD carries a release tag")
229
+ : fail("tagged", "HEAD is not tagged — tag the release before shipping");
230
+ }
231
+
232
+ async function checkBranchUpToDate({ targetDir }: Ctx): Promise<CheckResult> {
233
+ if (!(await gitHasUpstream(targetDir))) {
234
+ return skip("branch-up-to-date", "no upstream configured");
235
+ }
236
+ const behind = await gitBehindUpstream(targetDir);
237
+ if (behind === null) return skip("branch-up-to-date", "could not compare against upstream");
238
+ return behind === 0
239
+ ? pass("branch-up-to-date", "level with upstream")
240
+ : fail("branch-up-to-date", `${behind} commit(s) behind upstream — pull first`);
241
+ }
242
+
243
+ async function checkNoEmptyDirs({ targetDir }: Ctx): Promise<CheckResult> {
244
+ const empties: string[] = [];
245
+ const walk = (dir: string, depth: number): void => {
246
+ if (depth > 6 || empties.length >= 5) return;
247
+ let entries: string[];
248
+ try {
249
+ entries = readdirSync(dir);
250
+ } catch {
251
+ return;
252
+ }
253
+ const visible = entries.filter((e) => !SKIP_DIRS.has(e) && !e.startsWith("."));
254
+ if (visible.length === 0 && dir !== targetDir) {
255
+ empties.push(dir.replace(targetDir + "/", ""));
256
+ return;
257
+ }
258
+ for (const e of visible) {
259
+ const full = resolve(dir, e);
260
+ try {
261
+ if (statSync(full).isDirectory()) walk(full, depth + 1);
262
+ } catch {
263
+ /* unreadable */
264
+ }
265
+ }
266
+ };
267
+ walk(targetDir, 0);
268
+ return empties.length === 0
269
+ ? pass("no-empty-dirs", "no empty directories")
270
+ : fail("no-empty-dirs", `empty directories: ${empties.join(", ")}`);
271
+ }
272
+
273
+ /** Every feature must declare acceptance criteria before BUILD starts. */
274
+ async function checkFeatureCriteria({ targetDir }: Ctx): Promise<CheckResult> {
275
+ const { list } = loadFeatureList(targetDir);
276
+ const features = list.features ?? [];
277
+ if (features.length === 0) return fail("feature-criteria", "no features planned yet");
278
+ const missing = features.filter((f) => !(f.criteria ?? []).length).map((f) => f.id);
279
+ return missing.length === 0
280
+ ? pass("feature-criteria", `${features.length} feature(s) have criteria`)
281
+ : fail("feature-criteria", `features without criteria: ${missing.join(", ")}`);
282
+ }
283
+
284
+ /** Every task in the plan must be complete before the phase gate opens. */
285
+ async function checkTasksComplete({ targetDir }: Ctx): Promise<CheckResult> {
286
+ const { list } = loadFeatureList(targetDir);
287
+ const p = computeProgress(list);
288
+ if (p.tasksTotal === 0) return fail("tasks-complete", "no tasks planned");
289
+ if (p.tasksDone === p.tasksTotal) return pass("tasks-complete", `${p.tasksDone}/${p.tasksTotal} tasks complete`);
290
+ const remaining = p.tasksTotal - p.tasksDone;
291
+ return fail(
292
+ "tasks-complete",
293
+ `${remaining} task(s) still open (${p.blocked} blocked, ${p.inProgress} in progress, ${p.rework} rework)`,
294
+ );
295
+ }
296
+
297
+ function firstLines(s: string, n: number): string {
298
+ return s.split("\n").slice(0, n).join("\n").trim();
299
+ }
300
+
301
+ // ── Phase → checks ──────────────────────────────────────────────────────────
302
+
303
+ type Check = (ctx: Ctx) => Promise<CheckResult>;
304
+
305
+ const PHASE_CHECKS: Record<Phase, Check[]> = {
306
+ init: [checkGitRepo, checkConfigExists],
307
+ define: [checkFeatureCriteria],
308
+ plan: [checkFeatureCriteria, checkTasksPlanned],
309
+ build: [checkLint, checkTests, checkCoverage, checkNoPlaceholders, checkTasksComplete],
310
+ verify: [checkTests, checkCoverage, checkGitClean],
311
+ simplify: [checkTests, checkNoEmptyDirs, checkGitClean],
312
+ review: [checkBranchUpToDate, checkRubricContent, checkReadme, checkArchitectureDoc, checkDecisionsLogged],
313
+ ship: [
314
+ checkGitClean,
315
+ checkTagged,
316
+ checkChangelog,
317
+ checkReadme,
318
+ checkLicense,
319
+ checkNoEmptyDirs,
320
+ checkNoPlaceholders,
321
+ ],
322
+ };
323
+
324
+ async function checkTasksPlanned({ targetDir }: Ctx): Promise<CheckResult> {
325
+ const { list } = loadFeatureList(targetDir);
326
+ const total = (list.features ?? []).reduce((n, f) => n + (f.tasks ?? []).length, 0);
327
+ return total > 0
328
+ ? pass("tasks-planned", `${total} task(s) planned`)
329
+ : fail("tasks-planned", "no tasks planned — PLAN must produce a task list");
330
+ }
331
+
332
+ /** Checks that are meaningful for a single task rather than a whole phase. */
333
+ const TASK_SCOPED = new Set(["lint", "tests", "coverage"]);
334
+
335
+ export function getPhaseCheckNames(phase: Phase): string[] {
336
+ return (PHASE_CHECKS[phase] ?? []).map((fn) => fn.name.replace(/^check/, "").toLowerCase());
337
+ }
338
+
339
+ // ── Runner ──────────────────────────────────────────────────────────────────
340
+
341
+ export type RunChecksOptions = {
342
+ feature?: string;
343
+ task?: string;
344
+ /** Persist the verdict to gateHistory. Read-only callers pass false. */
345
+ record?: boolean;
346
+ };
347
+
348
+ /**
349
+ * Run the checks for `phase`.
350
+ *
351
+ * With `feature` + `task` set, only task-scoped checks run plus that task's
352
+ * own acceptance criteria — validating one task must not demand that the
353
+ * whole phase is finished.
354
+ */
355
+ export async function runChecks(
356
+ targetDir: string,
357
+ phase: Phase | null,
358
+ options: RunChecksOptions = {},
359
+ ): Promise<GateResult> {
360
+ const { config } = loadConfig(targetDir);
361
+
362
+ if (!phase) {
363
+ return { phase: "none", checks: [], overall: false, failures: ["no-phase"] };
364
+ }
365
+ if (config.gates?.enabled === false) {
366
+ return {
367
+ phase,
368
+ checks: [skip("gates-disabled", "gates are disabled in config — nothing enforced")],
369
+ overall: true,
370
+ failures: [],
371
+ };
372
+ }
373
+
374
+ const ctx: Ctx = { targetDir, config };
375
+ const isTaskScoped = Boolean(options.feature && options.task);
376
+
377
+ let checks = PHASE_CHECKS[phase] ?? [];
378
+ if (isTaskScoped) {
379
+ checks = checks.filter((fn) => TASK_SCOPED.has(fn.name.replace(/^check/, "").toLowerCase()));
380
+ }
381
+
382
+ const results: CheckResult[] = [];
383
+ for (const fn of checks) {
384
+ try {
385
+ results.push(await fn(ctx));
386
+ } catch (e) {
387
+ results.push(fail(fn.name, `check threw: ${e instanceof Error ? e.message : String(e)}`));
388
+ }
389
+ }
390
+
391
+ if (isTaskScoped) {
392
+ results.push(checkTaskCriteria(targetDir, options.feature!, options.task!));
393
+ }
394
+
395
+ const failures = results.filter((r) => !r.pass).map((r) => r.name);
396
+ const result: GateResult = {
397
+ phase,
398
+ checks: results,
399
+ overall: failures.length === 0,
400
+ failures,
401
+ ...(options.feature ? { feature: options.feature } : {}),
402
+ ...(options.task ? { task: options.task } : {}),
403
+ };
404
+
405
+ if (options.record !== false) {
406
+ try {
407
+ const fresh = loadConfig(targetDir);
408
+ if (fresh.ok) {
409
+ recordGate(fresh.config, phase, result.overall ? "pass" : "fail", {
410
+ feature: options.feature,
411
+ task: options.task,
412
+ });
413
+ saveConfig(targetDir, fresh.config);
414
+ }
415
+ } catch {
416
+ /* gate history is best-effort and must never break validation */
417
+ }
418
+ }
419
+
420
+ return result;
421
+ }
422
+
423
+ /** A task passes when it is marked complete and every subtask is complete. */
424
+ export function checkTaskCriteria(targetDir: string, featureId: string, taskId: string): CheckResult {
425
+ const { list } = loadFeatureList(targetDir);
426
+ const feature = findFeature(list, featureId);
427
+ if (!feature) return fail("task-criteria", `unknown feature ${featureId}`);
428
+ const found = findTask(list, taskId);
429
+ if (!found) return fail("task-criteria", `unknown task ${taskId}`);
430
+ const { task } = found;
431
+ const openSubtasks = (task.subtasks ?? []).filter((s) => s.status !== "complete");
432
+ if (openSubtasks.length > 0) {
433
+ return fail("task-criteria", `${openSubtasks.length} subtask(s) still open on ${taskId}`);
434
+ }
435
+ if (!isDone(task.status)) {
436
+ return fail("task-criteria", `${taskId} is "${task.status}", not complete`);
437
+ }
438
+ return pass("task-criteria", `${taskId} complete with all subtasks done`);
439
+ }
440
+
441
+ export function areGatesEnabled(targetDir: string): boolean {
442
+ const { config } = loadConfig(targetDir);
443
+ return config.gates?.enabled !== false;
444
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * infinity-harness — cross-process file locking.
3
+ *
4
+ * Parallel workers all write the same plan file. Without mutual exclusion,
5
+ * two writers that both read revision N both pass the `baseRevision` check and
6
+ * both write N+1 — one set of edits vanishes. `baseRevision` detects a stale
7
+ * *read*; it cannot serialise a read-modify-write. Only a lock can.
8
+ *
9
+ * Two flavours, deliberately:
10
+ *
11
+ * - `withLockSync` wraps the plan's read-apply-write in one atomic section.
12
+ * It is synchronous because the critical section is, and it **fails
13
+ * closed**: if the lock cannot be taken, the write is refused rather than
14
+ * racing. Losing an edit silently is worse than an error the caller can
15
+ * retry.
16
+ * - `withLock` is the async, best-effort variant for coarse advisory
17
+ * locking where proceeding un-locked is acceptable.
18
+ *
19
+ * Both hold the lock only for the duration of the work, never across a turn.
20
+ * An earlier version took a lock at the start of an agent turn with an
21
+ * 8-second staleness timeout, so every turn longer than 8 seconds left a lock
22
+ * another process was entitled to steal.
23
+ */
24
+
25
+ import { dirname } from "node:path";
26
+ import { mkdirSync, rmdirSync, statSync, writeFileSync, unlinkSync, readdirSync } from "node:fs";
27
+ import { ensureDir, fileExists, writeTextAtomic } from "./fsx.ts";
28
+
29
+ export type LockHandle = { release: () => Promise<void> };
30
+
31
+ /** A lock held longer than this is assumed to belong to a dead process. */
32
+ export const STALE_MS = 30_000;
33
+ export const RETRIES = 12;
34
+ export const RETRY_MIN_MS = 20;
35
+ /** Total time `withLockSync` will wait before refusing. */
36
+ export const SYNC_LOCK_TIMEOUT_MS = 10_000;
37
+
38
+ export class LockTimeoutError extends Error {
39
+ override readonly name = "LockTimeoutError";
40
+ constructor(path: string, waitedMs: number) {
41
+ super(
42
+ `could not lock ${path} after ${waitedMs}ms — another process is holding it. ` +
43
+ `Retry; if this persists, remove ${path}.ilock`,
44
+ );
45
+ Object.setPrototypeOf(this, LockTimeoutError.prototype);
46
+ }
47
+ }
48
+
49
+ // ── Synchronous lock ────────────────────────────────────────────────────────
50
+
51
+ /**
52
+ * Lock directory name.
53
+ *
54
+ * Deliberately NOT `<path>.lock` — that is exactly what `proper-lockfile`
55
+ * uses, and it is a directory there too. Sharing the name means a caller that
56
+ * wraps `withLock` around something that calls `withLockSync` deadlocks
57
+ * against itself: it holds the async lock, then blocks the event loop waiting
58
+ * for the same directory it already owns, until the timeout fires. Distinct
59
+ * names make nesting the two merely redundant instead of fatal.
60
+ */
61
+ function lockDirFor(path: string): string {
62
+ return `${path}.ilock`;
63
+ }
64
+
65
+ /**
66
+ * Block the current thread for `ms`.
67
+ *
68
+ * `Atomics.wait` on a never-notified buffer is the only real synchronous sleep
69
+ * in Node. Busy-waiting on `Date.now()` would spin a core, and the critical
70
+ * sections here are short enough that blocking is the right trade.
71
+ */
72
+ function sleepSync(ms: number): void {
73
+ const shared = new Int32Array(new SharedArrayBuffer(4));
74
+ Atomics.wait(shared, 0, 0, ms);
75
+ }
76
+
77
+ function isStale(lockDir: string): boolean {
78
+ try {
79
+ return Date.now() - statSync(lockDir).mtimeMs > STALE_MS;
80
+ } catch {
81
+ // Vanished between the EEXIST and the stat — treat as free.
82
+ return true;
83
+ }
84
+ }
85
+
86
+ function breakStaleLock(lockDir: string): void {
87
+ try {
88
+ for (const entry of readdirSync(lockDir)) {
89
+ try {
90
+ unlinkSync(`${lockDir}/${entry}`);
91
+ } catch {
92
+ /* best effort */
93
+ }
94
+ }
95
+ rmdirSync(lockDir);
96
+ } catch {
97
+ // Another process broke it first, or it is no longer stale. Either way the
98
+ // next acquire attempt settles it.
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Run `fn` while holding an exclusive lock on `path`.
104
+ *
105
+ * `mkdir` is the primitive: it either creates the directory or fails with
106
+ * EEXIST, atomically, on every filesystem we care about — unlike a
107
+ * check-then-create on a lock *file*, which has the same race we are trying to
108
+ * close.
109
+ *
110
+ * @throws LockTimeoutError when the lock cannot be acquired in time.
111
+ */
112
+ export function withLockSync<T>(path: string, fn: () => T, timeoutMs = SYNC_LOCK_TIMEOUT_MS): T {
113
+ const lockDir = lockDirFor(path);
114
+ ensureDir(dirname(path));
115
+
116
+ const deadline = Date.now() + timeoutMs;
117
+ let acquired = false;
118
+ let backoff = RETRY_MIN_MS;
119
+
120
+ while (!acquired) {
121
+ try {
122
+ mkdirSync(lockDir);
123
+ acquired = true;
124
+ } catch (e) {
125
+ if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
126
+ if (isStale(lockDir)) {
127
+ breakStaleLock(lockDir);
128
+ continue;
129
+ }
130
+ if (Date.now() >= deadline) throw new LockTimeoutError(path, timeoutMs);
131
+ sleepSync(Math.min(backoff, 250));
132
+ backoff = Math.min(backoff * 2, 250);
133
+ }
134
+ }
135
+
136
+ try {
137
+ // Owner marker: purely diagnostic, so a stuck lock names a pid.
138
+ try {
139
+ writeFileSync(`${lockDir}/owner`, `${process.pid}\n`, "utf-8");
140
+ } catch {
141
+ /* the directory is the lock; the marker is a nicety */
142
+ }
143
+ return fn();
144
+ } finally {
145
+ breakStaleLock(lockDir);
146
+ }
147
+ }
148
+
149
+ // ── Asynchronous, best-effort lock ──────────────────────────────────────────
150
+
151
+ /**
152
+ * Run `fn` while holding an advisory lock on `path`.
153
+ *
154
+ * Best-effort by design: if the lock cannot be acquired, `fn` still runs and
155
+ * `locked` reports false. Use this only where an interleave is tolerable —
156
+ * never for a read-modify-write. Plan writes use `withLockSync`.
157
+ */
158
+ export async function withLock<T>(
159
+ path: string,
160
+ fn: () => Promise<T> | T,
161
+ ): Promise<{ value: T; locked: boolean }> {
162
+ const handle = await acquire(path);
163
+ try {
164
+ const value = await fn();
165
+ return { value, locked: handle !== null };
166
+ } finally {
167
+ if (handle) {
168
+ try {
169
+ await handle.release();
170
+ } catch {
171
+ /* the lock times out on its own */
172
+ }
173
+ }
174
+ }
175
+ }
176
+
177
+ async function acquire(path: string): Promise<LockHandle | null> {
178
+ try {
179
+ ensureDir(dirname(path));
180
+ // proper-lockfile refuses to lock a path that does not exist.
181
+ if (!fileExists(path)) writeTextAtomic(path, "");
182
+ const lockfile = await import("proper-lockfile");
183
+ const release = await lockfile.lock(path, {
184
+ retries: { retries: RETRIES, minTimeout: RETRY_MIN_MS, maxTimeout: 500, factor: 1.6 },
185
+ stale: STALE_MS,
186
+ realpath: false,
187
+ });
188
+ return { release: async () => release() };
189
+ } catch {
190
+ return null;
191
+ }
192
+ }