infinity-harness 2.0.3 → 2.1.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/CHANGELOG.md CHANGED
@@ -4,6 +4,64 @@ All notable changes to this project are documented here.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [2.1.0] — 2026-08-23
8
+
9
+ You could not start, and if you had, you could not have got past the first gate. Both are fixed.
10
+
11
+ ### Added
12
+
13
+ - **`/infinity:init`, and the `infinity_init` tool.** There was no way to create a harness. `pi
14
+ install` put the extension in place and then every command answered *"No harness in this project
15
+ (harness/config.json not found)"* — with nothing anywhere that made one. The package installed,
16
+ loaded, and passed its entire test suite while being unusable.
17
+
18
+ Init detects the stack and its lint/test/build commands, writes the config, an empty plan, the
19
+ phase and role docs the brief points at, and starters for the documents the review gate demands,
20
+ then hands the model its first brief. It asks two questions when there are dialogs and takes the
21
+ detected defaults when there are not, so an unattended run never stalls on a prompt. It never
22
+ overwrites an existing file, and `/infinity:init force` restores what was deleted without touching
23
+ what was written.
24
+ - **Feature names, acceptance criteria and the run's goal are writable through `infinity_plan`.**
25
+ Features are derived from task keys, so there was no input for their metadata — and the DEFINE
26
+ gate requires criteria on every feature. The first gate in the pipeline could only be passed by
27
+ hand-editing the plan file, which the brief explicitly tells you not to do.
28
+
29
+ `features` merges by id and never deletes, because features are inferred rather than submitted;
30
+ `tasks` keeps its omission-means-deletion rule. Leaving `tasks` out entirely is now distinct from
31
+ sending `[]`: absent means "not touching them", empty still means "delete them all". Nesting tasks
32
+ inside a feature — the obvious wrong guess — is refused with the shape that works.
33
+ - **A `coldstart` E2E scenario**: bare directory, `/infinity:init`, brief, plan, gate, advance,
34
+ through the real adapter. Every leg of it was a defect before it was a test.
35
+
36
+ ### Fixed
37
+
38
+ - **Six more shipped documents told the agent to run a CLI this package does not have** —
39
+ `infinity-harness contract propose`, `decision "..."`, `rollback list`, `checkpoint create`. The
40
+ 2.0.2 guard only caught a hardcoded list of verbs, which by construction only ever catches the
41
+ ones already found. It now looks at *where* the claim is made: inside a code fence or span, the
42
+ package name followed by a word is a command line, and there is no command line.
43
+ - **The plan view hid the thing the model is marked on.** Reading the plan listed tasks but not
44
+ features or their criteria — so the DEFINE gate judged something the model could not see. It now
45
+ shows the goal, each feature, and its criteria, flagging any feature that has none.
46
+ - **Comments counted as document content.** `docCheck` stripped headings but not HTML comments, so a
47
+ scaffolded file whose guidance lived in a comment would satisfy the gate that demanded it. A
48
+ comment is instructions to the author, not content.
49
+
50
+ ---
51
+
52
+ ## [2.0.4] — 2026-08-23
53
+
54
+ ### Changed
55
+
56
+ - **A domain skill now has to be pulled in by the task's own vocabulary.** Run against a real project
57
+ the matcher offered `cli-design` for *"serialise plan writes so two workers cannot race on the
58
+ lock"* — because the goal above it read "ship the payments rewrite behind a flag", and `flags` is
59
+ on that skill's tag list. One incidental word is not vocabulary. A `domain` or `meta` skill must
60
+ now clear the bar on tag hits alone; the phase ranks it but never qualifies it. `process` skills
61
+ are unchanged — belonging to the phase is the whole point of them.
62
+
63
+ ---
64
+
7
65
  ## [2.0.3] — 2026-08-23
8
66
 
9
67
  ### Fixed
package/README.md CHANGED
@@ -77,12 +77,25 @@ cd your-project
77
77
  pi
78
78
  ```
79
79
 
80
- On session start the harness injects a brief — phase, role, current task, acceptance criteria, and
81
- what to do next. Do the work, then:
80
+ Then, once, in that project:
81
+
82
+ ```
83
+ /infinity:init
84
+ ```
85
+
86
+ That is the whole setup. It detects your stack and its lint/test/build commands, writes
87
+ `harness/` with the config, an empty plan, the phase and role docs, and starters for the
88
+ documents the review gate will demand — then hands the model its first brief. It never
89
+ overwrites a file that already exists, and `/infinity:init force` restores anything you
90
+ deleted without touching what you wrote.
91
+
92
+ From then on, every session opens with a brief: phase, role, current task, acceptance criteria,
93
+ the craft skills that match the work, and what to do next. Do the work, then:
82
94
 
83
95
  ```
84
96
  /infinity:validate run the gate for this phase
85
97
  /infinity:run hand it the wheel: validate → advance → re-brief, until done or stuck
98
+ /infinity:status where the run is right now
86
99
  /infinity:config change any setting, including which model runs which tier
87
100
  /infinity:models what models pi has, and how they are being routed
88
101
  /infinity:dashboard open the live web view
@@ -91,6 +104,25 @@ what to do next. Do the work, then:
91
104
 
92
105
  `/infinity:run` is the point of the tool. It keeps the loop turning without you.
93
106
 
107
+ ### The first pass through
108
+
109
+ DEFINE wants acceptance criteria on every feature, so start by telling it what you are building.
110
+ The agent writes that through `infinity_plan`:
111
+
112
+ ```jsonc
113
+ {
114
+ "goal": "Ship the payments rewrite behind a flag",
115
+ "features": [
116
+ { "id": "feature-001", "name": "Checkout flow", "criteria": ["refunds reconcile against the ledger"] }
117
+ ]
118
+ }
119
+ ```
120
+
121
+ Features carry names and criteria; tasks are a separate list keyed `feature-001/task-001`, and
122
+ arrive in PLAN. Omitting a task deletes it — that is the rule that keeps the plan honest — but
123
+ omitting a *feature* just leaves it alone, because features are inferred from task keys rather
124
+ than submitted.
125
+
94
126
  ## Configuration
95
127
 
