infinity-harness 2.0.4 → 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 +45 -0
- package/README.md +44 -6
- package/extensions/infinity-harness/index.ts +198 -9
- package/harness/docs/ARCHITECTURE.md +1 -1
- package/harness/docs/phases/define.md +27 -9
- package/harness/docs/phases/ship.md +1 -1
- package/harness/skills/code-review.md +2 -2
- package/harness/skills/context-hygiene.md +1 -1
- package/harness/skills/diagnosing-bugs.md +1 -1
- package/harness/skills/domain-modeling.md +2 -1
- package/harness/skills/prototype.md +2 -2
- package/package.json +1 -1
- package/src/core/gates.ts +8 -1
- package/src/core/init.ts +379 -0
- package/src/taskList.ts +154 -7
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,51 @@ 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
|
+
|
|
7
52
|
## [2.0.4] — 2026-08-23
|
|
8
53
|
|
|
9
54
|
### Changed
|
package/README.md
CHANGED
|
@@ -77,12 +77,25 @@ cd your-project
|
|
|
77
77
|
pi
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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
|
|
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/
|
|
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 #
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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) =>
|
|
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:
|
|
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, {
|
|
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,
|
|
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,
|
|
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,
|
|
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` —
|
|
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. **
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
- [ ]
|
|
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.
|
|
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
|
|
29
|
-
(`
|
|
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 → `
|
|
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),
|
|
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
|
|
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
|
|
39
|
-
|
|
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
|
+
"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
|
-
|
|
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})`);
|
package/src/core/init.ts
ADDED
|
@@ -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
|
+
}
|
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
|
-
|
|
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.
|
|
133
|
-
throw new ValidationError(
|
|
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
|
-
|
|
150
|
-
|
|
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 =
|
|
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 {
|