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
package/src/replan.ts ADDED
@@ -0,0 +1,300 @@
1
+ /**
2
+ * infinity-harness — replan: mid-BUILD plan amendment.
3
+ *
4
+ * `amendPlan` is the additive counterpart to `taskList.writeTaskList`: it adds
5
+ * sprints, features and tasks to the plan of record without asking the caller
6
+ * to resubmit everything, then re-validates the whole dependency graph and
7
+ * bumps `baseRevision`. A `maxReplansPerRun` budget keeps a stuck run from
8
+ * replanning forever, and every amendment is appended to
9
+ * `harness/replan.json`.
10
+ *
11
+ * Plan I/O, sidecar I/O, config reads, paths and locking all come from
12
+ * `core/`. This module used to carry private copies of each, and they had
13
+ * quietly drifted: the private plan loader did a raw `readFileSync` +
14
+ * `JSON.parse`, so it neither normalised status aliases (a stored "done" never
15
+ * compared equal to "complete", which `validateDeps` reads as an unresolved
16
+ * dependency) nor fell back to the `.bak` after a corrupt write, and its saver
17
+ * did a bare tmp+rename with no backup. The private lock helper wrapped
18
+ * `proper-lockfile` on `<path>.lock` while the plan writer takes
19
+ * `<path>.ilock`, so the two never actually excluded each other. One
20
+ * implementation of each now, in `core/`.
21
+ */
22
+
23
+ import { unlinkSync } from "node:fs";
24
+ import type { Feature, FeatureList, Subtask, Task, TaskStatus } from "./core/types.ts";
25
+ import { TASK_STATUSES } from "./core/types.ts";
26
+ import { detectCycle, flattenTasks, loadFeatureList, saveFeatureList } from "./core/featureList.ts";
27
+ import { fileExists, readJsonSafe, writeJsonAtomic } from "./core/fsx.ts";
28
+ import { loadConfig } from "./core/config.ts";
29
+ import { featureListPath, replanPath } from "./core/paths.ts";
30
+ import { withLockSync } from "./core/lock.ts";
31
+
32
+ /**
33
+ * Repo-relative label for the sidecar, kept for callers that display it.
34
+ * `core/paths.replanPath()` is what actually resolves the file.
35
+ */
36
+ export const REPLAN_FILE = "harness/replan.json";
37
+
38
+ /** @deprecated The plan's location belongs to `core/paths.featureListPath()`. */
39
+ export const FEATURE_LIST = "harness/features/feature-list.json";
40
+
41
+ export const DEFAULT_MAX_REPLANS = 2;
42
+
43
+ /** One task as submitted to `amendPlan`. Stored fields are shaped by `toStoredTask`. */
44
+ export type ReplanTaskInput = {
45
+ id: string;
46
+ key?: string;
47
+ description: string;
48
+ status?: string;
49
+ dependsOn?: string[];
50
+ subtasks?: Array<{ id: string; title: string; status: string }>;
51
+ difficulty?: string;
52
+ modelHint?: string;
53
+ acceptanceCriteria?: string[];
54
+ };
55
+
56
+ export interface AmendPlanOpts {
57
+ projectDir?: string;
58
+ reason?: string;
59
+ addSprints?: Array<{ id: string; name: string; goalId?: string; difficulty?: string }>;
60
+ addFeatures?: Array<{
61
+ id: string;
62
+ name: string;
63
+ description?: string;
64
+ sprintId?: string;
65
+ goalId?: string;
66
+ passes?: boolean;
67
+ tasks?: ReplanTaskInput[];
68
+ difficulty?: string;
69
+ }>;
70
+ addTasks?: Array<{ featureId: string; task: ReplanTaskInput }>;
71
+ }
72
+
73
+ export interface AmendPlanResult {
74
+ baseRevision: number;
75
+ added: { sprints: number; features: number; tasks: number };
76
+ }
77
+
78
+ /** One appended amendment. `harness/replan.json` is an array of these. */
79
+ export type ReplanHistoryEntry = {
80
+ timestamp: string;
81
+ reason: string;
82
+ added: AmendPlanResult["added"];
83
+ baseRevision: number;
84
+ };
85
+
86
+ function projectDirOf(p?: string): string {
87
+ return p ?? process.cwd();
88
+ }
89
+
90
+ /** Identity used for dependency resolution: the stable `key` when set, else the id. */
91
+ function taskKey(t: Pick<Task, "id" | "key">): string {
92
+ return t.key ?? t.id;
93
+ }
94
+
95
+ /**
96
+ * Load the plan through the core loader.
97
+ *
98
+ * Keeps the "missing file" error this module has always thrown: amending a
99
+ * project that was never planned is a caller mistake, not an empty plan to be
100
+ * seeded.
101
+ */
102
+ function loadPlan(projectDir: string): FeatureList {
103
+ const { list, path, existed } = loadFeatureList(projectDir);
104
+ if (!existed) throw new Error("feature-list.json missing: " + path);
105
+ return list;
106
+ }
107
+
108
+ // ── validation ──────────────────────────────────────────────────────────────
109
+
110
+ /** The vocabulary lives in `core/types`; this only reports it. */
111
+ function validateStatus(status: string): void {
112
+ if (!(TASK_STATUSES as readonly string[]).includes(status)) {
113
+ throw new Error(`invalid task status: ${status} (expected ${TASK_STATUSES.join("|")})`);
114
+ }
115
+ }
116
+
117
+ type DepView = { key: string; dependsOn: string[]; status: string };
118
+
119
+ /**
120
+ * Every dependency resolves, and nothing claims to be started or finished
121
+ * while something it depends on is not complete.
122
+ */
123
+ function validateDeps(tasks: DepView[]): void {
124
+ const byKey = new Map(tasks.map((t) => [t.key, t]));
125
+ for (const t of tasks) {
126
+ for (const dep of t.dependsOn ?? []) {
127
+ if (!byKey.has(dep)) throw new Error(`dependsOn references missing task ${dep} (from ${t.key})`);
128
+ }
129
+ if (t.status === "in_progress" || t.status === "complete") {
130
+ const unresolved = (t.dependsOn ?? []).filter((d) => byKey.get(d)?.status !== "complete");
131
+ if (unresolved.length > 0) {
132
+ throw new Error(`${t.key} cannot be ${t.status} while dependencies are unresolved: ${unresolved.join(", ")}`);
133
+ }
134
+ }
135
+ }
136
+ }
137
+
138
+ // ── sidecar: harness/replan.json ────────────────────────────────────────────
139
+
140
+ /** Read the history, tolerating the bare-array, `{ history }` and single-entry shapes. */
141
+ function readReplanHistory(projectDir: string): ReplanHistoryEntry[] {
142
+ const raw = readJsonSafe<unknown>(replanPath(projectDir), null);
143
+ if (Array.isArray(raw)) return raw as ReplanHistoryEntry[];
144
+ if (!raw || typeof raw !== "object") return [];
145
+ const obj = raw as { history?: unknown; reason?: unknown };
146
+ if (Array.isArray(obj.history)) return obj.history as ReplanHistoryEntry[];
147
+ return obj.reason ? [raw as ReplanHistoryEntry] : [];
148
+ }
149
+
150
+ /** Append an amendment. Locked, because it is a read-modify-write of the sidecar. */
151
+ function appendReplanHistory(projectDir: string, entry: ReplanHistoryEntry): void {
152
+ const path = replanPath(projectDir);
153
+ withLockSync(path, () => {
154
+ const history = readReplanHistory(projectDir);
155
+ history.push(entry);
156
+ writeJsonAtomic(path, history);
157
+ });
158
+ }
159
+
160
+ function readMaxReplans(projectDir: string): number {
161
+ const { config } = loadConfig(projectDir);
162
+ const replan = config.replan as { maxReplans?: unknown; maxReplansPerRun?: unknown } | undefined;
163
+ const budgets = config.budgets as { maxReplansPerRun?: unknown } | undefined;
164
+ if (typeof replan?.maxReplans === "number") return replan.maxReplans;
165
+ if (typeof replan?.maxReplansPerRun === "number") return replan.maxReplansPerRun;
166
+ if (typeof budgets?.maxReplansPerRun === "number") return budgets.maxReplansPerRun;
167
+ return DEFAULT_MAX_REPLANS;
168
+ }
169
+
170
+ // ── the amendment ───────────────────────────────────────────────────────────
171
+
172
+ /** Shape a submitted task into the stored form. One place, so the two add paths agree. */
173
+ function toStoredTask(t: ReplanTaskInput): Task {
174
+ return {
175
+ id: t.id,
176
+ key: t.key,
177
+ description: t.description,
178
+ status: (t.status ?? "pending") as TaskStatus,
179
+ dependsOn: t.dependsOn ?? [],
180
+ subtasks: (t.subtasks ?? []) as Subtask[],
181
+ difficulty: t.difficulty as Task["difficulty"],
182
+ modelHint: t.modelHint,
183
+ acceptanceCriteria: t.acceptanceCriteria ?? [],
184
+ };
185
+ }
186
+
187
+ export async function amendPlan(opts: AmendPlanOpts): Promise<AmendPlanResult> {
188
+ const projectDir = projectDirOf(opts.projectDir);
189
+ const maxReplans = readMaxReplans(projectDir);
190
+ const priorCount = readReplanHistory(projectDir).length;
191
+ if (priorCount >= maxReplans) {
192
+ throw new Error(`maxReplansPerRun exceeded: ${priorCount} >= ${maxReplans}`);
193
+ }
194
+
195
+ // Read, amend, validate, bump, write — one atomic section. Adding to the
196
+ // plan is a read-apply-write over the same file every parallel worker edits.
197
+ const result = withLockSync(featureListPath(projectDir), () => {
198
+ const list = loadPlan(projectDir);
199
+
200
+ let addedSprints = 0;
201
+ let addedFeatures = 0;
202
+ let addedTasks = 0;
203
+
204
+ // `sprints` is optional on the type; loadFeatureList always normalises it
205
+ // to an array, so bind it once rather than asserting at each use.
206
+ const sprints = (list.sprints ??= []);
207
+ for (const s of opts.addSprints ?? []) {
208
+ if (!s.id || !s.name) throw new Error("sprint requires id and name");
209
+ if (sprints.some((x) => x.id === s.id)) throw new Error(`duplicate sprint id: ${s.id}`);
210
+ sprints.push({
211
+ id: s.id,
212
+ name: s.name,
213
+ ...(s.goalId ? { goalId: s.goalId } : {}),
214
+ ...(s.difficulty ? { difficulty: s.difficulty } : {}),
215
+ });
216
+ addedSprints++;
217
+ }
218
+
219
+ for (const f of opts.addFeatures ?? []) {
220
+ if (!f.id || !f.name) throw new Error("feature requires id and name");
221
+ if (list.features.some((x) => x.id === f.id)) throw new Error(`duplicate feature id: ${f.id}`);
222
+ const feature: Feature = {
223
+ id: f.id,
224
+ name: f.name,
225
+ description: f.description ?? "",
226
+ passes: f.passes ?? false,
227
+ sprintId: f.sprintId,
228
+ goalId: f.goalId,
229
+ difficulty: f.difficulty,
230
+ tasks: (f.tasks ?? []).map(toStoredTask),
231
+ };
232
+ list.features.push(feature);
233
+ addedFeatures++;
234
+ }
235
+
236
+ for (const at of opts.addTasks ?? []) {
237
+ const feature = list.features.find((x) => x.id === at.featureId);
238
+ if (!feature) throw new Error(`feature not found for addTasks: ${at.featureId}`);
239
+ const t = at.task;
240
+ if (!t.id || !t.description) throw new Error("task requires id and description");
241
+ const key = taskKey(t);
242
+ // Keys are the currency of `dependsOn`, so they are unique plan-wide;
243
+ // ids only have to be unique inside their feature.
244
+ const globalKeys = new Set(flattenTasks(list).map(taskKey));
245
+ if (globalKeys.has(key)) throw new Error(`duplicate task key: ${key}`);
246
+ if ((feature.tasks ?? []).some((x) => x.id === t.id)) {
247
+ throw new Error(`duplicate task id in feature ${at.featureId}: ${t.id}`);
248
+ }
249
+ if (t.status) validateStatus(t.status);
250
+ feature.tasks = feature.tasks ?? [];
251
+ feature.tasks.push(toStoredTask(t));
252
+ addedTasks++;
253
+ }
254
+
255
+ // Re-validate the whole graph, not just what was added: an amendment can
256
+ // satisfy or break a dependency anywhere in the plan.
257
+ const all = flattenTasks(list);
258
+ const depView: DepView[] = all.map((t) => ({
259
+ key: taskKey(t),
260
+ dependsOn: t.dependsOn ?? [],
261
+ status: t.status ?? "pending",
262
+ }));
263
+ validateDeps(depView);
264
+ detectCycle(depView.map((t) => ({ compositeKey: t.key, dependsOn: t.dependsOn })));
265
+
266
+ const hasChange = addedSprints > 0 || addedFeatures > 0 || addedTasks > 0;
267
+ if (hasChange) list.baseRevision += 1;
268
+
269
+ saveFeatureList(projectDir, list);
270
+ return {
271
+ baseRevision: list.baseRevision,
272
+ added: { sprints: addedSprints, features: addedFeatures, tasks: addedTasks },
273
+ };
274
+ });
275
+
276
+ appendReplanHistory(projectDir, {
277
+ timestamp: new Date().toISOString(),
278
+ reason: opts.reason ?? "amendPlan",
279
+ added: result.added,
280
+ baseRevision: result.baseRevision,
281
+ });
282
+
283
+ return result;
284
+ }
285
+
286
+ export function loadReplanHistory(projectDir?: string): ReplanHistoryEntry[] {
287
+ return readReplanHistory(projectDirOf(projectDir));
288
+ }
289
+
290
+ export async function clearReplanHistory(projectDir?: string): Promise<void> {
291
+ const path = replanPath(projectDirOf(projectDir));
292
+ withLockSync(path, () => {
293
+ if (!fileExists(path)) return;
294
+ try {
295
+ unlinkSync(path);
296
+ } catch {
297
+ /* already gone — the point is that it is not there afterwards */
298
+ }
299
+ });
300
+ }
package/src/review.ts ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * review — bounce guard for REVIEW fail -> rework
3
+ * Fresh-read each call from harness/config.json, respects allowBackward, maxBounces, bounceRequiresDelta + fileDelta
4
+ * Pure helper used by enforcer/unstuck; does not mutate feature-list.json itself
5
+ */
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { resolve } from "node:path";
8
+ import { stripBom } from "./core/fsx.ts";
9
+
10
+ export interface ShouldBounceOpts {
11
+ projectDir?: string;
12
+ fileDelta: boolean;
13
+ bounceCount?: number;
14
+ }
15
+ export interface ShouldBounceResult {
16
+ shouldBounce: boolean;
17
+ reason: string;
18
+ maxBounces: number;
19
+ bounceCount: number;
20
+ }
21
+
22
+ function projectDirOf(pr?: string): string { return pr ? resolve(pr) : process.cwd(); }
23
+ function readConfig(projectDir: string): any {
24
+ try {
25
+ const q = resolve(projectDir, "harness", "config.json");
26
+ if (!existsSync(q)) return null;
27
+ return JSON.parse(stripBom(readFileSync(q, "utf-8")));
28
+ } catch { return null; }
29
+ }
30
+ function readBounceCount(projectDir: string): number {
31
+ try {
32
+ const rp = resolve(projectDir, "harness", "rework.json");
33
+ if (!existsSync(rp)) return 0;
34
+ const raw = JSON.parse(stripBom(readFileSync(rp, "utf-8")));
35
+ if (Array.isArray((raw as any).history)) return (raw as any).history.length;
36
+ if ((raw as any).returnTask) return 1;
37
+ if (Array.isArray(raw)) return raw.length;
38
+ return 0;
39
+ } catch { return 0; }
40
+ }
41
+
42
+ export function shouldBounceToRework(opts: ShouldBounceOpts): ShouldBounceResult {
43
+ const projectDir = projectDirOf(opts.projectDir);
44
+ const cfg = readConfig(projectDir);
45
+ const allowBackward = cfg?.review?.allowBackward ?? true;
46
+ const maxBounces = typeof cfg?.review?.maxBounces === "number" ? cfg.review.maxBounces : 2;
47
+ const bounceRequiresDelta = cfg?.review?.bounceRequiresDelta ?? true;
48
+ const bounceCount = typeof opts.bounceCount === "number" ? opts.bounceCount : readBounceCount(projectDir);
49
+ if (!allowBackward) return { shouldBounce: false, reason: "review.allowBackward false", maxBounces, bounceCount };
50
+ if (bounceCount >= maxBounces) return { shouldBounce: false, reason: "maxBounces " + bounceCount + " >= " + maxBounces, maxBounces, bounceCount };
51
+ if (bounceRequiresDelta && !opts.fileDelta) return { shouldBounce: false, reason: "bounceRequiresDelta true and no fileDelta", maxBounces, bounceCount };
52
+ return { shouldBounce: true, reason: "bounce eligible", maxBounces, bounceCount };
53
+ }
package/src/rework.ts ADDED
@@ -0,0 +1,274 @@
1
+ /**
2
+ * infinity-harness — rework: the backward edge, with return-to-origin.
3
+ *
4
+ * A task that fails late rarely fails alone: whatever depends on it is suspect
5
+ * too. `startRework` walks the `dependsOn` graph forward from the origin, flips
6
+ * the origin and everything it reaches within `maxImpactDepth` to "rework", and
7
+ * records where the pipeline must return to in `harness/rework.json`.
8
+ *
9
+ * Plan I/O, sidecar I/O, config reads, paths and locking all come from
10
+ * `core/`. This module used to carry private copies of each, and they had
11
+ * quietly drifted: the private plan loader did a raw `readFileSync` +
12
+ * `JSON.parse`, so it neither normalised status aliases (a task stored as
13
+ * "done" never compared equal to "complete") nor fell back to the `.bak` after
14
+ * a corrupt write, and its saver did a bare tmp+rename with no backup. The
15
+ * private lock helper wrapped `proper-lockfile` on `<path>.lock` while the
16
+ * plan writer takes `<path>.ilock`, so the two never actually excluded each
17
+ * other. One implementation of each now, in `core/`.
18
+ */
19
+
20
+ import { unlinkSync } from "node:fs";
21
+ import type { FeatureList, Task } from "./core/types.ts";
22
+ import { flattenTasks, loadFeatureList, saveFeatureList } from "./core/featureList.ts";
23
+ import { fileExists, readJsonSafe, writeJsonAtomic } from "./core/fsx.ts";
24
+ import { loadConfig } from "./core/config.ts";
25
+ import { featureListPath, reworkPath } from "./core/paths.ts";
26
+ import { withLockSync } from "./core/lock.ts";
27
+
28
+ /**
29
+ * Repo-relative label for the sidecar, kept for callers that display it.
30
+ * `core/paths.reworkPath()` is what actually resolves the file.
31
+ */
32
+ export const REWORK_FILE = "harness/rework.json";
33
+
34
+ export const DEFAULT_MAX_REWORKS = 3;
35
+ export const DEFAULT_IMPACT_DEPTH = 3;
36
+
37
+ export interface ReworkRecord {
38
+ runId: string;
39
+ returnFeature: string;
40
+ returnTask: string;
41
+ impacted: string[];
42
+ reason: string;
43
+ timestamp: string;
44
+ remainingBudgets?: { reworks: number; replans: number; bounces: number };
45
+ maxImpactDepth?: number;
46
+ }
47
+
48
+ export interface StartReworkOpts {
49
+ projectDir?: string;
50
+ featureId: string;
51
+ taskId: string;
52
+ reason?: string;
53
+ runId?: string;
54
+ maxImpactDepth?: number;
55
+ key?: string;
56
+ }
57
+
58
+ export type StartReworkResult = {
59
+ impacted: string[];
60
+ baseRevision: number;
61
+ rework: ReworkRecord;
62
+ };
63
+
64
+ function projectDirOf(p?: string): string {
65
+ return p ?? process.cwd();
66
+ }
67
+
68
+ /** The minimum shape the impact walk needs: an identity and its dependencies. */
69
+ export type ImpactTask = Pick<Task, "id" | "key" | "dependsOn">;
70
+
71
+ /**
72
+ * Identity used by the rework graph: the stable `key` when set, else the id.
73
+ *
74
+ * Deliberately *not* `flattenTasks`' `compositeKey`, which falls back to
75
+ * `featureId/id`. `dependsOn` entries in the plan are written in this
76
+ * spelling, and matching them is the whole job here.
77
+ */
78
+ function taskKey(t: ImpactTask): string {
79
+ return t.key ?? t.id;
80
+ }
81
+
82
+ /**
83
+ * Load the plan through the core loader.
84
+ *
85
+ * Keeps the "missing file" error this module has always thrown: a rework
86
+ * against a project that was never planned is a caller mistake, not an empty
87
+ * plan to be seeded.
88
+ */
89
+ function loadPlan(projectDir: string): FeatureList {
90
+ const { list, path, existed } = loadFeatureList(projectDir);
91
+ if (!existed) throw new Error("feature-list.json missing: " + path);
92
+ return list;
93
+ }
94
+
95
+ /** Breadth-first walk over the reverse-dependency edges, capped at `maxDepth`. */
96
+ export function computeImpact(
97
+ allTasks: readonly ImpactTask[],
98
+ originKey: string,
99
+ maxDepth: number,
100
+ ): string[] {
101
+ const impacted: string[] = [];
102
+ const visited = new Set<string>([originKey]);
103
+ let frontier: Array<{ key: string; depth: number }> = [{ key: originKey, depth: 0 }];
104
+ while (frontier.length) {
105
+ const next: Array<{ key: string; depth: number }> = [];
106
+ for (const cur of frontier) {
107
+ if (cur.depth >= maxDepth) continue;
108
+ for (const t of allTasks) {
109
+ const k = taskKey(t);
110
+ if (visited.has(k)) continue;
111
+ if ((t.dependsOn ?? []).includes(cur.key)) {
112
+ visited.add(k);
113
+ impacted.push(k);
114
+ next.push({ key: k, depth: cur.depth + 1 });
115
+ }
116
+ }
117
+ }
118
+ frontier = next;
119
+ }
120
+ return impacted;
121
+ }
122
+
123
+ // ── sidecar: harness/rework.json ────────────────────────────────────────────
124
+
125
+ /** On disk this is either one bare record (the first write) or `{ history }`. */
126
+ type ReworkSidecar = { history?: unknown; returnTask?: unknown };
127
+
128
+ function readSidecar(projectDir: string): ReworkSidecar | null {
129
+ const raw = readJsonSafe<unknown>(reworkPath(projectDir), null);
130
+ return raw && typeof raw === "object" ? (raw as ReworkSidecar) : null;
131
+ }
132
+
133
+ function countPriorReworks(projectDir: string): number {
134
+ const prev = readSidecar(projectDir);
135
+ if (!prev) return 0;
136
+ if (Array.isArray(prev.history)) return prev.history.length;
137
+ return prev.returnTask ? 1 : 0;
138
+ }
139
+
140
+ /**
141
+ * Append a record, promoting a legacy single-record file to `{ history }`.
142
+ * Locked, because it is a read-modify-write of the sidecar.
143
+ */
144
+ function appendReworkRecord(projectDir: string, record: ReworkRecord): void {
145
+ const path = reworkPath(projectDir);
146
+ withLockSync(path, () => {
147
+ const prev = readSidecar(projectDir);
148
+ if (!prev) {
149
+ writeJsonAtomic(path, { ...record });
150
+ return;
151
+ }
152
+ let history: ReworkRecord[];
153
+ if (Array.isArray(prev.history)) {
154
+ history = [...(prev.history as ReworkRecord[])];
155
+ } else if (prev.returnTask) {
156
+ const { history: _legacy, ...rest } = prev;
157
+ history = [rest as unknown as ReworkRecord];
158
+ } else {
159
+ history = [];
160
+ }
161
+ history.push({ ...record });
162
+ writeJsonAtomic(path, { history });
163
+ });
164
+ }
165
+
166
+ function readMaxReworks(projectDir: string): number {
167
+ const { config } = loadConfig(projectDir);
168
+ const rework = config.rework as { maxReworks?: unknown } | undefined;
169
+ const budgets = config.budgets as { maxReworksPerRun?: unknown } | undefined;
170
+ if (typeof rework?.maxReworks === "number") return rework.maxReworks;
171
+ if (typeof budgets?.maxReworksPerRun === "number") return budgets.maxReworksPerRun;
172
+ return DEFAULT_MAX_REWORKS;
173
+ }
174
+
175
+ // ── the backward edge ───────────────────────────────────────────────────────
176
+
177
+ /**
178
+ * Resolve which spelling of the origin the caller meant.
179
+ *
180
+ * Callers pass a bare id, a `key`, or a `featureId/taskId` composite more or
181
+ * less interchangeably; the plan stores one of them. First match wins, in
182
+ * order of specificity.
183
+ */
184
+ function resolveOriginKey(tasks: readonly ImpactTask[], opts: StartReworkOpts): string {
185
+ const explicitKey = opts.key ?? opts.taskId;
186
+ const byId = tasks.find((t) => t.id === opts.taskId);
187
+ const candidates = [
188
+ ...(byId ? [taskKey(byId)] : []),
189
+ explicitKey,
190
+ `${opts.featureId}/${opts.taskId}`,
191
+ opts.taskId,
192
+ ];
193
+ for (const cand of candidates) {
194
+ if (tasks.some((t) => taskKey(t) === cand)) return cand;
195
+ }
196
+ return explicitKey;
197
+ }
198
+
199
+ export async function startRework(opts: StartReworkOpts): Promise<StartReworkResult> {
200
+ const projectDir = projectDirOf(opts.projectDir);
201
+ const maxDepth = opts.maxImpactDepth ?? DEFAULT_IMPACT_DEPTH;
202
+ const originKey = resolveOriginKey(flattenTasks(loadPlan(projectDir)), opts);
203
+
204
+ const maxReworks = readMaxReworks(projectDir);
205
+ const priorCount = countPriorReworks(projectDir);
206
+ if (priorCount >= maxReworks) {
207
+ throw new Error("maxReworksPerRun exceeded: " + priorCount + " >= " + maxReworks);
208
+ }
209
+
210
+ // Read, flip, bump, write — one atomic section. The status flip is a
211
+ // read-apply-write over the same file every parallel worker edits.
212
+ const result = withLockSync(featureListPath(projectDir), () => {
213
+ const list = loadPlan(projectDir);
214
+ const tasks = flattenTasks(list);
215
+ if (!tasks.some((t) => taskKey(t) === originKey)) {
216
+ throw new Error("origin task not found: " + originKey);
217
+ }
218
+
219
+ const impacted = computeImpact(tasks, originKey, maxDepth);
220
+ const toRework = new Set<string>([originKey, ...impacted]);
221
+
222
+ // flattenTasks hands back copies, so the flip walks the stored tasks.
223
+ let changed = false;
224
+ for (const feature of list.features) {
225
+ for (const t of feature.tasks ?? []) {
226
+ if (toRework.has(taskKey(t)) && t.status !== "rework") {
227
+ t.status = "rework";
228
+ changed = true;
229
+ }
230
+ }
231
+ }
232
+ if (changed) list.baseRevision += 1;
233
+ saveFeatureList(projectDir, list);
234
+
235
+ const record: ReworkRecord = {
236
+ runId: opts.runId ?? "run-" + Date.now(),
237
+ returnFeature: opts.featureId,
238
+ returnTask: opts.taskId,
239
+ impacted,
240
+ reason: opts.reason ?? "rework via startRework",
241
+ timestamp: new Date().toISOString(),
242
+ remainingBudgets: { reworks: maxReworks - priorCount - 1, replans: 2, bounces: 2 },
243
+ maxImpactDepth: maxDepth,
244
+ };
245
+ return { impacted, baseRevision: list.baseRevision, record };
246
+ });
247
+
248
+ appendReworkRecord(projectDir, result.record);
249
+
250
+ return { impacted: result.impacted, baseRevision: result.baseRevision, rework: result.record };
251
+ }
252
+
253
+ /** The most recent rework record, or null when there is none to return to. */
254
+ export function loadRework(projectDir?: string): ReworkRecord | null {
255
+ const prev = readSidecar(projectDirOf(projectDir));
256
+ if (!prev) return null;
257
+ if (Array.isArray(prev.history)) {
258
+ const history = prev.history as ReworkRecord[];
259
+ return history[history.length - 1] ?? null;
260
+ }
261
+ return prev as unknown as ReworkRecord;
262
+ }
263
+
264
+ export async function clearRework(projectDir?: string): Promise<void> {
265
+ const path = reworkPath(projectDirOf(projectDir));
266
+ withLockSync(path, () => {
267
+ if (!fileExists(path)) return;
268
+ try {
269
+ unlinkSync(path);
270
+ } catch {
271
+ /* already gone — the point is that it is not there afterwards */
272
+ }
273
+ });
274
+ }