96
128
  Everything is configurable from inside pi:
@@ -166,7 +198,12 @@ brief all read it; nothing caches a second copy.
166
198
 
167
199
  The agent edits it by submitting the **complete** task list through the `infinity_plan` tool:
168
200
 
169
- - **Omission means deletion.** One unambiguous rule beats incremental edits a model loses track of.
201
+ - **Omission means deletion** — for tasks. One unambiguous rule beats incremental edits a model
202
+ loses track of. Leaving the `tasks` field out entirely is different from sending an empty one:
203
+ absent means "not touching them", empty means "delete them all".
204
+ - **Features are a merge, not a submission.** They are inferred from task keys, so they are never
205
+ resubmitted wholesale; `features` supplies names and acceptance criteria by id, and omitting one
206
+ leaves it alone.
170
207
  - **`baseRevision` guards every write.** A stale revision is rejected, so parallel workers can't
171
208
  clobber each other.
172
209
  - **Unknown fields survive.** An update merges onto the stored task, so `difficulty`, `modelHint`,
@@ -241,8 +278,9 @@ that looks like a broken endpoint but is only a small cap.
241
278
 
242
279
  | Tool | Purpose |
243
280
  |---|---|
281
+ | `infinity_init` | Create the harness in this project |
244
282
  | `infinity_brief` | What am I supposed to be doing right now? |
245
- | `infinity_plan` | Read or rewrite the task list |
283
+ | `infinity_plan` | Read or rewrite the plan tasks, features, criteria, goal |
246
284
  | `infinity_validate` | Run the gate for this phase |
247
285
  | `infinity_advance` | Move to the next phase (refuses on a failing gate) |
248
286
  | `infinity_dashboard` | Start/stop/query the web view |
@@ -270,7 +308,7 @@ infinity-harness/
270
308
  │ ├── model-router.json optional routing
271
309
  │ ├── docs/ architecture · decisions · phase and role docs
272
310
  │ └── skills/ 28 craft skills the brief points at
273
- ├── tests/ 23 files, plain node:assert
311
+ ├── tests/ 25 files, plain node:assert
274
312
  └── scripts/run-tests.mjs
275
313
  ```
276
314
 
@@ -282,7 +320,7 @@ there is one implementation, and the adapter calls it.
282
320
  ```bash
283
321
  npm install
284
322
  npm run check # tsc --noEmit
285
- npm test # 23 test files
323
+ npm test # 25 test files
286
324
  npm run e2e # end-to-end against a live model
