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,286 @@
1
+ /**
2
+ * infinity-harness — feature-list.json: the plan of record.
3
+ *
4
+ * This module is the *only* writer of harness/features/feature-list.json.
5
+ *
6
+ * The rule that matters: a write must never lose a field it did not
7
+ * understand. Earlier versions rebuilt each task from a fixed shape and
8
+ * silently dropped `difficulty`, `modelHint`, `criteria` and anything a
9
+ * future version might add. Here, updates are merged onto the stored task, so
10
+ * unknown keys survive every round-trip.
11
+ */
12
+
13
+ import type { Feature, FeatureList, Task, TaskStatus, Subtask } from "./types.ts";
14
+ import { ValidationError } from "./types.ts";
15
+ import { featureListPath } from "./paths.ts";
16
+ import { readJson, writeJsonAtomic, backupOnce, fileExists } from "./fsx.ts";
17
+
18
+ export const MAX_TASKS = 200;
19
+ export const MAX_DEPENDS_ON = 20;
20
+ export const MAX_SUBJECT_LEN = 200;
21
+ export const MAX_DESCRIPTION_LEN = 4000;
22
+
23
+ const KEY_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
24
+
25
+ export function emptyFeatureList(): FeatureList {
26
+ return {
27
+ version: "2.0",
28
+ baseRevision: 0,
29
+ goals: [],
30
+ sprints: [],
31
+ features: [],
32
+ };
33
+ }
34
+
35
+ export function validateKey(key: string, path: string): string {
36
+ const trimmed = String(key ?? "").trim();
37
+ if (!trimmed) throw new ValidationError(`${path} must be non-empty`);
38
+ if (trimmed.includes("/")) {
39
+ const parts = trimmed.split("/");
40
+ if (parts.length !== 2) {
41
+ throw new ValidationError(`${path} composite key must be "featureId/taskId", got "${key}"`);
42
+ }
43
+ for (const part of parts) {
44
+ if (!KEY_RE.test(part.trim())) throw new ValidationError(`${path} segment "${part}" is not a valid key`);
45
+ }
46
+ return parts.map((p) => p.trim()).join("/");
47
+ }
48
+ if (!KEY_RE.test(trimmed)) {
49
+ throw new ValidationError(
50
+ `${path} must be 1-64 chars of letters, digits, dot, underscore or hyphen (got "${key}")`,
51
+ );
52
+ }
53
+ return trimmed;
54
+ }
55
+
56
+ const STATUS_ALIASES: Record<string, TaskStatus> = {
57
+ completed: "complete",
58
+ done: "complete",
59
+ closed: "complete",
60
+ passed: "complete",
61
+ complete: "complete",
62
+ "in-progress": "in_progress",
63
+ in_progress: "in_progress",
64
+ inprogress: "in_progress",
65
+ active: "in_progress",
66
+ pending: "pending",
67
+ todo: "pending",
68
+ blocked: "blocked",
69
+ rework: "rework",
70
+ };
71
+
72
+ export function normalizeStatus(status: unknown): TaskStatus {
73
+ const key = String(status ?? "").trim().toLowerCase();
74
+ const mapped = STATUS_ALIASES[key];
75
+ if (!mapped) throw new ValidationError(`unknown status: ${JSON.stringify(status)}`);
76
+ return mapped;
77
+ }
78
+
79
+ export function normalizeSubtaskStatus(status: unknown): Subtask["status"] {
80
+ const s = normalizeStatus(status ?? "pending");
81
+ if (s === "blocked" || s === "rework") return "in_progress";
82
+ return s;
83
+ }
84
+
85
+ export function isDone(status: TaskStatus): boolean {
86
+ return status === "complete";
87
+ }
88
+
89
+ // ── Load / save ─────────────────────────────────────────────────────────────
90
+
91
+ export type LoadedFeatureList = {
92
+ list: FeatureList;
93
+ path: string;
94
+ existed: boolean;
95
+ };
96
+
97
+ export function loadFeatureList(targetDir: string): LoadedFeatureList {
98
+ const path = featureListPath(targetDir);
99
+ if (!fileExists(path)) return { list: emptyFeatureList(), path, existed: false };
100
+ let parsed: FeatureList | null;
101
+ try {
102
+ parsed = readJson<FeatureList>(path);
103
+ } catch {
104
+ // Corrupt plan: fall back to the backup rather than clobbering it.
105
+ try {
106
+ const bak = readJson<FeatureList>(`${path}.bak`);
107
+ if (bak) return { list: normalizeList(bak), path, existed: true };
108
+ } catch {
109
+ /* fall through */
110
+ }
111
+ return { list: emptyFeatureList(), path, existed: true };
112
+ }
113
+ if (!parsed) return { list: emptyFeatureList(), path, existed: true };
114
+ return { list: normalizeList(parsed), path, existed: true };
115
+ }
116
+
117
+ function normalizeList(raw: FeatureList): FeatureList {
118
+ const list: FeatureList = {
119
+ ...raw,
120
+ version: typeof raw.version === "string" ? raw.version : "2.0",
121
+ baseRevision: typeof raw.baseRevision === "number" ? raw.baseRevision : 0,
122
+ goals: Array.isArray(raw.goals) ? raw.goals : [],
123
+ sprints: Array.isArray(raw.sprints) ? raw.sprints : [],
124
+ features: Array.isArray(raw.features) ? raw.features : [],
125
+ };
126
+ for (const f of list.features) {
127
+ if (!Array.isArray(f.tasks)) f.tasks = [];
128
+ for (const t of f.tasks) {
129
+ if (!Array.isArray(t.dependsOn)) t.dependsOn = [];
130
+ if (!Array.isArray(t.subtasks)) t.subtasks = [];
131
+ try {
132
+ t.status = normalizeStatus(t.status);
133
+ } catch {
134
+ t.status = "pending";
135
+ }
136
+ }
137
+ }
138
+ return list;
139
+ }
140
+
141
+ export function saveFeatureList(targetDir: string, list: FeatureList): void {
142
+ const path = featureListPath(targetDir);
143
+ backupOnce(path);
144
+ writeJsonAtomic(path, list);
145
+ }
146
+
147
+ // ── Flat view ───────────────────────────────────────────────────────────────
148
+
149
+ export type FlatTask = Task & {
150
+ /** Always populated: `key` if set, else `featureId/id`. */
151
+ compositeKey: string;
152
+ featureId: string;
153
+ featureName: string;
154
+ /** 1-based position in the flattened plan, used for `← #3` dep labels. */
155
+ index: number;
156
+ };
157
+
158
+ /** Flatten every task across every feature, in plan order. */
159
+ export function flattenTasks(list: FeatureList): FlatTask[] {
160
+ const out: FlatTask[] = [];
161
+ let i = 0;
162
+ for (const f of list.features ?? []) {
163
+ for (const t of f.tasks ?? []) {
164
+ i += 1;
165
+ out.push({
166
+ ...t,
167
+ compositeKey: t.key ?? `${f.id}/${t.id}`,
168
+ featureId: f.id,
169
+ featureName: f.name,
170
+ index: i,
171
+ });
172
+ }
173
+ }
174
+ return out;
175
+ }
176
+
177
+ /** Resolve a task by bare id, composite key, or `key` field. */
178
+ export function findTask(
179
+ list: FeatureList,
180
+ needle: string,
181
+ ): { feature: Feature; task: Task } | null {
182
+ const want = String(needle ?? "").trim();
183
+ if (!want) return null;
184
+ for (const f of list.features ?? []) {
185
+ for (const t of f.tasks ?? []) {
186
+ if (t.id === want) return { feature: f, task: t };
187
+ if (t.key === want) return { feature: f, task: t };
188
+ if (`${f.id}/${t.id}` === want) return { feature: f, task: t };
189
+ }
190
+ }
191
+ return null;
192
+ }
193
+
194
+ export function findFeature(list: FeatureList, featureId: string): Feature | null {
195
+ return (list.features ?? []).find((f) => f.id === featureId) ?? null;
196
+ }
197
+
198
+ // ── Progress ────────────────────────────────────────────────────────────────
199
+
200
+ export type Progress = {
201
+ tasksDone: number;
202
+ tasksTotal: number;
203
+ featuresDone: number;
204
+ featuresTotal: number;
205
+ blocked: number;
206
+ inProgress: number;
207
+ rework: number;
208
+ percent: number;
209
+ };
210
+
211
+ export function computeProgress(list: FeatureList): Progress {
212
+ const tasks = flattenTasks(list);
213
+ const tasksDone = tasks.filter((t) => isDone(t.status)).length;
214
+ const features = list.features ?? [];
215
+ const featuresDone = features.filter(
216
+ (f) => (f.tasks ?? []).length > 0 && (f.tasks ?? []).every((t) => isDone(t.status)),
217
+ ).length;
218
+ return {
219
+ tasksDone,
220
+ tasksTotal: tasks.length,
221
+ featuresDone,
222
+ featuresTotal: features.length,
223
+ blocked: tasks.filter((t) => t.status === "blocked").length,
224
+ inProgress: tasks.filter((t) => t.status === "in_progress").length,
225
+ rework: tasks.filter((t) => t.status === "rework").length,
226
+ percent: tasks.length === 0 ? 0 : Math.round((tasksDone / tasks.length) * 100),
227
+ };
228
+ }
229
+
230
+ /**
231
+ * The next task the pipeline should work on: the first in_progress task,
232
+ * else the first pending task whose dependencies are all complete.
233
+ * Returns null when everything is done or everything left is blocked.
234
+ */
235
+ export function nextActionableTask(list: FeatureList): FlatTask | null {
236
+ const tasks = flattenTasks(list);
237
+ const byKey = new Map<string, FlatTask>();
238
+ for (const t of tasks) {
239
+ byKey.set(t.compositeKey, t);
240
+ byKey.set(t.id, t);
241
+ if (t.key) byKey.set(t.key, t);
242
+ }
243
+ const inProgress = tasks.find((t) => t.status === "in_progress");
244
+ if (inProgress) return inProgress;
245
+ const rework = tasks.find((t) => t.status === "rework");
246
+ if (rework) return rework;
247
+ for (const t of tasks) {
248
+ if (t.status !== "pending") continue;
249
+ const deps = t.dependsOn ?? [];
250
+ const unmet = deps.filter((d) => {
251
+ const dep = byKey.get(d);
252
+ return !dep || !isDone(dep.status);
253
+ });
254
+ if (unmet.length === 0) return t;
255
+ }
256
+ return null;
257
+ }
258
+
259
+ // ── Dependency integrity ────────────────────────────────────────────────────
260
+
261
+ export function detectCycle(tasks: Array<{ compositeKey: string; dependsOn?: string[] }>): void {
262
+ const map = new Map<string, string[]>();
263
+ for (const t of tasks) map.set(t.compositeKey, t.dependsOn ?? []);
264
+ const visiting = new Set<string>();
265
+ const visited = new Set<string>();
266
+ const stack: string[] = [];
267
+
268
+ const dfs = (key: string): void => {
269
+ if (visiting.has(key)) {
270
+ const cycleStart = stack.indexOf(key);
271
+ const cycle = [...stack.slice(cycleStart), key].join(" → ");
272
+ throw new ValidationError(`dependency cycle: ${cycle}`);
273
+ }
274
+ if (visited.has(key)) return;
275
+ visiting.add(key);
276
+ stack.push(key);
277
+ for (const dep of map.get(key) ?? []) {
278
+ if (map.has(dep)) dfs(dep);
279
+ }
280
+ stack.pop();
281
+ visiting.delete(key);
282
+ visited.add(key);
283
+ };
284
+
285
+ for (const k of map.keys()) dfs(k);
286
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * infinity-harness — filesystem helpers.
3
+ *
4
+ * Two guarantees callers depend on:
5
+ * - `writeJsonAtomic` never leaves a half-written file, and never leaves a
6
+ * stray temp file behind when the write fails.
7
+ * - `readJson` distinguishes "absent" (null) from "corrupt" (throws), so
8
+ * callers can seed defaults without silently discarding real state.
9
+ */
10
+
11
+ import {
12
+ existsSync,
13
+ mkdirSync,
14
+ readFileSync,
15
+ renameSync,
16
+ unlinkSync,
17
+ writeFileSync,
18
+ copyFileSync,
19
+ } from "node:fs";
20
+ import { dirname } from "node:path";
21
+ import { randomBytes } from "node:crypto";
22
+
23
+ export function ensureDir(dir: string): void {
24
+ mkdirSync(dir, { recursive: true });
25
+ }
26
+
27
+ /**
28
+ * Strip a leading UTF-8 byte-order mark.
29
+ *
30
+ * Windows writes one routinely — PowerShell's `Set-Content -Encoding utf8`,
31
+ * Notepad, and several editors all do — and `JSON.parse` rejects it. Without
32
+ * this, a user who opens harness/config.json in Notepad, changes one value and
33
+ * saves gets "config is missing or unreadable" and no clue why. The files are
34
+ * explicitly documented as hand-editable, so they have to survive the editors
35
+ * people actually have.
36
+ */
37
+ export function stripBom(text: string): string {
38
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
39
+ }
40
+
41
+ export function readText(path: string): string | null {
42
+ if (!existsSync(path)) return null;
43
+ try {
44
+ return stripBom(readFileSync(path, "utf-8"));
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Read and parse JSON.
52
+ * @returns parsed value, or `null` when the file does not exist.
53
+ * @throws when the file exists but does not parse — callers must not paper
54
+ * over a corrupt state file by overwriting it with defaults.
55
+ */
56
+ export function readJson<T>(path: string): T | null {
57
+ if (!existsSync(path)) return null;
58
+ const raw = stripBom(readFileSync(path, "utf-8"));
59
+ if (raw.trim() === "") return null;
60
+ try {
61
+ return JSON.parse(raw) as T;
62
+ } catch (e) {
63
+ const msg = e instanceof Error ? e.message : String(e);
64
+ throw new SyntaxError(`${path} is not valid JSON: ${msg}`);
65
+ }
66
+ }
67
+
68
+ /** Read JSON, falling back to `fallback` on absence *or* corruption. */
69
+ export function readJsonSafe<T>(path: string, fallback: T): T {
70
+ try {
71
+ const v = readJson<T>(path);
72
+ return v === null ? fallback : v;
73
+ } catch {
74
+ return fallback;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Write JSON atomically: temp file in the same directory, then rename.
80
+ * The temp name includes pid + random bytes so concurrent writers in the same
81
+ * process (or across processes) never collide on the temp path.
82
+ */
83
+ export function writeJsonAtomic(path: string, value: unknown): void {
84
+ writeTextAtomic(path, JSON.stringify(value, null, 2) + "\n");
85
+ }
86
+
87
+ export function writeTextAtomic(path: string, contents: string): void {
88
+ ensureDir(dirname(path));
89
+ const tmp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
90
+ try {
91
+ writeFileSync(tmp, contents, "utf-8");
92
+ renameSync(tmp, path);
93
+ } catch (e) {
94
+ try {
95
+ if (existsSync(tmp)) unlinkSync(tmp);
96
+ } catch {
97
+ /* best effort */
98
+ }
99
+ throw e;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Keep one `.bak` beside a state file before overwriting it.
105
+ * Cheap insurance for multi-day runs: a corrupt write leaves the prior good
106
+ * revision recoverable without reaching for git.
107
+ */
108
+ export function backupOnce(path: string): void {
109
+ if (!existsSync(path)) return;
110
+ try {
111
+ copyFileSync(path, `${path}.bak`);
112
+ } catch {
113
+ /* best effort — a failed backup must never block the write */
114
+ }
115
+ }
116
+
117
+ export function fileExists(path: string): boolean {
118
+ return existsSync(path);
119
+ }