infinity-harness 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core/gates.ts CHANGED
@@ -12,12 +12,13 @@
12
12
  */
13
13
 
14
14
  import { resolve } from "node:path";
15
- import { readdirSync, statSync } from "node:fs";
15
+ import { existsSync, readdirSync, statSync } from "node:fs";
16
16
  import type { CheckResult, GateResult, HarnessConfig, Phase } from "./types.ts";
17
17
  import { loadConfig, saveConfig, recordGate } from "./config.ts";
18
18
  import { loadFeatureList, findTask, findFeature, computeProgress, isDone } from "./featureList.ts";
19
19
  import * as P from "./paths.ts";
20
20
  import { readText, fileExists } from "./fsx.ts";
21
+ import { auditSkillsDir } from "./skillsAudit.ts";
21
22
  import {
22
23
  run,
23
24
  isGitRepo,
@@ -230,6 +231,39 @@ async function checkRubricContent({ targetDir }: Ctx): Promise<CheckResult> {
230
231
  return docCheck("rubric-content", P.rubricPath(targetDir), 100, "harness/evaluator-rubric.md");
231
232
  }
232
233
 
234
+ /**
235
+ * Any skills this project ships must be loadable by pi.
236
+ *
237
+ * Advisory, because a malformed skill does not make the code wrong — it makes
238
+ * pi print a `[Skill conflicts]` block on every start, which is exactly the
239
+ * kind of thing that gets ignored for months. This package learned that the
240
+ * hard way from its own README sitting in its own skills directory. Reporting
241
+ * it in the gate is how a project finds out before its users do.
242
+ */
243
+ async function checkSkillsLoad({ targetDir }: Ctx): Promise<CheckResult> {
244
+ const dirs = [P.skillsDir(targetDir), resolve(targetDir, ".pi", "skills"), resolve(targetDir, ".agents", "skills")];
245
+ const present = dirs.filter((d) => existsSync(d));
246
+ if (present.length === 0) {
247
+ return { ...skip("skills-load", "this project ships no skills"), advisory: true };
248
+ }
249
+
250
+ const problems: string[] = [];
251
+ let count = 0;
252
+ for (const dir of present) {
253
+ const audit = auditSkillsDir(dir);
254
+ count += audit.skills.length;
255
+ for (const p of audit.problems) {
256
+ problems.push(`${p.file.replace(`${targetDir}/`, "")}: ${p.message}`);
257
+ }
258
+ }
259
+ return problems.length === 0
260
+ ? { ...pass("skills-load", `${count} skill(s) load cleanly in pi`), advisory: true }
261
+ : {
262
+ ...fail("skills-load", `pi would report a skill conflict — ${problems.slice(0, 4).join("; ")}`),
263
+ advisory: true,
264
+ };
265
+ }
266
+
233
267
  async function checkTagged({ targetDir }: Ctx): Promise<CheckResult> {
234
268
  return (await gitHasTag(targetDir))
235
269
  ? pass("tagged", "HEAD carries a release tag")
@@ -311,12 +345,12 @@ type Check = (ctx: Ctx) => Promise<CheckResult>;
311
345
 
312
346
  const PHASE_CHECKS: Record<Phase, Check[]> = {
313
347
  init: [checkGitRepo, checkConfigExists],
314
- define: [checkFeatureCriteria],
348
+ define: [checkFeatureCriteria, checkSkillsLoad],
315
349
  plan: [checkFeatureCriteria, checkTasksPlanned],
316
350
  build: [checkLint, checkTests, checkCoverage, checkNoPlaceholders, checkTasksComplete],
317
351
  verify: [checkTests, checkCoverage, checkGitClean],
318
352
  simplify: [checkTests, checkNoEmptyDirs, checkGitClean],
319
- review: [checkBranchUpToDate, checkRubricContent, checkReadme, checkArchitectureDoc, checkDecisionsLogged],
353
+ review: [checkBranchUpToDate, checkRubricContent, checkReadme, checkArchitectureDoc, checkDecisionsLogged, checkSkillsLoad],
320
354
  ship: [
321
355
  checkGitClean,
322
356
  checkTagged,
@@ -0,0 +1,370 @@
1
+ /**
2
+ * infinity-harness — the escalation ladder, actually connected.
3
+ *
4
+ * `unstuck.ts` has always been able to choose what to do when a run stalls:
5
+ * retry → reframe → consult → rework → replan → master, with budgets,
6
+ * fingerprint dedup and a cooldown. It picks a strategy name and returns it.
7
+ *
8
+ * Nothing ever executed one. There was a chooser and no actuator, so
9
+ * `chooseUnstuckStrategy` was called by its own tests and by nothing else,
10
+ * and `/infinity:run` did the only thing it could when the gate kept failing:
11
+ * count strikes and stop. A run that could have escalated to a stronger model,
12
+ * reworked the task that poisoned everything downstream, or amended a plan
13
+ * that turned out to be wrong instead sat there failing the same check three
14
+ * times and gave up.
15
+ *
16
+ * This module is the actuator. It asks `unstuck` what to do, does the part
17
+ * that is ours to do — flipping tasks to `rework`, naming the model to
18
+ * escalate to — and returns an instruction the agent can act on for the part
19
+ * that is the agent's.
20
+ *
21
+ * It deliberately does not implement `replan` itself. Inventing the tasks a
22
+ * stuck plan is missing is a modelling job, not a control-flow job; the ladder
23
+ * tells the agent to amend the plan and hands it `infinity_replan`.
24
+ */
25
+
26
+ import { loadFeatureList, flattenTasks, type FlatTask } from "./core/featureList.ts";
27
+ import type { Phase } from "./core/types.ts";
28
+ import { chooseUnstuckStrategy, type UnstuckStrategy } from "./unstuck.ts";
29
+ import { shouldBounceToRework } from "./review.ts";
30
+ import { startRework, loadRework } from "./rework.ts";
31
+ import { loadReplanHistory } from "./replan.ts";
32
+ import { loadRouterConfig } from "./modelRouter.ts";
33
+
34
+ export type EscalationState = {
35
+ /** How many times this run has consulted a stronger model. */
36
+ consultedCount: number;
37
+ /** MASTER is a one-shot. */
38
+ masterUsed: boolean;
39
+ /** ISO timestamp of the last escalation, for the cooldown. */
40
+ lastUnstuckAt: string | null;
41
+ /** Working-tree fingerprints already seen, for dedup. */
42
+ fingerprints: string[];
43
+ /** Rungs already taken during this stall, so the ladder climbs. */
44
+ tried: UnstuckStrategy[];
45
+ };
46
+
47
+ export function emptyEscalationState(): EscalationState {
48
+ return { consultedCount: 0, masterUsed: false, lastUnstuckAt: null, fingerprints: [], tried: [] };
49
+ }
50
+
51
+ export type Escalation = {
52
+ strategy: UnstuckStrategy | null;
53
+ /** Why this strategy, in words the human reading the log will understand. */
54
+ reason: string;
55
+ /** What the agent should now do. Null when nothing can be done. */
56
+ instruction: string | null;
57
+ /** The model to escalate to, for `consult` and `master`. */
58
+ model: string | null;
59
+ /** What this actually changed on disk, if anything. */
60
+ applied: string | null;
61
+ /** The state to carry into the next decision. */
62
+ next: EscalationState;
63
+ };
64
+
65
+ export type EscalateOptions = {
66
+ targetDir: string;
67
+ runId: string;
68
+ phase: Phase;
69
+ /** The gate's failing checks, so the instruction can name them. */
70
+ failures: string[];
71
+ /** Whether the working tree moved since the last attempt. */
72
+ fileDelta: boolean;
73
+ /** Fingerprint of the current attempt. */
74
+ fingerprint: string;
75
+ state: EscalationState;
76
+ now?: Date;
77
+ };
78
+
79
+ /** Fingerprints kept for dedup. Older ones tell us nothing. */
80
+ const FINGERPRINT_WINDOW = 12;
81
+
82
+ /**
83
+ * Decide what to do about a stalled run, and do the part that is ours.
84
+ *
85
+ * Never throws: an escalation that fails is a run that continues without one,
86
+ * not a run that dies. The failure is reported in `reason`, because a silent
87
+ * fallback to "retry" would look exactly like a healthy ladder.
88
+ */
89
+ export async function escalate(options: EscalateOptions): Promise<Escalation> {
90
+ const { targetDir, phase, state } = options;
91
+ const now = options.now ?? new Date();
92
+
93
+ const carry = (strategy: UnstuckStrategy | null, over: Partial<EscalationState> = {}): EscalationState => ({
94
+ ...state,
95
+ ...over,
96
+ lastUnstuckAt: now.toISOString(),
97
+ fingerprints: [...state.fingerprints, options.fingerprint].slice(-FINGERPRINT_WINDOW),
98
+ tried: strategy && !state.tried.includes(strategy) ? [...state.tried, strategy] : state.tried,
99
+ });
100
+
101
+ const { list } = loadFeatureList(targetDir);
102
+ const tasks = flattenTasks(list);
103
+ const task = currentTask(tasks);
104
+
105
+ // REVIEW is its own decision before the general ladder. A failing review is
106
+ // not the reviewer being stuck; it is the reviewer saying the work is wrong,
107
+ // and the answer to that is going back to the work.
108
+ if (phase === "review") {
109
+ // `shouldBounceToRework` reads its own bounce count from rework.json,
110
+ // which is the right source: bounces are reworks, not replans.
111
+ const bounce = shouldBounceToRework({ projectDir: targetDir, fileDelta: options.fileDelta });
112
+ if (bounce.shouldBounce && task) {
113
+ const applied = await applyRework(options, task, "review bounce");
114
+ return {
115
+ strategy: "rework",
116
+ reason: `review bounce: ${bounce.reason}`,
117
+ instruction: reworkInstruction(task, applied.impacted, options.failures),
118
+ model: null,
119
+ applied: applied.detail,
120
+ next: carry("rework"),
121
+ };
122
+ }
123
+ }
124
+
125
+ let choice;
126
+ try {
127
+ choice = chooseUnstuckStrategy({
128
+ projectDir: targetDir,
129
+ featureId: task?.featureId,
130
+ taskId: task?.id,
131
+ attemptFingerprints: state.fingerprints,
132
+ currentFingerprint: options.fingerprint,
133
+ fileDelta: options.fileDelta,
134
+ tried: state.tried,
135
+ // A stall is defined by the tree not having moved, so the delta guard —
136
+ // which exists for review bounces — must not veto the rungs that exist
137
+ // for exactly this situation.
138
+ requireDeltaForRework: false,
139
+ lastUnstuckAt: state.lastUnstuckAt ?? undefined,
140
+ consultedCount: state.consultedCount,
141
+ currentDifficulty: task?.difficulty ?? null,
142
+ masterUsed: state.masterUsed,
143
+ });
144
+ } catch (e) {
145
+ return {
146
+ strategy: null,
147
+ reason: `the escalation ladder could not run: ${message(e)}`,
148
+ instruction: null,
149
+ model: null,
150
+ applied: null,
151
+ next: carry(null),
152
+ };
153
+ }
154
+
155
+ const failureList = options.failures.length
156
+ ? options.failures.map((f) => ` - ${f}`).join("\n")
157
+ : " - (the gate reported no detail)";
158
+
159
+ switch (choice.strategy) {
160
+ case "retry":
161
+ return {
162
+ strategy: "retry",
163
+ reason: choice.reason,
164
+ instruction:
165
+ `The ${phase.toUpperCase()} gate failed again and the working tree has not moved. One ` +
166
+ `more attempt before this changes tack — go straight at the failure, and change ` +
167
+ `something real this time:\n${failureList}`,
168
+ model: null,
169
+ applied: null,
170
+ next: carry("retry"),
171
+ };
172
+
173
+ case "reframe":
174
+ return {
175
+ strategy: "reframe",
176
+ reason: choice.reason,
177
+ instruction:
178
+ `STOP AND REFRAME. The same gate has now failed repeatedly with the same result, which ` +
179
+ `means the approach is wrong, not incomplete. Before writing another line:\n` +
180
+ ` 1. State the assumption you have been working under.\n` +
181
+ ` 2. Say why the evidence contradicts it — quote the failure.\n` +
182
+ ` 3. Describe a different approach, and only then implement it.\n\n` +
183
+ `Still failing:\n${failureList}`,
184
+ model: null,
185
+ applied: null,
186
+ next: carry("reframe"),
187
+ };
188
+
189
+ case "consult": {
190
+ const model = choice.nextModel ?? null;
191
+ return {
192
+ strategy: "consult",
193
+ reason: choice.reason,
194
+ instruction:
195
+ `ESCALATE. Reframing did not shift this either, so it is going to a stronger model` +
196
+ (model ? `: ${model}` : "") +
197
+ `. Write down, precisely, what you have tried and what the failure actually says — ` +
198
+ `that hand-off is the whole value of the escalation.\n\nStill failing:\n${failureList}`,
199
+ model,
200
+ applied: model ? `consulting ${model}` : null,
201
+ next: carry("consult", { consultedCount: state.consultedCount + 1 }),
202
+ };
203
+ }
204
+
205
+ case "rework": {
206
+ if (!task) {
207
+ return {
208
+ strategy: null,
209
+ reason: "rework was chosen but there is no actionable task to rework",
210
+ instruction: null,
211
+ model: null,
212
+ applied: null,
213
+ next: carry(null),
214
+ };
215
+ }
216
+ const applied = await applyRework(options, task, `stuck on ${phase}`);
217
+ if (applied.error) {
218
+ return {
219
+ strategy: null,
220
+ reason: `rework failed: ${applied.error}`,
221
+ instruction: null,
222
+ model: null,
223
+ applied: null,
224
+ next: carry(null),
225
+ };
226
+ }
227
+ return {
228
+ strategy: "rework",
229
+ reason: choice.reason,
230
+ instruction: reworkInstruction(task, applied.impacted, options.failures),
231
+ model: null,
232
+ applied: applied.detail,
233
+ next: carry("rework"),
234
+ };
235
+ }
236
+
237
+ case "replan":
238
+ return {
239
+ strategy: "replan",
240
+ reason: choice.reason,
241
+ instruction:
242
+ `THE PLAN IS WRONG. Retrying, reframing and reworking have all failed, which points at ` +
243
+ `the plan rather than the work: something this needs was never planned. Call ` +
244
+ `\`infinity_replan\` to add the missing sprints, features or tasks — do not invent them ` +
245
+ `in code and leave the plan stale.\n\nStill failing:\n${failureList}`,
246
+ model: null,
247
+ applied: null,
248
+ next: carry("replan"),
249
+ };
250
+
251
+ case "master": {
252
+ const model = masterModel(targetDir);
253
+ return {
254
+ strategy: "master",
255
+ reason: choice.reason,
256
+ instruction:
257
+ `LAST RESORT. Every other rung of the ladder is spent` +
258
+ (model ? `, so this goes to the master model: ${model}` : "") +
259
+ `. State the problem from scratch, as if to someone who has not seen any of the ` +
260
+ `previous attempts, and include what has already been ruled out.\n\n` +
261
+ `Still failing:\n${failureList}`,
262
+ model,
263
+ applied: model ? `escalated to master (${model})` : null,
264
+ next: carry("master", { masterUsed: true }),
265
+ };
266
+ }
267
+
268
+ default:
269
+ return {
270
+ strategy: null,
271
+ reason: choice.reason || "no strategy available",
272
+ instruction: null,
273
+ model: null,
274
+ applied: null,
275
+ next: carry(null),
276
+ };
277
+ }
278
+ }
279
+
280
+ /**
281
+ * The task the run is stuck on: whatever is in flight, else the first thing
282
+ * that is not finished. `rework` needs a specific origin to walk out from.
283
+ */
284
+ function currentTask(tasks: FlatTask[]): FlatTask | null {
285
+ return (
286
+ tasks.find((t) => t.status === "in_progress") ??
287
+ tasks.find((t) => t.status === "rework") ??
288
+ tasks.find((t) => t.status !== "complete") ??
289
+ null
290
+ );
291
+ }
292
+
293
+ async function applyRework(
294
+ options: EscalateOptions,
295
+ task: FlatTask,
296
+ reason: string,
297
+ ): Promise<{ impacted: string[]; detail: string | null; error?: string }> {
298
+ try {
299
+ const result = await startRework({
300
+ projectDir: options.targetDir,
301
+ featureId: task.featureId,
302
+ taskId: task.id,
303
+ key: task.key,
304
+ reason,
305
+ runId: options.runId,
306
+ });
307
+ const detail =
308
+ `flipped ${task.compositeKey}` +
309
+ (result.impacted.length ? ` and ${result.impacted.length} dependent task(s)` : "") +
310
+ ` to rework (plan revision ${result.baseRevision})`;
311
+ return { impacted: result.impacted, detail };
312
+ } catch (e) {
313
+ return { impacted: [], detail: null, error: message(e) };
314
+ }
315
+ }
316
+
317
+ function reworkInstruction(task: FlatTask, impacted: string[], failures: string[]): string {
318
+ const downstream = impacted.length
319
+ ? `Everything that depends on it went with it: ${impacted.join(", ")}. ` +
320
+ `They were built on the broken thing, so they are suspect until re-proved.`
321
+ : `Nothing depends on it, so this is contained.`;
322
+ return (
323
+ `REWORK. This task has been flipped back to \`rework\` because the work built on it does not ` +
324
+ `hold up:\n ${task.compositeKey} — ${task.description}\n\n${downstream}\n\n` +
325
+ `Fix the root task first, then re-prove the rest. Failing checks:\n` +
326
+ (failures.length ? failures.map((f) => ` - ${f}`).join("\n") : " - (no detail)")
327
+ );
328
+ }
329
+
330
+ function masterModel(targetDir: string): string | null {
331
+ try {
332
+ const router = loadRouterConfig(targetDir);
333
+ const master = (router as { master?: unknown }).master;
334
+ return typeof master === "string" && master.trim() ? master.trim() : null;
335
+ } catch {
336
+ return null;
337
+ }
338
+ }
339
+
340
+ function message(e: unknown): string {
341
+ return e instanceof Error ? e.message : String(e);
342
+ }
343
+
344
+ /** A one-line summary for the widget, the log, and the human coming back. */
345
+ export function describeEscalation(e: Escalation): string {
346
+ if (!e.strategy) return `no escalation available — ${e.reason}`;
347
+ return `${e.strategy}: ${e.reason}${e.applied ? ` (${e.applied})` : ""}`;
348
+ }
349
+
350
+ /** Where the run currently sits on the ladder, for the status surfaces. */
351
+ export function escalationSummary(targetDir: string): { reworks: number; replans: number; returnTo: string | null } {
352
+ let reworks = 0;
353
+ let returnTo: string | null = null;
354
+ try {
355
+ const record = loadRework(targetDir);
356
+ if (record) {
357
+ returnTo = `${record.returnFeature}/${record.returnTask}`;
358
+ reworks = 1;
359
+ }
360
+ } catch {
361
+ /* absent is not an error */
362
+ }
363
+ let replans = 0;
364
+ try {
365
+ replans = loadReplanHistory(targetDir).length;
366
+ } catch {
367
+ /* absent is not an error */
368
+ }
369
+ return { reworks, replans, returnTo };
370
+ }