287
325
  ```
288
326
 
@@ -26,13 +26,19 @@ import { runChecks } from "../../src/core/gates.ts";
26
26
  import { advancePhase } from "../../src/core/phases.ts";
27
27
  import { configPath } from "../../src/core/paths.ts";
28
28
  import { withLock } from "../../src/core/lock.ts";
29
- import { ValidationError, type FeatureList, type Phase } from "../../src/core/types.ts";
29
+ import {
30
+ DEFAULT_ENABLED_PHASES,
31
+ ValidationError,
32
+ type FeatureList,
33
+ type Phase,
34
+ } from "../../src/core/types.ts";
30
35
  import { writeTaskList, summarizeApply, type TaskInput } from "../../src/taskList.ts";
31
36
  import { renderWidget, renderStatusLine, type WidgetState } from "../../src/ui/widget.ts";
32
37
  import { createStyler, detectGlyphs } from "../../src/ui/theme.ts";
33
38
  import { decideNext, stopFilePath } from "../../src/loop.ts";
34
39
  import { runConfigMenu, renderSettings, type ModelChoice, type Prompter } from "../../src/ui/config.ts";
35
40
  import { SETTINGS, readAll, readSetting, formatValue } from "../../src/core/settings.ts";
41
+ import { detectStack, describeInit, initHarness, type StackId } from "../../src/core/init.ts";
36
42
 
37
43
  const CHECKPOINT = "infinity:checkpoint";
38
44
  const WIDGET_KEY = "infinity-harness";
@@ -438,22 +444,72 @@ export default function (pi: ExtensionAPI): void {
438
444
  },
439
445
  },
440
446
  },
447
+ features: {
448
+ type: "array",
449
+ maxItems: 100,
450
+ description:
451
+ "Feature names and acceptance criteria, merged by id. Unlike tasks, omitting a feature " +
452
+ "here leaves it alone rather than deleting it. The DEFINE gate requires criteria on " +
453
+ "every feature, so this is how DEFINE is passed.",
454
+ items: {
455
+ type: "object",
456
+ required: ["id"],
457
+ properties: {
458
+ id: { type: "string", description: 'Feature id, e.g. "feature-001"' },
459
+ name: { type: "string", description: "What the feature is, in a few words" },
460
+ description: { type: "string" },
461
+ criteria: {
462
+ type: "array",
463
+ items: { type: "string" },
464
+ description: "How you will know this feature is done. Observable, not aspirational.",
465
+ },
466
+ },
467
+ },
468
+ },
469
+ goal: {
470
+ type: "string",
471
+ description: "One line: what this whole run is for. Shown at the top of every brief.",
472
+ },
441
473
  },
442
474
  } as never,
443
- async execute(_id: string, params: { baseRevision?: number; tasks?: TaskInput[] }, _signal, _onUpdate, ctx) {
475
+ async execute(
476
+ _id: string,
477
+ params: {
478
+ baseRevision?: number;
479
+ tasks?: TaskInput[];
480
+ features?: { id: string; name?: string; description?: string; criteria?: string[] }[];
481
+ goal?: string;
482
+ },
483
+ _signal,
484
+ _onUpdate,
485
+ ctx,
486
+ ) {
444
487
  const dir = projectDir(ctx);
445
488
 
446
- if (!Array.isArray(params?.tasks)) {
489
+ // A submission with no tasks, no features and no goal is a read.
490
+ const writing =
491
+ Array.isArray(params?.tasks) || Array.isArray(params?.features) || typeof params?.goal === "string";
492
+ if (!writing) {
447
493
  const { list } = loadFeatureList(dir);
448
494
  const p = computeProgress(list);
495
+ // Features and their criteria are printed, not just tasks: the DEFINE
496
+ // gate judges criteria, so a plan view that hides them shows the model
497
+ // everything except the thing it is being marked on.
449
498
  const rows = (list.features ?? [])
450
- .flatMap((f) => (f.tasks ?? []).map((t) => `[${t.status}] ${t.key ?? t.id}: ${t.description}`))
499
+ .flatMap((f) => [
500
+ `${f.id} · ${f.name}${f.criteria?.length ? "" : " ← no acceptance criteria"}`,
501
+ ...(f.criteria ?? []).map((c) => ` ✓ ${c}`),
502
+ ...(f.tasks ?? []).map((t) => ` [${t.status}] ${t.key ?? t.id}: ${t.description}`),
503
+ ])
451
504
  .join("\n");
505
+ const goal = (list.goals ?? [])[0]?.title;
452
506
  return {
453
507
  content: [
454
508
  {
455
509
  type: "text",
456
- text: `Plan revision ${list.baseRevision} — ${p.tasksDone}/${p.tasksTotal} tasks\n${rows || "(empty)"}`,
510
+ text:
511
+ `Plan revision ${list.baseRevision} — ${p.tasksDone}/${p.tasksTotal} tasks` +
512
+ `${goal ? `\nGoal: ${goal}` : ""}\n${rows || "(empty)"}`,
457
513
  },
458
514
  ],
459
515
  details: { revision: list.baseRevision, progress: p },
@@ -464,7 +520,12 @@ export default function (pi: ExtensionAPI): void {
464
520
  // writeTaskList takes the plan lock itself, around the whole
465
521
  // read-apply-write. Wrapping it again here would only add a second
466
522
  // lock with weaker semantics.
467
- const result = writeTaskList(dir, { baseRevision: params.baseRevision, tasks: params.tasks! });
523
+ const result = writeTaskList(dir, {
524
+ baseRevision: params.baseRevision,
525
+ tasks: params.tasks,
526
+ features: params.features,
527
+ goal: params.goal,
528
+ });
468
529
  refreshWidget(ctx as ExtensionContext);
469
530
  return {
470
531
  content: [{ type: "text", text: summarizeApply(result) }],
@@ -678,6 +739,134 @@ export default function (pi: ExtensionAPI): void {
678
739
  },
679
740
  });
680
741
 
742
+ // -- init -----------------------------------------------------------------
743
+
744
+ /**
745
+ * Said wherever a command finds no harness.
746
+ *
747
+ * It used to be "No harness in this project." and nothing else — a dead end
748
+ * with no exit, in a tool whose every other command needs a harness to work.
749
+ * A warning that does not say what to do instead is only half a warning.
750
+ */
751
+ const NO_HARNESS = "No harness in this project yet. Run /infinity:init to create one.";
752
+
753
+ /** Everything the pipeline can run. INIT is not a phase you choose. */
754
+ const SELECTABLE_PHASES: Phase[] = ["define", "plan", "build", "verify", "simplify", "review", "ship"];
755
+
756
+ pi.registerTool({
757
+ name: "infinity_init",
758
+ label: "Init",
759
+ description:
760
+ "Create a harness in this project: config, an empty plan, the phase and role docs, and starters " +
761
+ "for the documents the review gate demands. Detects the stack and its lint/test/build commands. " +
762
+ "Refuses if a harness already exists unless force is set, and never overwrites an existing file.",
763
+ parameters: {
764
+ type: "object",
765
+ properties: {
766
+ mode: { type: "string", enum: ["copilot", "autopilot"], description: "copilot keeps the human in the loop" },
767
+ stack: { type: "string", enum: ["node", "python", "rust", "go", "unknown"] },
768
+ phases: {
769
+ type: "array",
770
+ items: { type: "string", enum: ["define", "plan", "build", "verify", "simplify", "review", "ship"] },
771
+ description: "Which phases run. Omit for the default pipeline.",
772
+ },
773
+ force: { type: "boolean", description: "Restore missing files in a project that already has a harness" },
774
+ },
775
+ } as never,
776
+ async execute(
777
+ _id: string,
778
+ params: { mode?: "copilot" | "autopilot"; stack?: StackId; phases?: Phase[]; force?: boolean },
779
+ _signal,
780
+ _onUpdate,
781
+ ctx,
782
+ ) {
783
+ const dir = projectDir(ctx);
784
+ const result = initHarness(dir, {
785
+ mode: params?.mode,
786
+ stack: params?.stack,
787
+ phases: params?.phases,
788
+ force: params?.force,
789
+ });
790
+ if (!result.ok) {
791
+ return {
792
+ content: [{ type: "text", text: result.error ?? "init failed" }],
793
+ details: result,
794
+ isError: true,
795
+ };
796
+ }
797
+ refreshWidget(ctx as ExtensionContext);
798
+ return { content: [{ type: "text", text: describeInit(result) }], details: result };
799
+ },
800
+ });
801
+
802
+ pi.registerCommand("infinity:init", {
803
+ description: "Create a harness in this project",
804
+ handler: async (args: string, ctx: ExtensionContext) => {
805
+ const dir = projectDir(ctx);
806
+ const force = /\bforce\b/.test(args);
807
+
808
+ if (isHarnessProject(dir) && !force) {
809
+ notify(
810
+ ctx,
811
+ "This project already has a harness. /infinity:config changes it; /infinity:init force restores missing files.",
812
+ "warning",
813
+ );
814
+ return;
815
+ }
816
+
817
+ const detected = detectStack(dir);
818
+ let mode: "copilot" | "autopilot" = "copilot";
819
+ let phases: Phase[] | undefined;
820
+
821
+ // With dialogs, ask the two questions whose answers we cannot infer.
822
+ // Without them, take the detected defaults and say so — an unattended
823
+ // run must not stall on a prompt nobody will answer.
824
+ if (ctx.hasUI) {
825
+ const cmds = Object.entries(detected.commands).filter(([, v]) => Boolean(v));
826
+ const summary = cmds.length ? cmds.map(([k, v]) => `${k}: ${v}`).join(", ") : "no commands detected";
827
+ const go = await ctx.ui.select(
828
+ `Create a harness here? ${detected.label} · ${summary}`,
829
+ ["yes, use these defaults", "yes, but let me choose the phases", "cancel"],
830
+ );
831
+ if (go === undefined || go === "cancel") {
832
+ notify(ctx, "init cancelled — nothing was written.", "info");
833
+ return;
834
+ }
835
+ const picked = await ctx.ui.select("How should it run?", [
836
+ "copilot — you stay in the loop",
837
+ "autopilot — it drives itself",
838
+ ]);
839
+ if (picked?.startsWith("autopilot")) mode = "autopilot";
840
+
841
+ if (go.includes("phases")) {
842
+ const chosen = new Set<Phase>(DEFAULT_ENABLED_PHASES);
843
+ for (;;) {
844
+ const rows = SELECTABLE_PHASES.map((p) => `${chosen.has(p) ? "[x]" : "[ ]"} ${p}`);
845
+ const hit = await ctx.ui.select("Phases to run", [...rows, "✓ done"]);
846
+ if (hit === undefined || hit === "✓ done") break;
847
+ const key = SELECTABLE_PHASES[rows.indexOf(hit)];
848
+ if (!key) break;
849
+ if (chosen.has(key)) chosen.delete(key);
850
+ else chosen.add(key);
851
+ }
852
+ phases = [...chosen];
853
+ }
854
+ }
855
+
856
+ const result = initHarness(dir, { mode, phases, force });
857
+ if (!result.ok) {
858
+ notify(ctx, result.error ?? "init failed", "error");
859
+ return;
860
+ }
861
+
862
+ notify(ctx, describeInit(result), "info");
863
+ refreshWidget(ctx);
864
+ // Hand the model the brief straight away, so the session that created
865
+ // the harness is also the session that starts using it.
866
+ pi.sendUserMessage(await briefText(dir), { deliverAs: "followUp" });
867
+ },
868
+ });
869
+
681
870
  // -- commands -------------------------------------------------------------
682
871
 
683
872
  pi.registerCommand("infinity:status", {
@@ -685,7 +874,7 @@ export default function (pi: ExtensionAPI): void {
685
874
  handler: async (_args: string, ctx: ExtensionContext) => {
686
875
  const dir = projectDir(ctx);
687
876
  if (!isHarnessProject(dir)) {
688
- notify(ctx, "No harness in this project (harness/config.json not found).", "warning");
877
+ notify(ctx, NO_HARNESS, "warning");
689
878
  return;
690
879
  }
691
880
  const state = widgetStateFor(dir);
@@ -725,7 +914,7 @@ export default function (pi: ExtensionAPI): void {
725
914
  handler: async (_args: string, ctx: ExtensionContext) => {
726
915
  const dir = projectDir(ctx);
727
916
  if (!isHarnessProject(dir)) {
728
- notify(ctx, "No harness in this project.", "warning");
917
+ notify(ctx, NO_HARNESS, "warning");
729
918
  return;
730
919
  }
731
920
  loopEnabled = true;
@@ -784,7 +973,7 @@ export default function (pi: ExtensionAPI): void {
784
973
  handler: async (args: string, ctx: ExtensionContext) => {
785
974
  const dir = projectDir(ctx);
786
975
  if (!isHarnessProject(dir)) {
787
- notify(ctx, "No harness in this project (harness/config.json not found).", "warning");
976
+ notify(ctx, NO_HARNESS, "warning");
788
977
  return;
789
978
  }
790
979
 
@@ -153,7 +153,7 @@ Every stop carries a reason. A human coming back finds an explanation, not a mys
153
153
  ## Verification
154
154
 
155
155
  - `npm test` — 20 unit files, plain `node:assert`, no framework.
156
- - `npm run e2e` — 12 scenarios over real temp projects, real git repos, real child processes: the
156
+ - `npm run e2e` — 13 scenarios over real temp projects, real git repos, real child processes: the
157
157
  full pipeline walkthrough, loop convergence, every stop condition, SIGKILL-and-restart, a 6-way
158
158
  concurrent write fan-out with an unlocked control, data round-trip, the dashboard, widget
159
159
  rendering across shapes, adversarial input, and the extension adapter itself.
@@ -27,14 +27,31 @@ feature list.
27
27
  - No source files in project root
28
28
  5. Write `specs/prd.md` — scope, success criteria, non-goals
29
29
  6. Keep the PRD bounded: no vague verbs ("improve", "enhance")
30
- 7. **Negotiate the sprint contract** (the DEFINE gate requires it agreed):
31
- - `infinity-harness contract propose --scope "..." --criteria "tests pass|feature X works"`
32
- - `infinity-harness contract review --agreed` (put on the evaluator hat: would
33
- these criteria really prove the sprint worked?)
34
- 8. **Create a feature branch**: `git checkout -b feature/<short-slug>` (the
35
- gate rejects work on main/master)
36
- 9. Run `infinity_validate` to check gates
37
- 10. If PASS → `infinity_advance` to advance to PLAN
30
+ 7. **Write the acceptance criteria into the plan** this is what the DEFINE
31
+ gate actually checks. Every feature needs criteria before it opens:
32
+
33
+ ```jsonc
34
+ // infinity_plan
35
+ {
36
+ "goal": "one line: what this whole run is for",
37
+ "features": [
38
+ { "id": "feature-001", "name": "Checkout flow",
39
+ "criteria": ["refunds reconcile against the ledger"] }
40
+ ]
41
+ }
42
+ ```
43
+
44
+ Tasks are a separate list and arrive in PLAN — do not nest them here.
45
+ Criteria must be observable: "refunds reconcile against the ledger", not
46
+ "refunds work well". Put the evaluator hat on and ask whether passing these
47
+ would really prove the feature works.
48
+ 8. **Record the sprint contract** in `harness/sprint-contract.md` — scope,
49
+ what is explicitly out, and how you will know you are done. The gate does
50
+ not read it; the next session does.
51
+ 9. **Create a feature branch**: `git checkout -b feature/<short-slug>` (later
52
+ gates reject work on main/master)
53
+ 10. Run `infinity_validate` to check gates
54
+ 11. If PASS → `infinity_advance` to advance to PLAN
38
55
 
39
56
  ## Rationalizations to Avoid
40
57
  | Excuse | Rebuttal |
@@ -49,7 +66,8 @@ feature list.
49
66
 
50
67
  ## Verification
51
68
  - [ ] `specs/prd.md` exists with scope, success criteria, non-goals
52
- - [ ] Sprint contract agreed with non-placeholder verification criteria
69
+ - [ ] Every feature has observable acceptance criteria in the plan
70
+ - [ ] Sprint contract recorded in `harness/sprint-contract.md`
53
71
  - [ ] On a feature branch (not main/master)
54
72
  - [ ] Folder structure agreed and documented
55
73
  - [ ] `infinity_validate` passes
@@ -17,7 +17,7 @@ clean working tree, and ensure all ship gates pass.
17
17
  6. Ensure LICENSE, CONTRIBUTING.md exist
18
18
  7. Run `infinity_validate` to check ship gates
19
19
  8. If PASS → `infinity_advance` (pipeline complete!)
20
- 9. Create checkpoint: `infinity-harness checkpoint create release-<version>`
20
+ 9. The release tag from step 5 is the checkpoint — nothing else to create.
21
21
 
22
22
  ## Rationalizations to Avoid
23
23
  | Excuse | Rebuttal |
@@ -25,8 +25,8 @@ the other.
25
25
 
26
26
  1. **Pin the fixed point.** `git diff <fixed-point>...HEAD` (three-dot) and
27
27
  `git log <fixed-point>..HEAD --oneline`. In the harness pipeline the fixed
28
- point is usually the phase-start or sprint-start commit/tag
29
- (`infinity-harness rollback list` shows checkpoints).
28
+ point is usually the phase-start or sprint-start commit or tag
29
+ (`git tag --sort=-creatordate | head` finds it).
30
30
  2. **Spec review.** The spec sources are `specs/prd.md`, the sprint contract
31
31
  (`harness/sprint-contract.md`), and the feature list's acceptance
32
32
  criteria. Report: (a) requirements missing or partial; (b) behaviour
@@ -18,7 +18,7 @@ session (you, another agent, a human) starts from what was WRITTEN.
18
18
 
19
19
  - **Externalize at the moment of discovery**, not "later":
20
20
  - Surprise, gotcha, non-obvious behavior → `harness/lessons-decisions.md "..."`
21
- - Design choice with a why → `infinity-harness decision "..."`
21
+ - Design choice with a why → `harness/docs/DECISIONS.md`
22
22
  - Resolved terminology → `harness/docs/DOMAIN.md`
23
23
  - Verified fact about an API/tool → `docs/research/` (with frontmatter)
24
24
  - **Re-read instead of remember.** Before acting on something you learned
@@ -82,4 +82,4 @@ pass → re-run the Phase 1 loop against the original scenario.
82
82
  - [ ] Record the confirmed hypothesis: `harness/lessons-decisions.md "bug X was caused by Y"`
83
83
 
84
84
  Then ask: what would have prevented this bug? If the answer is architectural
85
- (no test seam, tangled callers), record it: `infinity-harness decision "..."`.
85
+ (no test seam, tangled callers), append it to `harness/docs/DECISIONS.md`.
@@ -57,7 +57,8 @@ once `shipped`. NOT the same as a Cart (pre-checkout, mutable).
57
57
  scratch notes, no specs.
58
58
 
59
59
  ### Record decisions sparingly
60
- Record a decision (`infinity-harness decision "..."`) only when all three hold:
60
+ Record a decision one entry appended to `harness/docs/DECISIONS.md` only
61
+ when all three hold:
61
62
 
62
63
  1. **Hard to reverse** — changing your mind later costs something real
63
64
  2. **Surprising without context** — a future reader would ask "why?"
@@ -35,6 +35,6 @@ decides the shape:
35
35
  5. **Surface the state.** After every action, print the full relevant state
36
36
  so the effect of each step is visible.
37
37
  6. **Capture it when done.** Fold the validated decision into the real code
38
- and record it: `infinity-harness decision "state machine X chosen because Y
39
- (validated by prototype)"`. Then DELETE the prototype — the
38
+ and append it to `harness/docs/DECISIONS.md`: "state machine X chosen
39
+ because Y, validated by prototype". Then DELETE the prototype — the
40
40
  anti-placeholder gate will flag leftovers, and that's by design.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinity-harness",
