infinity-harness 2.0.4 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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` — 15 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.4",
3
+ "version": "2.2.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/brief.ts CHANGED
@@ -87,6 +87,20 @@ export async function buildBrief(targetDir: string, options: BuildBriefOptions =
87
87
  notes.push(`${blockedTasks.length} task(s) are blocked: ${blockedTasks.map((t) => t.compositeKey).join(", ")}`);
88
88
  }
89
89
 
90
+ // A second pass at a goal is not the same as a first pass at one, and the
91
+ // agent has no way to tell unless the brief says so. Without this the model
92
+ // re-plans from scratch and rebuilds what the last review already accepted.
93
+ const remaining = Array.isArray(config.remainingWork)
94
+ ? (config.remainingWork as unknown[]).filter((w): w is string => typeof w === "string" && w.trim() !== "")
95
+ : [];
96
+ if (remaining.length > 0) {
97
+ const pass = typeof config.goalPass === "number" ? `pass ${config.goalPass}` : "this pass";
98
+ notes.push(
99
+ `The goal was reviewed and judged not yet met. ${remaining.length} item(s) remain for ${pass} — ` +
100
+ `plan for these, not for the whole goal again: ${remaining.join("; ")}`,
101
+ );
102
+ }
103
+
90
104
  const complete = isFinalPhase(phase, config.phases?.enabled) && progress.tasksDone === progress.tasksTotal;
91
105
 
92
106
  return {
package/src/core/gates.ts CHANGED
@@ -12,12 +12,13 @@
12
12
  */
13
13
 
14
14
  import { resolve } from "node:path";
15
- import { readdirSync, statSync } from "node:fs";
15
+ import { existsSync, readdirSync, statSync } from "node:fs";
16
16
  import type { CheckResult, GateResult, HarnessConfig, Phase } from "./types.ts";
17
17
  import { loadConfig, saveConfig, recordGate } from "./config.ts";
18
18
  import { loadFeatureList, findTask, findFeature, computeProgress, isDone } from "./featureList.ts";
19
19
  import * as P from "./paths.ts";
20
20
  import { readText, fileExists } from "./fsx.ts";
21
+ import { auditSkillsDir } from "./skillsAudit.ts";
21
22
  import {
22
23
  run,
23
24
  isGitRepo,
@@ -196,7 +197,14 @@ async function checkNoPlaceholders({ targetDir, config }: Ctx): Promise<CheckRes
196
197
  function docCheck(name: string, path: string, minChars: number, hint: string): CheckResult {
197
198
  const text = readText(path);
198
199
  if (text === null) return fail(name, `${hint} is missing`);
199
- const body = text.replace(/^#.*$/gm, "").trim();
200
+ // Headings are structure and HTML comments are instructions to the author —
201
+ // neither is content. This matters because the scaffolded starters explain
202
+ // in a comment what belongs in the file; counting that would let the review
203
+ // gate pass on a template nobody had written a word into.
204
+ const body = text
205
+ .replace(/<!--[\s\S]*?-->/g, "")
206
+ .replace(/^#.*$/gm, "")
207
+ .trim();
200
208
  return body.length >= minChars
201
209
  ? pass(name, `${hint} present (${body.length} chars)`)
202
210
  : fail(name, `${hint} exists but is essentially empty (${body.length} chars, need ${minChars})`);
@@ -223,6 +231,39 @@ async function checkRubricContent({ targetDir }: Ctx): Promise<CheckResult> {
223
231
  return docCheck("rubric-content", P.rubricPath(targetDir), 100, "harness/evaluator-rubric.md");
224
232
  }
225
233
 
234
+ /**
235
+ * Any skills this project ships must be loadable by pi.
236
+ *
237
+ * Advisory, because a malformed skill does not make the code wrong — it makes
238
+ * pi print a `[Skill conflicts]` block on every start, which is exactly the
239
+ * kind of thing that gets ignored for months. This package learned that the
240
+ * hard way from its own README sitting in its own skills directory. Reporting
241
+ * it in the gate is how a project finds out before its users do.
242
+ */
243
+ async function checkSkillsLoad({ targetDir }: Ctx): Promise<CheckResult> {
244
+ const dirs = [P.skillsDir(targetDir), resolve(targetDir, ".pi", "skills"), resolve(targetDir, ".agents", "skills")];
245
+ const present = dirs.filter((d) => existsSync(d));
246
+ if (present.length === 0) {
247
+ return { ...skip("skills-load", "this project ships no skills"), advisory: true };
248
+ }
249
+
250
+ const problems: string[] = [];
251
+ let count = 0;
252
+ for (const dir of present) {
253
+ const audit = auditSkillsDir(dir);
254
+ count += audit.skills.length;
255
+ for (const p of audit.problems) {
256
+ problems.push(`${p.file.replace(`${targetDir}/`, "")}: ${p.message}`);
257
+ }
258
+ }
259
+ return problems.length === 0
260
+ ? { ...pass("skills-load", `${count} skill(s) load cleanly in pi`), advisory: true }
261
+ : {
262
+ ...fail("skills-load", `pi would report a skill conflict — ${problems.slice(0, 4).join("; ")}`),
263
+ advisory: true,
264
+ };
265
+ }
266
+
226
267
  async function checkTagged({ targetDir }: Ctx): Promise<CheckResult> {
227
268
  return (await gitHasTag(targetDir))
228
269
  ? pass("tagged", "HEAD carries a release tag")
@@ -304,12 +345,12 @@ type Check = (ctx: Ctx) => Promise<CheckResult>;
304
345
 
305
346
  const PHASE_CHECKS: Record<Phase, Check[]> = {
306
347
  init: [checkGitRepo, checkConfigExists],
307
- define: [checkFeatureCriteria],
348
+ define: [checkFeatureCriteria, checkSkillsLoad],
308
349
  plan: [checkFeatureCriteria, checkTasksPlanned],
309
350
  build: [checkLint, checkTests, checkCoverage, checkNoPlaceholders, checkTasksComplete],
310
351
  verify: [checkTests, checkCoverage, checkGitClean],
311
352
  simplify: [checkTests, checkNoEmptyDirs, checkGitClean],
312
- review: [checkBranchUpToDate, checkRubricContent, checkReadme, checkArchitectureDoc, checkDecisionsLogged],
353
+ review: [checkBranchUpToDate, checkRubricContent, checkReadme, checkArchitectureDoc, checkDecisionsLogged, checkSkillsLoad],
313
354
  ship: [
314
355
  checkGitClean,
315
356
  checkTagged,
@@ -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
+ }