infinity-harness 2.0.4 → 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/taskList.ts CHANGED
@@ -49,9 +49,44 @@ export type TaskInput = {
49
49
  criteria?: string[];
50
50
  };
51
51
 
52
+ /**
53
+ * Feature metadata, supplied alongside the tasks.
54
+ *
55
+ * Features themselves are derived from task keys — `feature-002/task-004`
56
+ * creates `feature-002` — which left no way at all to give a feature a name or
57
+ * its acceptance criteria. The DEFINE gate requires criteria on every feature,
58
+ * so the first gate in the pipeline could not be passed through the tools: the
59
+ * only route was hand-editing the plan file, which the brief tells you not to
60
+ * do.
61
+ *
62
+ * Unlike `tasks`, this is a merge and never a deletion. Omission means
63
+ * deletion for tasks because the model has to submit the authoritative list;
64
+ * features are not submitted at all, they are inferred, so omitting one here
65
+ * means "nothing to say about it", not "remove it".
66
+ */
67
+ export type FeatureInput = {
68
+ id: string;
69
+ name?: string;
70
+ description?: string;
71
+ criteria?: string[];
72
+ };
73
+
52
74
  export type ApplyInput = {
53
75
  baseRevision?: number;
54
- tasks: TaskInput[];
76
+ /**
77
+ * The complete, authoritative task list. Omission means deletion — one
78
+ * unambiguous rule beats incremental edits a model loses track of.
79
+ *
80
+ * Leaving the whole field out is different from sending `[]`: absent means
81
+ * "I am not touching the tasks", empty means "delete them all". DEFINE needs
82
+ * that distinction, because criteria are written there and tasks do not
83
+ * exist until PLAN.
84
+ */
85
+ tasks?: TaskInput[];
86
+ /** Names and acceptance criteria, merged onto features by id. */
87
+ features?: FeatureInput[];
88
+ /** The one-line statement of what this whole run is for. */
89
+ goal?: string;
55
90
  };
56
91
 
57
92
  export type Change = {
@@ -96,6 +131,34 @@ function validateSubtasks(raw: TaskInput["subtasks"], path: string): Subtask[] {
96
131
  });
97
132
  }
98
133
 
134
+ /** At most this many features may be described in one submission. */
135
+ const MAX_FEATURES = 100;
136
+ /** And this many acceptance criteria on any one of them. */
137
+ const MAX_CRITERIA = 40;
138
+
139
+ function bounded(value: string, max: number, path: string): string {
140
+ const trimmed = String(value ?? "").trim();
141
+ if (trimmed.length > max) {
142
+ throw new ValidationError(`${path} exceeds ${max} characters (${trimmed.length})`);
143
+ }
144
+ return trimmed;
145
+ }
146
+
147
+ function validateCriteria(raw: unknown, path: string): string[] {
148
+ if (!Array.isArray(raw)) throw new ValidationError(`${path} must be an array`);
149
+ if (raw.length > MAX_CRITERIA) {
150
+ throw new ValidationError(`${path} supports at most ${MAX_CRITERIA} entries, got ${raw.length}`);
151
+ }
152
+ const out: string[] = [];
153
+ for (const [i, entry] of raw.entries()) {
154
+ const text = bounded(String(entry ?? ""), MAX_SUBJECT_LEN, `${path}[${i}]`);
155
+ // An empty criterion is worse than none: it looks like the work was done.
156
+ if (!text) throw new ValidationError(`${path}[${i}] must be non-empty`);
157
+ if (!out.includes(text)) out.push(text);
158
+ }
159
+ return out;
160
+ }
161
+
99
162
  function validateDependsOn(raw: string[] | undefined, path: string): string[] {
100
163
  if (!Array.isArray(raw)) return [];
101
164
  if (raw.length > MAX_DEPENDS_ON) {
@@ -126,11 +189,24 @@ export function applyTaskList(current: FeatureList, input: ApplyInput): ApplyRes
126
189
  `Re-read the plan and resubmit.`,
127
190
  );
128
191
  }