3
- "version": "2.0.3",
3
+ "version": "2.1.0",
4
4
  "description": "A pi agent extension that runs a gated build pipeline unattended \u2014 enforces phases, validates with deterministic gates, and keeps working for hours or days without losing the plan.",
5
5
  "type": "module",
6
6
  "keywords": [
package/src/core/gates.ts CHANGED
@@ -196,7 +196,14 @@ async function checkNoPlaceholders({ targetDir, config }: Ctx): Promise<CheckRes
196
196
  function docCheck(name: string, path: string, minChars: number, hint: string): CheckResult {
197
197
  const text = readText(path);
198
198
  if (text === null) return fail(name, `${hint} is missing`);
199
- const body = text.replace(/^#.*$/gm, "").trim();
199
+ // Headings are structure and HTML comments are instructions to the author —
200
+ // neither is content. This matters because the scaffolded starters explain
201
+ // in a comment what belongs in the file; counting that would let the review
202
+ // gate pass on a template nobody had written a word into.
203
+ const body = text
204
+ .replace(/<!--[\s\S]*?-->/g, "")
205
+ .replace(/^#.*$/gm, "")
206
+ .trim();
200
207
  return body.length >= minChars
201
208
  ? pass(name, `${hint} present (${body.length} chars)`)
202
209
  : fail(name, `${hint} exists but is essentially empty (${body.length} chars, need ${minChars})`);
@@ -0,0 +1,379 @@
1
+ /**
2
+ * infinity-harness — creating a harness in a project.
3
+ *
4
+ * Until this module existed there was no way to start. `pi install` put the
5
+ * extension in place, and then every command answered:
6
+ *
7
+ * Warning: No harness in this project (harness/config.json not found).
8
+ *
9
+ * with nothing anywhere that would create one. The tool was, in the most
10
+ * literal sense, unusable out of the box.
11
+ *
12
+ * Init writes the smallest complete harness: the config, an empty plan, the
13
+ * phase and role docs the brief points at, and starters for the documents the
14
+ * REVIEW and SHIP gates demand.
15
+ *
16
+ * Those starters are deliberately shorter than the gate thresholds. It would
17
+ * be easy to scaffold an ARCHITECTURE.md long enough to satisfy
18
+ * `docCheck(..., 200)` on the day it is created — and that would mean the
19
+ * review gate passes on boilerplate nobody wrote. The whole design rests on
20
+ * the gate being unbribable; the setup step is not the place to hand it a
21
+ * bribe.
22
+ */
23
+
24
+ import { existsSync, readFileSync, readdirSync, mkdirSync, statSync, writeFileSync } from "node:fs";
25
+ import { dirname, join, resolve } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import type { HarnessConfig, Phase } from "./types.ts";
28
+ import { DEFAULT_ENABLED_PHASES, PHASE_ORDER, PHASE_ROLE } from "./types.ts";
29
+ import { defaultConfig, saveConfig } from "./config.ts";
30
+ import { emptyFeatureList, saveFeatureList } from "./featureList.ts";
31
+ import * as P from "./paths.ts";
32
+
33
+ export type StackId = "node" | "python" | "rust" | "go" | "unknown";
34
+
35
+ export type ProjectCommands = {
36
+ lint: string | null;
37
+ test: string | null;
38
+ coverage: string | null;
39
+ build: string | null;
40
+ };
41
+
42
+ export type DetectedStack = {
43
+ id: StackId;
44
+ label: string;
45
+ /** What gave it away, so the user can disagree with a reason. */
46
+ evidence: string;
47
+ commands: ProjectCommands;
48
+ };
49
+
50
+ const NO_COMMANDS: ProjectCommands = { lint: null, test: null, coverage: null, build: null };
51
+
52
+ /**
53
+ * Work out what kind of project this is, and what its checks are.
54
+ *
55
+ * Only evidenced commands are proposed. Guessing `pytest` at a project that
56
+ * does not have pytest installed produces a gate that fails for a reason the
57
+ * user did not cause and cannot read — worse than proposing nothing, because
58
+ * an empty command is skipped and says so.
59
+ */
60
+ export function detectStack(targetDir: string): DetectedStack {
61
+ const has = (f: string) => existsSync(resolve(targetDir, f));
62
+
63
+ if (has("package.json")) {
64
+ return { id: "node", label: "Node / TypeScript", evidence: "package.json", commands: nodeCommands(targetDir) };
65
+ }
66
+ if (has("Cargo.toml")) {
67
+ return {
68
+ id: "rust",
69
+ label: "Rust",
70
+ evidence: "Cargo.toml",
71
+ // cargo ships the same three verbs in every Rust project there is.
72
+ commands: {
73
+ lint: "cargo clippy -- -D warnings",
74
+ test: "cargo test",
75
+ coverage: null,
76
+ build: "cargo build",
77
+ },
78
+ };
79
+ }
80
+ if (has("go.mod")) {
81
+ return {
82
+ id: "go",
83
+ label: "Go",
84
+ evidence: "go.mod",
85
+ commands: { lint: "go vet ./...", test: "go test ./...", coverage: null, build: "go build ./..." },
86
+ };
87
+ }
88
+ if (has("pyproject.toml") || has("requirements.txt") || has("setup.py")) {
89
+ return {
90
+ id: "python",
91
+ label: "Python",
92
+ evidence: has("pyproject.toml") ? "pyproject.toml" : has("setup.py") ? "setup.py" : "requirements.txt",
93
+ commands: pythonCommands(targetDir),
94
+ };
95
+ }
96
+ return { id: "unknown", label: "unrecognised", evidence: "no manifest found", commands: { ...NO_COMMANDS } };
97
+ }
98
+
99
+ function nodeCommands(targetDir: string): ProjectCommands {
100
+ let scripts: Record<string, string> = {};
101
+ try {
102
+ const pkg = JSON.parse(readFileSync(resolve(targetDir, "package.json"), "utf-8"));
103
+ if (pkg && typeof pkg.scripts === "object" && pkg.scripts) scripts = pkg.scripts;
104
+ } catch {
105
+ return { ...NO_COMMANDS };
106
+ }
107
+ const runner = existsSync(resolve(targetDir, "pnpm-lock.yaml"))
108
+ ? "pnpm"
109
+ : existsSync(resolve(targetDir, "yarn.lock"))
110
+ ? "yarn"
111
+ : existsSync(resolve(targetDir, "bun.lockb"))
112
+ ? "bun"
113
+ : "npm";
114
+ const run = (script: string) => (runner === "npm" ? `npm run ${script}` : `${runner} run ${script}`);
115
+ const first = (...names: string[]) => names.find((n) => typeof scripts[n] === "string");
116
+
117
+ const lint = first("lint", "check", "eslint");
118
+ const test = first("test", "test:unit", "tests");
119
+ const coverage = first("coverage", "test:coverage", "test:cov");
120
+ const build = first("build", "compile");
121
+ return {
122
+ lint: lint ? run(lint) : null,
123
+ test: test ? run(test) : null,
124
+ coverage: coverage ? run(coverage) : null,
125
+ build: build ? run(build) : null,
126
+ };
127
+ }
128
+
129
+ function pythonCommands(targetDir: string): ProjectCommands {
130
+ let pyproject = "";
131
+ try {
132
+ pyproject = readFileSync(resolve(targetDir, "pyproject.toml"), "utf-8");
133
+ } catch {
134
+ /* optional */
135
+ }
136
+ const hasTests = existsSync(resolve(targetDir, "tests")) || /\[tool\.pytest/.test(pyproject);
137
+ return {
138
+ lint: /\[tool\.ruff/.test(pyproject) ? "ruff check ." : null,
139
+ test: hasTests ? "pytest" : null,
140
+ coverage: null,
141
+ build: null,
142
+ };
143
+ }
144
+
145
+ export type InitOptions = {
146
+ stack?: StackId;
147
+ mode?: "copilot" | "autopilot";
148
+ phases?: Phase[];
149
+ commands?: Partial<ProjectCommands>;
150
+ /** Re-scaffold missing files in a project that already has a config. */
151
+ force?: boolean;
152
+ };
153
+
154
+ export type InitResult = {
155
+ ok: boolean;
156
+ error?: string;
157
+ /** Paths written, relative to the project. */
158
+ created: string[];
159
+ /** Paths left alone because they already existed. */
160
+ kept: string[];
161
+ config: HarnessConfig;
162
+ stack: DetectedStack;
163
+ /** The phase the pipeline now sits at. */
164
+ phase: Phase;
165
+ };
166
+
167
+ /**
168
+ * Create a harness in `targetDir`.
169
+ *
170
+ * Refuses an existing harness unless `force`, and even then never overwrites a
171
+ * file that is already there: someone's half-written ARCHITECTURE.md is worth
172
+ * more than our starter.
173
+ */
174
+ export function initHarness(targetDir: string, options: InitOptions = {}): InitResult {
175
+ const stack = options.stack
176
+ ? { ...detectStack(targetDir), id: options.stack }
177
+ : detectStack(targetDir);
178
+
179
+ const created: string[] = [];
180
+ const kept: string[] = [];
181
+
182
+ const alreadyThere = existsSync(P.configPath(targetDir));
183
+ if (alreadyThere && !options.force) {
184
+ return {
185
+ ok: false,
186
+ error: "This project already has a harness. Use /infinity:config to change it, or re-run init with force to restore missing files.",
187
+ created,
188
+ kept,
189
+ config: defaultConfig(),
190
+ stack,
191
+ phase: "define",
192
+ };
193
+ }
194
+
195
+ const phases = normalizePhases(options.phases);
196
+ const phase = phases[0] ?? "define";
197
+
198
+ const config = defaultConfig();
199
+ config.stack = stack.id === "unknown" ? null : stack.id;
200
+ config.mode = options.mode ?? "copilot";
201
+ config.phases = { enabled: phases };
202
+ config.currentPhase = phase;
203
+ config.currentRole = PHASE_ROLE[phase];
204
+ config.commands = { ...stack.commands, ...stripUndefined(options.commands ?? {}) };
205
+
206
+ const write = (path: string, body: string) => {
207
+ const rel = path.slice(targetDir.length + 1);
208
+ if (existsSync(path)) {
209
+ kept.push(rel);
210
+ return;
211
+ }
212
+ mkdirSync(dirname(path), { recursive: true });
213
+ writeFileSync(path, body, "utf-8");
214
+ created.push(rel);
215
+ };
216
+
217
+ if (alreadyThere) {
218
+ kept.push("harness/config.json");
219
+ } else {
220
+ const saved = saveConfig(targetDir, config);
221
+ if (!saved.ok) {
222
+ return { ok: false, error: saved.error ?? "could not write harness/config.json", created, kept, config, stack, phase };
223
+ }
224
+ created.push("harness/config.json");
225
+ }
226
+
227
+ if (existsSync(P.featureListPath(targetDir))) {
228
+ kept.push("harness/features/feature-list.json");
229
+ } else {
230
+ saveFeatureList(targetDir, emptyFeatureList());
231
+ created.push("harness/features/feature-list.json");
232
+ }
233
+
234
+ // The brief points at these every phase; they are reference material, so
235
+ // they come from the package rather than being invented here.
236
+ copyPackagedDocs(targetDir, write);
237
+
238
+ write(P.architecturePath(targetDir), STARTER_ARCHITECTURE);
239
+ write(P.decisionsPath(targetDir), STARTER_DECISIONS);
240
+ write(P.constraintsPath(targetDir), STARTER_CONSTRAINTS);
241
+ write(resolve(P.docsDir(targetDir), "DOMAIN.md"), STARTER_DOMAIN);
242
+ write(P.rubricPath(targetDir), STARTER_RUBRIC);
243
+ write(P.lessonsPath(targetDir), STARTER_LESSONS);
244
+ write(resolve(P.harnessDir(targetDir), ".gitignore"), HARNESS_GITIGNORE);
245
+
246
+ return { ok: true, created, kept, config, stack, phase };
247
+ }
248
+
249
+ function stripUndefined<T extends object>(o: T): Partial<T> {
250
+ return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as Partial<T>;
251
+ }
252
+
253
+ /** Keep the caller's choice, but in pipeline order and without nonsense. */
254
+ function normalizePhases(requested: Phase[] | undefined): Phase[] {
255
+ if (!requested || requested.length === 0) return [...DEFAULT_ENABLED_PHASES];
256
+ const wanted = new Set(requested.filter((p) => PHASE_ORDER.includes(p)));
257
+ wanted.delete("init");
258
+ const ordered = PHASE_ORDER.filter((p) => wanted.has(p));
259
+ return ordered.length ? [...ordered] : [...DEFAULT_ENABLED_PHASES];
260
+ }
261
+
262
+ /** Where the package keeps its own `harness/docs`. */
263
+ export function packagedDocsDir(): string {
264
+ try {
265
+ return resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "harness", "docs");
266
+ } catch {
267
+ return "";
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Copy the phase and role docs out of the package into the project.
273
+ *
274
+ * They are copied rather than read in place so the project can edit them —
275
+ * a team's BUILD doc should be able to say what BUILD means to them.
276
+ */
277
+ function copyPackagedDocs(targetDir: string, write: (path: string, body: string) => void): void {
278
+ const src = packagedDocsDir();
279
+ if (!src || !existsSync(src)) return;
280
+ for (const sub of ["phases", "agents"]) {
281
+ const dir = join(src, sub);
282
+ if (!existsSync(dir)) continue;
283
+ let entries: string[];
284
+ try {
285
+ entries = readdirSync(dir);
286
+ } catch {
287
+ continue;
288
+ }
289
+ for (const name of entries) {
290
+ if (!name.endsWith(".md")) continue;
291
+ const from = join(dir, name);
292
+ try {
293
+ if (!statSync(from).isFile()) continue;
294
+ write(join(P.docsDir(targetDir), sub, name), readFileSync(from, "utf-8"));
295
+ } catch {
296
+ /* one unreadable doc must not fail the whole init */
297
+ }
298
+ }
299
+ }
300
+ }
301
+
302
+ // ── Starters ────────────────────────────────────────────────────────────────
303
+ //
304
+ // Every one of these is under the gate's threshold on purpose. They tell you
305
+ // what to write; they do not write it for you, because the gate that checks
306
+ // them is the only referee this system has.
307
+
308
+ const STARTER_ARCHITECTURE = `# Architecture
309
+
310
+ <!-- The REVIEW gate wants 200+ characters of real content here. -->
311
+
312
+ Modules, what each owns, and how data moves between them.
313
+ `;
314
+
315
+ const STARTER_DECISIONS = `# Decisions
316
+
317
+ <!-- The REVIEW gate wants 100+ characters of real content here. -->
318
+
319
+ One entry per decision: what was chosen, what was rejected, and why.
320
+ `;
321
+
322
+ const STARTER_CONSTRAINTS = `# Constraints
323
+
324
+ What this project may not do, and what it must always do.
325
+ `;
326
+
327
+ const STARTER_DOMAIN = `# Domain
328
+
329
+ Glossary only. One line per term, in the words the users of this system use.
330
+ `;
331
+
332
+ const STARTER_RUBRIC = `# Evaluator Rubric
333
+
334
+ <!-- The REVIEW gate wants 100+ characters of real content here. -->
335
+
336
+ What "good" means for this project, and how a reviewer scores it.
337
+ `;
338
+
339
+ const STARTER_LESSONS = `# Lessons and Decisions
340
+
341
+ Append as you go: what surprised you, what you would do differently.
342
+ `;
343
+
344
+ const HARNESS_GITIGNORE = `# Run state — regenerated every run, never worth a diff.
345
+ run-journal.jsonl
346
+ STOP
347
+ *.bak
348
+ *.lock
349
+ *.ilock
350
+ .preflight
351
+ .run-prompt.md
352
+
353
+ # The plan, the config and the docs ARE worth committing: they are the
354
+ # project's memory, and a harness without them starts from nothing.
355
+ `;
356
+
357
+ /** A human-readable summary of what init did. */
358
+ export function describeInit(result: InitResult): string {
359
+ if (!result.ok) return result.error ?? "init failed";
360
+ const lines = [
361
+ `infinity-harness ready · ${result.stack.label} (${result.stack.evidence})`,
362
+ "",
363
+ `Phase ${result.phase.toUpperCase()} — ${result.config.phases.enabled.join(" → ")}`,
364
+ `Mode ${result.config.mode}`,
365
+ ];
366
+ const cmds = Object.entries(result.config.commands ?? {}).filter(([, v]) => Boolean(v));
367
+ lines.push(
368
+ cmds.length
369
+ ? `Commands ${cmds.map(([k, v]) => `${k}: ${v}`).join(" · ")}`
370
+ : "Commands none detected — set them with /infinity:config → Project commands",
371
+ );
372
+ lines.push("");
373
+ lines.push(`Created ${result.created.length} file(s) under harness/.`);
374
+ if (result.kept.length) lines.push(`Left ${result.kept.length} existing file(s) alone.`);
375
+ lines.push("");
376
+ lines.push("Next: describe what you are building, then /infinity:next for the brief.");
377
+ lines.push(" /infinity:run hands it the wheel once there is a plan.");
378
+ return lines.join("\n");
379
+ }
@@ -330,25 +330,26 @@ export function matchSkills(skills: SkillMeta[], options: MatchOptions = {}): Sk
330
330
  if (hit && !hits.includes(t)) hits.push(t);
331
331
  }
332
332
 
333
- const position = phase ? skill.phases.indexOf(phase) : -1;
334
- const phaseCounts = position >= 0 && (skill.kind === "process" || hits.length > 0);
335
- if (phaseCounts) {
336
- score += Math.max(1, PHASE_WEIGHT - position);
337
- reasons.push(`${phase} phase`);
333
+ // `meta` is on every meta skill's tag list, so counting it like any other
334
+ // hit would surface all of them constantly. They have to be asked for.
335
+ const counted = skill.kind === "meta" ? hits.filter((h) => h !== "meta") : hits;
336
+ const tagScore = TAG_WEIGHT * Math.min(counted.length, MAX_TAG_HITS);
337
+
338
+ // A domain skill has to be pulled in by the task's own vocabulary, and one
339
+ // incidental word is not vocabulary. "Ship the payments rewrite behind a
340
+ // flag" should not summon the CLI-design skill because `flags` is on its
341
+ // tag list. The phase ranks a domain skill; it never qualifies one.
342
+ if (skill.kind !== "process" && tagScore < MIN_SCORE) continue;
343
+
344
+ score += tagScore;
345
+ if (counted.length) {
346
+ reasons.push(`matches ${counted.slice(0, MAX_TAG_HITS).map((h) => `"${h}"`).join(", ")}`);
338
347
  }
339
348
 
340
- if (hits.length && skill.kind !== "meta") {
341
- score += TAG_WEIGHT * Math.min(hits.length, MAX_TAG_HITS);
342
- } else if (hits.length) {
343
- // A meta skill has to be asked for: `meta` is on all of their tag lists,
344
- // so counting it like any other hit would surface all four constantly.
345
- const real = hits.filter((h) => h !== "meta");
346
- if (real.length === 0) continue;
347
- score += TAG_WEIGHT * Math.min(real.length, MAX_TAG_HITS);
348
- }
349
- if (hits.length) {
350
- const shown = hits.filter((h) => h !== "meta" || skill.kind !== "meta").slice(0, MAX_TAG_HITS);
351
- if (shown.length) reasons.push(`matches ${shown.map((h) => `"${h}"`).join(", ")}`);
349
+ const position = phase ? skill.phases.indexOf(phase) : -1;
350
+ if (position >= 0 && (skill.kind === "process" || counted.length > 0)) {
351
+ score += Math.max(1, PHASE_WEIGHT - position);
352
+ reasons.unshift(`${phase} phase`);
352
353
  }
353
354
 
354
355
  // Naming the skill in the task means it, and outranks any guess.
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 {