infinity-harness 2.0.4 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +111 -0
- package/README.md +98 -7
- package/extensions/infinity-harness/index.ts +771 -10
- 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/brief.ts +14 -0
- package/src/core/gates.ts +45 -4
- package/src/core/init.ts +379 -0
- package/src/escalate.ts +370 -0
- package/src/goal.ts +411 -0
- package/src/loop.ts +158 -12
- package/src/taskList.ts +154 -7
- package/src/ui/widget.ts +15 -0
- package/src/unstuck.ts +46 -19
|
@@ -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` — 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. **
|
|
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.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
|
-
|
|
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,
|
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
|
+
}
|