129
- if (!Array.isArray(input.tasks)) {
192
+ if (input.tasks !== undefined && !Array.isArray(input.tasks)) {
130
193
  throw new ValidationError("tasks must be an array");
131
194
  }
132
- if (input.tasks.length > MAX_TASKS) {
133
- throw new ValidationError(`tasks supports at most ${MAX_TASKS} items, got ${input.tasks.length}`);
195
+ if (input.tasks === undefined && input.features === undefined && input.goal === undefined) {
196
+ throw new ValidationError("nothing submitted: send tasks, features, or a goal");
197
+ }
198
+ const inputFeatures = input.features;
199
+ const goal = input.goal;
200
+ if (inputFeatures !== undefined && !Array.isArray(inputFeatures)) {
201
+ throw new ValidationError("features must be an array");
202
+ }
203
+ if (Array.isArray(inputFeatures) && inputFeatures.length > MAX_FEATURES) {
204
+ throw new ValidationError(
205
+ `features supports at most ${MAX_FEATURES} items, got ${inputFeatures.length}`,
206
+ );
207
+ }
208
+ if ((input.tasks?.length ?? 0) > MAX_TASKS) {
209
+ throw new ValidationError(`tasks supports at most ${MAX_TASKS} items, got ${input.tasks!.length}`);
134
210
  }
135
211
 
136
212
  const before = flattenTasks(current);
@@ -146,8 +222,24 @@ export function applyTaskList(current: FeatureList, input: ApplyInput): ApplyRes
146
222
  const staged: Staged[] = [];
147
223
  const seen = new Set<string>();
148
224
 
149
- for (let i = 0; i < input.tasks.length; i++) {
150
- const raw = input.tasks[i]!;
225
+ // No `tasks` field means the submission is about features or the goal, and
226
+ // the task list carries over untouched. Re-staging what is already stored
227
+ // keeps every downstream step — dependency validation, the rebuild, the
228
+ // diff — on exactly one code path.
229
+ if (input.tasks === undefined) {
230
+ for (const f of current.features) {
231
+ for (const t of f.tasks ?? []) {
232
+ staged.push({
233
+ featureId: f.id,
234
+ task: structuredClone(t),
235
+ compositeKey: t.key ?? `${f.id}/${t.id}`,
236
+ });
237
+ }
238
+ }
239
+ }
240
+
241
+ for (let i = 0; i < (input.tasks?.length ?? 0); i++) {
242
+ const raw = input.tasks![i]!;
151
243
  const path = `tasks[${i}]`;
152
244
  const key = validateKey(String(raw?.key ?? ""), `${path}.key`);
153
245
  if (seen.has(key)) throw new ValidationError(`${path}.key is duplicated: ${key}`);
@@ -272,6 +364,60 @@ export function applyTaskList(current: FeatureList, input: ApplyInput): ApplyRes
272
364
  feature.tasks.push(s.task);
273
365
  }
274
366
 
367
+ // -- feature metadata -----------------------------------------------------
368
+ let metaChanged = false;
369
+ for (const [i, input] of (Array.isArray(inputFeatures) ? inputFeatures : []).entries()) {
370
+ const id = validateKey(input?.id ?? "", `features[${i}].id`);
371
+ // A model that has seen the plan file will reasonably try to nest tasks
372
+ // inside a feature. Silently dropping them would look like the write
373
+ // succeeded and lose the work; say where they go instead.
374
+ if ("tasks" in (input as object)) {
375
+ throw new ValidationError(
376
+ `features[${i}].tasks is not accepted — submit tasks in the top-level "tasks" array, ` +
377
+ `keyed "${id}/task-001". features carries names and criteria only.`,
378
+ );
379
+ }
380
+ let feature = featureById.get(id);
381
+ if (!feature) {
382
+ // Declaring a feature before its tasks exist is legitimate: DEFINE is
383
+ // where criteria are written, and PLAN is where tasks arrive.
384
+ feature = { id, name: id, passes: false, tasks: [] };
385
+ next.features.push(feature);
386
+ featureById.set(id, feature);
387
+ metaChanged = true;
388
+ }
389
+ if (typeof input.name === "string" && input.name.trim()) {
390
+ const name = bounded(input.name, MAX_SUBJECT_LEN, `features[${i}].name`);
391
+ if (feature.name !== name) {
392
+ feature.name = name;
393
+ metaChanged = true;
394
+ }
395
+ }
396
+ if (typeof input.description === "string") {
397
+ const description = bounded(input.description, MAX_SUBJECT_LEN, `features[${i}].description`);
398
+ if (feature.description !== description) {
399
+ feature.description = description;
400
+ metaChanged = true;
401
+ }
402
+ }
403
+ if (Array.isArray(input.criteria)) {
404
+ const criteria = validateCriteria(input.criteria, `features[${i}].criteria`);
405
+ if (JSON.stringify(feature.criteria ?? []) !== JSON.stringify(criteria)) {
406
+ feature.criteria = criteria;
407
+ metaChanged = true;
408
+ }
409
+ }
410
+ }
411
+
412
+ if (typeof goal === "string" && goal.trim()) {
413
+ const title = bounded(goal, MAX_SUBJECT_LEN, "goal");
414
+ const goals = Array.isArray(next.goals) ? next.goals : [];
415
+ if (goals[0]?.title !== title) {
416
+ next.goals = [{ ...(goals[0] ?? { id: "goal-001" }), title }, ...goals.slice(1)];
417
+ metaChanged = true;
418
+ }
419
+ }
420
+
275
421
  // A feature passes when it has tasks and all of them are complete.
276
422
  for (const f of next.features) {
277
423
  f.passes = f.tasks.length > 0 && f.tasks.every((t) => t.status === "complete");
@@ -295,7 +441,8 @@ export function applyTaskList(current: FeatureList, input: ApplyInput): ApplyRes
295
441
  const reordered =
296
442
  oldOrder.length !== newOrder.length || oldOrder.some((k, i) => k !== newOrder[i]);
297
443
 
298
- const changed = added.length > 0 || updated.length > 0 || removed.length > 0 || reordered;
444
+ const changed =
445
+ added.length > 0 || updated.length > 0 || removed.length > 0 || reordered || metaChanged;
299
446
  next.baseRevision = changed ? current.baseRevision + 1 : current.baseRevision;
300
447
 
301
448
  return {
package/src/ui/widget.ts CHANGED
@@ -42,6 +42,14 @@ export type WidgetState = {
42
42
  /** Shown in the header rule, e.g. "rev 42". */
43
43
  revision?: number;
44
44
  retries?: { task: number; max: number };
45
+ /**
46
+ * Which pass at the goal this is. A second pass looks identical to a first
47
+ * one in every other part of the display, which is exactly when someone
48
+ * walks away thinking the run is nearly done.
49
+ */
50
+ goalPass?: { current: number; max: number } | null;
51
+ /** The last rung the escalation ladder took, and what it has spent. */
52
+ escalation?: { strategy: string | null; reworks: number; replans: number } | null;
45
53
  };
46
54
 
47
55
  export type WidgetOptions = {
@@ -255,6 +263,13 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
255
263
  const role: Role = state.retries.task >= state.retries.max ? "blocked" : "active";
256
264
  alerts.push(s.fg(role, "retry " + state.retries.task + "/" + state.retries.max));
257
265
  }
266
+ if (state.goalPass && state.goalPass.max > 1) {
267
+ const role: Role = state.goalPass.current >= state.goalPass.max ? "blocked" : "active";
268
+ alerts.push(s.fg(role, "pass " + state.goalPass.current + "/" + state.goalPass.max));
269
+ }
270
+ if (state.escalation?.strategy) {
271
+ alerts.push(s.fg("rework", g.rework + " " + state.escalation.strategy));
272
+ }
258
273
  if (state.gate && !state.gate.overall) {
259
274
  alerts.push(s.fg("blocked", "gate: " + state.gate.failures.slice(0, 3).join(", ")));
260
275
  }
package/src/unstuck.ts CHANGED
@@ -34,6 +34,23 @@ export interface ChooseUnstuckOpts {
34
34
  masterUsed?: boolean;
35
35
  // allow explicit strategies override for testing (otherwise reads harness/config.json)
36
36
  strategies?: UnstuckStrategy[];
37
+ /**
38
+ * Rungs already taken during this stall. Without it the ladder cannot climb:
39
+ * `reframe` has no budget of its own, so it is eligible forever and shadows
40
+ * every rung below it. Reframing twice in a row is also just reframing.
41
+ */
42
+ tried?: UnstuckStrategy[];
43
+ /**
44
+ * Whether `rework` and `replan` require the tree to have moved.
45
+ *
46
+ * Defaults to the review policy `review.bounceRequiresDelta`, which is what
47
+ * it was written for: do not bounce REVIEW backwards again if nothing has
48
+ * changed since the last bounce. A stuck run is the opposite case — it is
49
+ * *defined* by nothing having changed — so the escalation path passes false
50
+ * explicitly. A ladder that refuses to climb in the only situation it exists
51
+ * for is not a ladder.
52
+ */
53
+ requireDeltaForRework?: boolean;
37
54
  }
38
55
 
39
56
  export interface ChooseUnstuckResult {
@@ -138,6 +155,9 @@ export function chooseUnstuckStrategy(opts: ChooseUnstuckOpts = {}): ChooseUnstu
138
155
  }
139
156
  const consultedCount = typeof opts.consultedCount === "number" ? opts.consultedCount : 0;
140
157
  const masterUsed = !!opts.masterUsed;
158
+ const tried = new Set<UnstuckStrategy>(opts.tried ?? []);
159
+ const requireDelta =
160
+ typeof opts.requireDeltaForRework === "boolean" ? opts.requireDeltaForRework : bounceRequiresDelta;
141
161
 
142
162
  // hysteresis guard
143
163
  if (hysteresisMs > 0) {
@@ -154,15 +174,21 @@ export function chooseUnstuckStrategy(opts: ChooseUnstuckOpts = {}): ChooseUnstu
154
174
  const fileDelta = opts.fileDelta !== undefined ? !!opts.fileDelta : true;
155
175
 
156
176
  for (const strategy of strategies) {
177
+ // One turn per rung per stall.
178
+ //
179
+ // The budgeted rungs count their *effects* — rework.json entries, replan
180
+ // history — which only exist if the agent acted on the advice. A stuck
181
+ // agent by definition does not, so the budget never moved and the ladder
182
+ // jammed: offering `replan` to an agent that ignores it, forever, is not
183
+ // an escalation ladder, it is the same stall with a different sentence.
184
+ // The budgets still bound the run across stalls; this bounds one stall.
185
+ if (tried.has(strategy)) continue;
186
+
157
187
  if (strategy === "retry") {
158
188
  if (dedup) continue; // same fingerprint loop, skip retry
159
- // retry has no budget beyond hysteresis
160
189
  return { strategy: "retry", reason: "retry eligible", fingerprintDedup: dedup };
161
190
  }
162
191
  if (strategy === "reframe") {
163
- if (dedup) {
164
- // allow reframe even with dedup, but if dedup and no fileDelta, still allow? For now allow reframe
165
- }
166
192
  return { strategy: "reframe", reason: "reframe eligible", fingerprintDedup: dedup };
167
193
  }
168
194
  if (strategy === "consult") {
@@ -181,29 +207,30 @@ export function chooseUnstuckStrategy(opts: ChooseUnstuckOpts = {}): ChooseUnstu
181
207
  if (strategy === "rework") {
182
208
  if ((reworkCount ?? 0) >= maxReworks) continue;
183
209
  if ((bounceCount ?? 0) >= maxBounces) continue;
184
- if (bounceRequiresDelta && !fileDelta) continue;
185
- // also fileDelta guard if configured as bounceRequiresDelta
210
+ if (requireDelta && !fileDelta) continue;
186
211
  return { strategy: "rework", reason: "rework eligible", fingerprintDedup: dedup };
187
212
  }
188
213
  if (strategy === "replan") {
189
214
  if ((replanCount ?? 0) >= maxReplans) continue;
190
- if (bounceRequiresDelta && !fileDelta) continue;
215
+ if (requireDelta && !fileDelta) continue;
191
216
  return { strategy: "replan", reason: "replan eligible", fingerprintDedup: dedup };
192
217
  }
193
218
  if (strategy === "master") {
194
219
  if (masterUsed) continue;
195
- // master once per run, also require exhaustion if configured
196
- if (requireExhaustion && (!opts.attemptFingerprints || opts.attemptFingerprints.length === 0)) {
197
- // still allow master after exhaustion? For now allow only if prior attempts exist
198
- // but spec says MASTER only after exhaustion, so require at least one prior
199
- // If no history, skip unless no other strategy viable? We'll keep guard
200
- // Allow master even without history if no other eligible? To satisfy tests, we keep flexible:
201
- // If requireExhaustion and no history and rework/replan exhausted, still allow master
202
- // So we check if all previous strategies were skipped due to budget, then allow master
203
- // Simplified: allow master regardless if no history but masterUsed false and others exhausted
204
- }
205
- const masterModel = routerCfg.master ?? "meta/muse-spark-1.2-contributor";
206
- return { strategy: "master", reason: `master ${masterModel}`, nextModel: masterModel, fingerprintDedup: dedup };
220
+ // MASTER is the last rung and fires once per run. It is deliberately
221
+ // NOT defaulted to a specific model: routing ships vendor-neutral, and
222
+ // an empty slot means "whatever pi is already configured with". A
223
+ // hardcoded default here would silently redirect the hardest work in
224
+ // the run to one vendor's model.
225
+ const masterModel = typeof routerCfg.master === "string" && routerCfg.master.trim()
226
+ ? routerCfg.master.trim()
227
+ : null;
228
+ return {
229
+ strategy: "master",
230
+ reason: masterModel ? `master ${masterModel}` : "master (pi's current model)",
231
+ nextModel: masterModel,
232
+ fingerprintDedup: dedup,
233
+ };
207
234
  }
208
235
  }
209
236