infinity-harness 2.2.0 → 2.3.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 +130 -0
- package/README.md +153 -29
- package/extensions/infinity-harness/index.ts +742 -51
- package/harness/docs/agents/researcher.md +14 -0
- package/harness/docs/phases/research.md +64 -0
- package/harness/skills/grilling.md +1 -1
- package/harness/skills/research.md +1 -1
- package/package.json +2 -2
- package/src/approval.ts +194 -0
- package/src/core/brief.ts +51 -2
- package/src/core/config.ts +32 -13
- package/src/core/fsx.ts +10 -0
- package/src/core/gates.ts +15 -0
- package/src/core/init.ts +43 -2
- package/src/core/paths.ts +14 -0
- package/src/core/settings.ts +56 -0
- package/src/core/types.ts +65 -2
- package/src/goal.ts +11 -0
- package/src/handoff.ts +197 -0
- package/src/intake.ts +263 -0
- package/src/loop.ts +88 -10
- package/src/remote.ts +22 -1
- package/src/runState.ts +121 -0
- package/src/ui/dashboard.ts +140 -16
- package/src/ui/planTree.ts +287 -0
- package/src/ui/theme.ts +15 -0
- package/src/ui/widget.ts +230 -67
- package/src/ui/wizard.ts +195 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Researcher Role
|
|
2
|
+
|
|
3
|
+
Tone: Curious and sceptical, in that order. Report what you found, not what you hoped.
|
|
4
|
+
|
|
5
|
+
You establish what is true before anyone commits to what to build.
|
|
6
|
+
|
|
7
|
+
- Read primary sources. A summary of a summary is a rumour
|
|
8
|
+
- Separate what you were told from what you inferred — an inference is a question
|
|
9
|
+
- Present at least two real options, with what each costs
|
|
10
|
+
- Recommend one, and name what would make the recommendation wrong
|
|
11
|
+
- End with the open questions only a human can answer; they are the DEFINE interview
|
|
12
|
+
- Write findings to `harness/docs/RESEARCH.md` and nothing else — you do not
|
|
13
|
+
write code, choose a file layout, or open tasks
|
|
14
|
+
- Hand off to Planner once a human could read your document and disagree with it
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# RESEARCH Phase
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
Turn an idea into evidence before anyone writes a specification. RESEARCH is
|
|
5
|
+
optional and runs first, before DEFINE. Its output is one document —
|
|
6
|
+
`harness/docs/RESEARCH.md` — that a human can read and disagree with.
|
|
7
|
+
|
|
8
|
+
The phase exists because "build me a thing" is not a specification, and the
|
|
9
|
+
cheapest hour of a long run is the one spent finding out what the thing has to
|
|
10
|
+
be. A run that gets BUILD wrong fails a gate and retries. A run that got the
|
|
11
|
+
idea wrong spends a weekend building the wrong product perfectly.
|
|
12
|
+
|
|
13
|
+
## When to Use
|
|
14
|
+
- The human gave an idea, not a specification
|
|
15
|
+
- The problem has prior art worth reading before reinventing it
|
|
16
|
+
- There is a real choice of approach and the trade-offs are not obvious
|
|
17
|
+
- Skip it when the work is well understood — this phase is off by default
|
|
18
|
+
|
|
19
|
+
## Craft Skills (read before working)
|
|
20
|
+
- `harness/skills/research.md` — answer blocking factual questions from primary sources
|
|
21
|
+
- `harness/skills/grilling.md` — attack your own conclusions before a human does
|
|
22
|
+
- `harness/skills/scope-discipline.md` — research is not the place to start building
|
|
23
|
+
|
|
24
|
+
## Process
|
|
25
|
+
1. Restate the idea in your own words, including what you are *not* sure about.
|
|
26
|
+
2. Find prior art. What already exists that does some of this? What did it get
|
|
27
|
+
right, and where does it stop?
|
|
28
|
+
3. Name the constraints that are real: platform, data, budget, deadline, the
|
|
29
|
+
humans who will operate it. Say which ones you were told and which ones you
|
|
30
|
+
inferred, because an inferred constraint is a question for the human.
|
|
31
|
+
4. Lay out the genuine options — at least two — with what each costs and what
|
|
32
|
+
each buys. An option list of one is a decision wearing a disguise.
|
|
33
|
+
5. Recommend one, and say what would have to be true for the recommendation to
|
|
34
|
+
be wrong. That sentence is the most useful line in the document.
|
|
35
|
+
6. List the open questions a human has to answer. These become the DEFINE
|
|
36
|
+
interview.
|
|
37
|
+
7. Write all of it to `harness/docs/RESEARCH.md`.
|
|
38
|
+
8. Run `infinity_validate`. On PASS the harness advances to DEFINE — or, if the
|
|
39
|
+
human asked to approve this phase, it stops and waits for their signature.
|
|
40
|
+
|
|
41
|
+
## Rationalizations to Avoid
|
|
42
|
+
| Excuse | Rebuttal |
|
|
43
|
+
|--------|----------|
|
|
44
|
+
| "I already know how to build this" | Then the phase costs you ten minutes and confirms it |
|
|
45
|
+
| "I'll research as I go" | Research during BUILD arrives after the decisions it should have informed |
|
|
46
|
+
| "There's no prior art" | There is. You have not looked yet |
|
|
47
|
+
|
|
48
|
+
## Red Flags
|
|
49
|
+
- A document with no open questions — you did not look hard enough
|
|
50
|
+
- One option presented as inevitable
|
|
51
|
+
- Findings with no source, stated as confidently as findings with one
|
|
52
|
+
- Research that has already chosen the file layout: that is PLAN's job
|
|
53
|
+
|
|
54
|
+
## Verification
|
|
55
|
+
- [ ] `harness/docs/RESEARCH.md` exists and says something a human could argue with
|
|
56
|
+
- [ ] Prior art named, not gestured at
|
|
57
|
+
- [ ] At least two options, with costs
|
|
58
|
+
- [ ] A recommendation, and what would falsify it
|
|
59
|
+
- [ ] Open questions listed for DEFINE
|
|
60
|
+
- [ ] `infinity_validate` passes
|
|
61
|
+
|
|
62
|
+
## Handoff
|
|
63
|
+
On gate pass: the harness advances to DEFINE (Researcher → Planner). The open
|
|
64
|
+
questions from this document are the DEFINE interview.
|
|
@@ -3,7 +3,7 @@ name: grilling
|
|
|
3
3
|
description: Stress-test a spec or plan with relentless one-at-a-time questions before committing
|
|
4
4
|
tags: [grill, spec, requirements, questions, stress, interview, scope, clarify]
|
|
5
5
|
when: before proposing the sprint contract, or when a plan feels underspecified
|
|
6
|
-
phases: [define]
|
|
6
|
+
phases: [research, define]
|
|
7
7
|
kind: process
|
|
8
8
|
provenance: { origin: "mattpocock/skills", license: MIT, adapted: true }
|
|
9
9
|
---
|
|
@@ -3,7 +3,7 @@ name: research
|
|
|
3
3
|
description: Answer factual questions from primary sources; capture cited findings in the repo
|
|
4
4
|
tags: [research, docs, documentation, investigate, source, facts, api, spec, evidence]
|
|
5
5
|
when: a factual question about an API, library, protocol, or tool blocks progress
|
|
6
|
-
phases: [define, plan, build]
|
|
6
|
+
phases: [research, define, plan, build]
|
|
7
7
|
kind: process
|
|
8
8
|
provenance: { origin: "mattpocock/skills", license: MIT, adapted: true }
|
|
9
9
|
---
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "infinity-harness",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "A pi agent extension that runs a gated build pipeline unattended
|
|
3
|
+
"version": "2.3.0",
|
|
4
|
+
"description": "A pi agent extension that runs a gated build pipeline unattended — enforces phases, validates with deterministic gates, and keeps working for hours or days without losing the plan.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"pi-package",
|
package/src/approval.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infinity-harness — human sign-off on the phases that decide what gets built.
|
|
3
|
+
*
|
|
4
|
+
* The gate is a good referee for execution and a poor one for intent. It can
|
|
5
|
+
* prove a feature has acceptance criteria; it cannot prove they are the right
|
|
6
|
+
* criteria. It can prove a plan has tasks; it cannot prove the plan builds the
|
|
7
|
+
* thing the human asked for. Those two judgements are the human's, and this
|
|
8
|
+
* module is where the run stops to collect them.
|
|
9
|
+
*
|
|
10
|
+
* The policy is per-phase and set once, by the start-up wizard:
|
|
11
|
+
*
|
|
12
|
+
* copilot RESEARCH (if on), DEFINE and PLAN are approved by the human
|
|
13
|
+
* autopilot the human picks which of them to approve and which to forfeit;
|
|
14
|
+
* forfeiting all three is the "give it a goal and walk away" mode
|
|
15
|
+
*
|
|
16
|
+
* Nothing after PLAN is approvable. Once the definition and the plan are
|
|
17
|
+
* signed off, a wrong BUILD fails a gate and retries — a wrong DEFINE spends a
|
|
18
|
+
* weekend building the wrong product perfectly.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { HarnessConfig, Phase } from "./core/types.ts";
|
|
22
|
+
import { APPROVABLE_PHASES } from "./core/types.ts";
|
|
23
|
+
import { loadConfig, saveConfig } from "./core/config.ts";
|
|
24
|
+
|
|
25
|
+
export type ApprovalRequest = {
|
|
26
|
+
phase: Phase;
|
|
27
|
+
/** What the human is being asked to look at before signing. */
|
|
28
|
+
artifacts: string[];
|
|
29
|
+
prompt: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function isApprovable(phase: Phase | null): boolean {
|
|
33
|
+
return phase !== null && (APPROVABLE_PHASES as readonly string[]).includes(phase);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Does `phase` need a signature before the pipeline may leave it? */
|
|
37
|
+
export function needsApproval(config: HarnessConfig, phase: Phase | null): boolean {
|
|
38
|
+
if (!isApprovable(phase)) return false;
|
|
39
|
+
const approvals = (config.approvals ?? {}) as Record<string, unknown>;
|
|
40
|
+
return approvals[phase as string] === true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Which phases the human has asked to sign, in pipeline order. */
|
|
44
|
+
export function approvedPhases(config: HarnessConfig): Phase[] {
|
|
45
|
+
return APPROVABLE_PHASES.filter((p) => needsApproval(config, p)) as Phase[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const ARTIFACTS: Record<string, string[]> = {
|
|
49
|
+
research: ["harness/docs/RESEARCH.md"],
|
|
50
|
+
define: ["specs/prd.md", "harness/sprint-contract.md", "the acceptance criteria in the plan"],
|
|
51
|
+
plan: ["the task list in the widget, or `/infinity:dashboard`"],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const ASKS: Record<string, string> = {
|
|
55
|
+
research:
|
|
56
|
+
"Does this research describe the problem you actually have, and is the recommendation one you would take?",
|
|
57
|
+
define:
|
|
58
|
+
"Is this the thing you want built, and would meeting these criteria convince you it works?",
|
|
59
|
+
plan: "Does this plan build that thing, in an order that makes sense, with nothing important missing?",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export function describeApproval(phase: Phase): ApprovalRequest {
|
|
63
|
+
return {
|
|
64
|
+
phase,
|
|
65
|
+
artifacts: ARTIFACTS[phase] ?? [],
|
|
66
|
+
prompt: ASKS[phase] ?? `Approve the ${phase.toUpperCase()} phase?`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The text a human reads when the run pauses for them. */
|
|
71
|
+
export function renderApprovalRequest(request: ApprovalRequest): string {
|
|
72
|
+
const lines = [
|
|
73
|
+
`${request.phase.toUpperCase()} passed its gate and is waiting for you.`,
|
|
74
|
+
"",
|
|
75
|
+
request.prompt,
|
|
76
|
+
];
|
|
77
|
+
if (request.artifacts.length) {
|
|
78
|
+
lines.push("", "Look at:");
|
|
79
|
+
for (const a of request.artifacts) lines.push(` - ${a}`);
|
|
80
|
+
}
|
|
81
|
+
lines.push(
|
|
82
|
+
"",
|
|
83
|
+
"`/infinity:approve` continues. `/infinity:approve <what is wrong>` sends it back",
|
|
84
|
+
"to be redone with your note. The run does not advance until you answer.",
|
|
85
|
+
);
|
|
86
|
+
return lines.join("\n");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Park the pipeline on `phase` until a human answers. */
|
|
90
|
+
export function requestApproval(targetDir: string, phase: Phase): { ok: boolean; error: string | null } {
|
|
91
|
+
const { config, ok, error } = loadConfig(targetDir);
|
|
92
|
+
if (!ok) return { ok: false, error: error ?? "cannot load config" };
|
|
93
|
+
config.awaitingApproval = phase;
|
|
94
|
+
const saved = saveConfig(targetDir, config);
|
|
95
|
+
return { ok: saved.ok, error: saved.error };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A rejection, pinned to the state of the project when it was made.
|
|
100
|
+
*
|
|
101
|
+
* Without the fingerprint the run nags: the gate is deterministic, so the
|
|
102
|
+
* moment a phase is sent back it passes again on the next tick and asks the
|
|
103
|
+
* human the same question. Recording *what the project looked like* when they
|
|
104
|
+
* said no means the question is only asked again once something has actually
|
|
105
|
+
* changed in response to it.
|
|
106
|
+
*/
|
|
107
|
+
export type ApprovalRejection = { phase: string; note: string; fingerprint: string; at: string };
|
|
108
|
+
|
|
109
|
+
export function loadRejection(config: HarnessConfig): ApprovalRejection | null {
|
|
110
|
+
const raw = config.approvalRejection as Partial<ApprovalRejection> | undefined;
|
|
111
|
+
if (!raw || typeof raw.phase !== "string" || typeof raw.fingerprint !== "string") return null;
|
|
112
|
+
return {
|
|
113
|
+
phase: raw.phase,
|
|
114
|
+
note: typeof raw.note === "string" ? raw.note : "",
|
|
115
|
+
fingerprint: raw.fingerprint,
|
|
116
|
+
at: typeof raw.at === "string" ? raw.at : "",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Has the project moved since the human said no?
|
|
122
|
+
*
|
|
123
|
+
* `true` means the agent has not yet done anything about the rejection, so
|
|
124
|
+
* re-asking would be asking the same question about the same artefact.
|
|
125
|
+
*/
|
|
126
|
+
export function rejectionStandsFor(
|
|
127
|
+
config: HarnessConfig,
|
|
128
|
+
phase: string,
|
|
129
|
+
fingerprint: string,
|
|
130
|
+
): ApprovalRejection | null {
|
|
131
|
+
const rejection = loadRejection(config);
|
|
132
|
+
if (!rejection || rejection.phase !== phase) return null;
|
|
133
|
+
return rejection.fingerprint === fingerprint ? rejection : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export type ApprovalOutcome =
|
|
137
|
+
| { ok: true; approved: true; phase: Phase }
|
|
138
|
+
| { ok: true; approved: false; phase: Phase; note: string }
|
|
139
|
+
| { ok: false; error: string };
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Answer a pending approval.
|
|
143
|
+
*
|
|
144
|
+
* An empty note approves. A note is a rejection carrying the reason, which is
|
|
145
|
+
* the only useful form of "no": the phase is re-run with the human's words in
|
|
146
|
+
* the brief, rather than re-run identically and failing the same way.
|
|
147
|
+
*/
|
|
148
|
+
export function resolveApproval(
|
|
149
|
+
targetDir: string,
|
|
150
|
+
note = "",
|
|
151
|
+
fingerprint = "",
|
|
152
|
+
): ApprovalOutcome {
|
|
153
|
+
const { config, ok, error } = loadConfig(targetDir);
|
|
154
|
+
if (!ok) return { ok: false, error: error ?? "cannot load config" };
|
|
155
|
+
|
|
156
|
+
const phase = config.awaitingApproval;
|
|
157
|
+
if (!phase) return { ok: false, error: "Nothing is waiting for approval." };
|
|
158
|
+
|
|
159
|
+
config.awaitingApproval = null;
|
|
160
|
+
const trimmed = note.trim();
|
|
161
|
+
if (trimmed) {
|
|
162
|
+
const notes = Array.isArray(config.approvalNotes) ? (config.approvalNotes as unknown[]) : [];
|
|
163
|
+
config.approvalNotes = [
|
|
164
|
+
...notes,
|
|
165
|
+
{ phase, note: trimmed, at: new Date().toISOString() },
|
|
166
|
+
].slice(-50);
|
|
167
|
+
config.approvalRejection = { phase, note: trimmed, fingerprint, at: new Date().toISOString() };
|
|
168
|
+
} else {
|
|
169
|
+
// Approved. The old rejection is answered, and the note that carried it
|
|
170
|
+
// must stop appearing in the brief — an agent told to address a complaint
|
|
171
|
+
// that was already resolved goes round in circles fixing nothing.
|
|
172
|
+
config.approvalRejection = null;
|
|
173
|
+
config.approvalNotes = (Array.isArray(config.approvalNotes) ? config.approvalNotes : []).filter(
|
|
174
|
+
(n) => typeof n === "object" && n !== null && (n as { phase?: unknown }).phase !== phase,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const saved = saveConfig(targetDir, config);
|
|
179
|
+
if (!saved.ok) return { ok: false, error: saved.error ?? "could not save config" };
|
|
180
|
+
|
|
181
|
+
return trimmed
|
|
182
|
+
? { ok: true, approved: false, phase, note: trimmed }
|
|
183
|
+
: { ok: true, approved: true, phase };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Notes the human left when rejecting a phase, newest last. */
|
|
187
|
+
export function approvalNotes(config: HarnessConfig, phase?: Phase): { phase: string; note: string; at: string }[] {
|
|
188
|
+
const raw = Array.isArray(config.approvalNotes) ? config.approvalNotes : [];
|
|
189
|
+
const all = raw.filter(
|
|
190
|
+
(n): n is { phase: string; note: string; at: string } =>
|
|
191
|
+
typeof n === "object" && n !== null && typeof (n as { note?: unknown }).note === "string",
|
|
192
|
+
);
|
|
193
|
+
return phase ? all.filter((n) => n.phase === phase) : all;
|
|
194
|
+
}
|
package/src/core/brief.ts
CHANGED
|
@@ -42,6 +42,8 @@ const SKILL_SUGGESTIONS = 2;
|
|
|
42
42
|
|
|
43
43
|
const PHASE_INTENT: Record<Phase, string> = {
|
|
44
44
|
init: "Set up the project skeleton and confirm the harness can see it.",
|
|
45
|
+
research:
|
|
46
|
+
"Find out what this actually has to be. Prior art, constraints, options with costs, a recommendation, and the questions only the human can answer. Write harness/docs/RESEARCH.md. No code.",
|
|
45
47
|
define: "Write down what is being built and how you will know it is done. Acceptance criteria per feature.",
|
|
46
48
|
plan: "Break each feature into ordered, dependency-aware tasks. No code yet.",
|
|
47
49
|
build: "Implement the current task. One task at a time, tests alongside.",
|
|
@@ -66,6 +68,25 @@ export async function buildBrief(targetDir: string, options: BuildBriefOptions =
|
|
|
66
68
|
const notes: string[] = [];
|
|
67
69
|
if (!ok) notes.push("harness/config.json is missing or unreadable — run init.");
|
|
68
70
|
|
|
71
|
+
// The human rejected this phase and said why. Repeating the phase without
|
|
72
|
+
// repeating their words produces the same output and the same rejection.
|
|
73
|
+
if (phase) {
|
|
74
|
+
const rejections = rejectionNotes(config, phase);
|
|
75
|
+
const last = rejections[rejections.length - 1];
|
|
76
|
+
if (last) {
|
|
77
|
+
notes.push(
|
|
78
|
+
`A human reviewed ${phase.toUpperCase()} and sent it back. Address this before validating again: ${last.note}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (config.awaitingApproval) {
|
|
84
|
+
notes.push(
|
|
85
|
+
`${String(config.awaitingApproval).toUpperCase()} is waiting for a human signature. ` +
|
|
86
|
+
"Do not start the next phase — stop and let them answer.",
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
69
90
|
const nextTask = nextActionableTask(list);
|
|
70
91
|
const feature = nextTask ? findFeature(list, nextTask.featureId) : null;
|
|
71
92
|
|
|
@@ -108,7 +129,11 @@ export async function buildBrief(targetDir: string, options: BuildBriefOptions =
|
|
|
108
129
|
role,
|
|
109
130
|
paused: Boolean(config.paused),
|
|
110
131
|
complete,
|
|
111
|
-
goal
|
|
132
|
+
// The plan's goal is what the agent wrote down; the intake brief is what
|
|
133
|
+
// the human actually said. Before a plan exists, the second is all there
|
|
134
|
+
// is — and a run that starts with neither invents its own scope, which is
|
|
135
|
+
// exactly the failure this field was added to stop.
|
|
136
|
+
goal: (list.goals ?? [])[0]?.title ?? intakeBrief(config),
|
|
112
137
|
feature: feature ? { id: feature.id, name: feature.name } : null,
|
|
113
138
|
task: nextTask
|
|
114
139
|
? {
|
|
@@ -134,7 +159,7 @@ export async function buildBrief(targetDir: string, options: BuildBriefOptions =
|
|
|
134
159
|
max: retry.tasks.max,
|
|
135
160
|
},
|
|
136
161
|
skills: suggestSkills(phase, {
|
|
137
|
-
goal: (list.goals ?? [])[0]?.title ??
|
|
162
|
+
goal: (list.goals ?? [])[0]?.title ?? intakeBrief(config),
|
|
138
163
|
feature: feature?.name ?? null,
|
|
139
164
|
task: nextTask?.description ?? null,
|
|
140
165
|
criteria: collectCriteria(feature, nextTask?.criteria),
|
|
@@ -166,6 +191,30 @@ function suggestSkills(
|
|
|
166
191
|
}
|
|
167
192
|
}
|
|
168
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Notes a human left when they sent a phase back.
|
|
196
|
+
*
|
|
197
|
+
* Read here rather than imported from `src/approval.ts` on purpose: `core/`
|
|
198
|
+
* owns the config file format and must not depend on the layer above it.
|
|
199
|
+
*/
|
|
200
|
+
function rejectionNotes(config: HarnessConfig, phase: Phase): { note: string }[] {
|
|
201
|
+
const raw = Array.isArray(config.approvalNotes) ? config.approvalNotes : [];
|
|
202
|
+
return raw.filter(
|
|
203
|
+
(n): n is { phase: string; note: string } =>
|
|
204
|
+
typeof n === "object" &&
|
|
205
|
+
n !== null &&
|
|
206
|
+
(n as { phase?: unknown }).phase === phase &&
|
|
207
|
+
typeof (n as { note?: unknown }).note === "string",
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** What the human typed into the start-up wizard, if anything. */
|
|
212
|
+
function intakeBrief(config: HarnessConfig): string | null {
|
|
213
|
+
const intake = config.intake as { brief?: unknown } | undefined;
|
|
214
|
+
const brief = intake?.brief;
|
|
215
|
+
return typeof brief === "string" && brief.trim() ? brief.trim() : null;
|
|
216
|
+
}
|
|
217
|
+
|
|
169
218
|
function collectCriteria(
|
|
170
219
|
feature: { criteria?: string[] } | null,
|
|
171
220
|
taskCriteria: string[] | undefined,
|
package/src/core/config.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { HarnessConfig, GateHistoryEntry, Phase, Role } from "./types.ts";
|
|
11
|
-
import { DEFAULT_ENABLED_PHASES } from "./types.ts";
|
|
11
|
+
import { DEFAULT_ENABLED_PHASES, PHASE_ROLE } from "./types.ts";
|
|
12
12
|
import { configPath } from "./paths.ts";
|
|
13
13
|
import { readJson, writeJsonAtomic, backupOnce, fileExists } from "./fsx.ts";
|
|
14
14
|
|
|
@@ -49,6 +49,10 @@ export function defaultConfig(): HarnessConfig {
|
|
|
49
49
|
},
|
|
50
50
|
phases: { enabled: [...DEFAULT_ENABLED_PHASES] },
|
|
51
51
|
roles: { strict: false },
|
|
52
|
+
session: { handoff: "phase", contextThreshold: 0.7, carryNotes: true },
|
|
53
|
+
approvals: { research: false, define: false, plan: false },
|
|
54
|
+
intake: { completed: false, brief: null, at: null },
|
|
55
|
+
awaitingApproval: null,
|
|
52
56
|
loop: {
|
|
53
57
|
maxIterations: 2000,
|
|
54
58
|
maxWallClockMs: 24 * 60 * 60 * 1000,
|
|
@@ -159,6 +163,25 @@ export function recordGate(
|
|
|
159
163
|
...(scope?.feature ? { feature: scope.feature } : {}),
|
|
160
164
|
...(scope?.task ? { task: scope.task } : {}),
|
|
161
165
|
};
|
|
166
|
+
|
|
167
|
+
// Two callers record the same *pass*: `runChecks` when the gate is run, and
|
|
168
|
+
// `transitionPhase` when that verdict lets the pipeline leave the phase. The
|
|
169
|
+
// history read `research:pass → research:pass → define:pass → define:pass`,
|
|
170
|
+
// which reads as a phase that had to be attempted twice — the opposite of
|
|
171
|
+
// what happened. The same pass is not two passes.
|
|
172
|
+
//
|
|
173
|
+
// Repeated *failures* are never collapsed: five failures on one phase is
|
|
174
|
+
// exactly the fact a human comes back to read.
|
|
175
|
+
const last = config.gateHistory[config.gateHistory.length - 1];
|
|
176
|
+
const duplicatePass =
|
|
177
|
+
result === "pass" &&
|
|
178
|
+
last !== undefined &&
|
|
179
|
+
last.phase === entry.phase &&
|
|
180
|
+
last.result === "pass" &&
|
|
181
|
+
(last.feature ?? null) === (entry.feature ?? null) &&
|
|
182
|
+
(last.task ?? null) === (entry.task ?? null);
|
|
183
|
+
if (duplicatePass) return;
|
|
184
|
+
|
|
162
185
|
config.gateHistory.push(entry);
|
|
163
186
|
trimGateHistory(config);
|
|
164
187
|
}
|
|
@@ -249,17 +272,13 @@ export function validateConfig(config: HarnessConfig): string[] {
|
|
|
249
272
|
return missing;
|
|
250
273
|
}
|
|
251
274
|
|
|
275
|
+
/**
|
|
276
|
+
* The role that owns a phase.
|
|
277
|
+
*
|
|
278
|
+
* This used to be a second copy of `PHASE_ROLE` written out longhand, which
|
|
279
|
+
* meant adding a phase compiled fine and then silently reported the wrong
|
|
280
|
+
* role for it. There is one table.
|
|
281
|
+
*/
|
|
252
282
|
export function currentRoleFor(phase: Phase | null): Role | null {
|
|
253
|
-
|
|
254
|
-
const map: Record<Phase, Role> = {
|
|
255
|
-
init: "planner",
|
|
256
|
-
define: "planner",
|
|
257
|
-
plan: "planner",
|
|
258
|
-
build: "generator",
|
|
259
|
-
verify: "evaluator",
|
|
260
|
-
simplify: "simplifier",
|
|
261
|
-
review: "evaluator",
|
|
262
|
-
ship: "evaluator",
|
|
263
|
-
};
|
|
264
|
-
return map[phase] ?? null;
|
|
283
|
+
return phase ? (PHASE_ROLE[phase] ?? null) : null;
|
|
265
284
|
}
|
package/src/core/fsx.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
mkdirSync,
|
|
14
14
|
readFileSync,
|
|
15
15
|
renameSync,
|
|
16
|
+
rmSync,
|
|
16
17
|
unlinkSync,
|
|
17
18
|
writeFileSync,
|
|
18
19
|
copyFileSync,
|
|
@@ -117,3 +118,12 @@ export function backupOnce(path: string): void {
|
|
|
117
118
|
export function fileExists(path: string): boolean {
|
|
118
119
|
return existsSync(path);
|
|
119
120
|
}
|
|
121
|
+
|
|
122
|
+
/** Delete a file if it is there. Never throws — a missing file is the goal. */
|
|
123
|
+
export function removeFile(path: string): void {
|
|
124
|
+
try {
|
|
125
|
+
if (existsSync(path)) rmSync(path, { force: true });
|
|
126
|
+
} catch {
|
|
127
|
+
/* a file we cannot delete is the caller's problem, not a crash */
|
|
128
|
+
}
|
|
129
|
+
}
|
package/src/core/gates.ts
CHANGED
|
@@ -221,6 +221,20 @@ async function checkLicense({ targetDir }: Ctx): Promise<CheckResult> {
|
|
|
221
221
|
async function checkChangelog({ targetDir }: Ctx): Promise<CheckResult> {
|
|
222
222
|
return docCheck("changelog", resolve(targetDir, "CHANGELOG.md"), 100, "CHANGELOG.md");
|
|
223
223
|
}
|
|
224
|
+
/**
|
|
225
|
+
* RESEARCH produced something a human could read and disagree with.
|
|
226
|
+
*
|
|
227
|
+
* The research phase exists because "build me a thing" is not a specification,
|
|
228
|
+
* and the cheapest hour of a long run is the one spent finding out what the
|
|
229
|
+
* thing actually has to be. The gate asks for evidence of that hour: prior
|
|
230
|
+
* art, constraints, options weighed, a recommendation. 400 characters is a low
|
|
231
|
+
* bar deliberately — the gate judges that work happened, the human judges
|
|
232
|
+
* whether it was any good.
|
|
233
|
+
*/
|
|
234
|
+
async function checkResearchDoc({ targetDir }: Ctx): Promise<CheckResult> {
|
|
235
|
+
return docCheck("research-doc", P.researchPath(targetDir), 400, "harness/docs/RESEARCH.md");
|
|
236
|
+
}
|
|
237
|
+
|
|
224
238
|
async function checkArchitectureDoc({ targetDir }: Ctx): Promise<CheckResult> {
|
|
225
239
|
return docCheck("architecture-doc", P.architecturePath(targetDir), 200, "harness/docs/ARCHITECTURE.md");
|
|
226
240
|
}
|
|
@@ -345,6 +359,7 @@ type Check = (ctx: Ctx) => Promise<CheckResult>;
|
|
|
345
359
|
|
|
346
360
|
const PHASE_CHECKS: Record<Phase, Check[]> = {
|
|
347
361
|
init: [checkGitRepo, checkConfigExists],
|
|
362
|
+
research: [checkResearchDoc],
|
|
348
363
|
define: [checkFeatureCriteria, checkSkillsLoad],
|
|
349
364
|
plan: [checkFeatureCriteria, checkTasksPlanned],
|
|
350
365
|
build: [checkLint, checkTests, checkCoverage, checkNoPlaceholders, checkTasksComplete],
|
package/src/core/init.ts
CHANGED
|
@@ -149,6 +149,12 @@ export type InitOptions = {
|
|
|
149
149
|
commands?: Partial<ProjectCommands>;
|
|
150
150
|
/** Re-scaffold missing files in a project that already has a config. */
|
|
151
151
|
force?: boolean;
|
|
152
|
+
/** Which phases stop for a human signature. See SessionPolicy / ApprovalPolicy. */
|
|
153
|
+
approvals?: Partial<HarnessConfig["approvals"]>;
|
|
154
|
+
/** Session-handoff policy. Defaults to a fresh session per phase. */
|
|
155
|
+
session?: Partial<HarnessConfig["session"]>;
|
|
156
|
+
/** What the human said they want built. Recorded, and read by the first brief. */
|
|
157
|
+
brief?: string | null;
|
|
152
158
|
};
|
|
153
159
|
|
|
154
160
|
export type InitResult = {
|
|
@@ -202,6 +208,15 @@ export function initHarness(targetDir: string, options: InitOptions = {}): InitR
|
|
|
202
208
|
config.currentPhase = phase;
|
|
203
209
|
config.currentRole = PHASE_ROLE[phase];
|
|
204
210
|
config.commands = { ...stack.commands, ...stripUndefined(options.commands ?? {}) };
|
|
211
|
+
config.approvals = { ...config.approvals, ...stripUndefined(options.approvals ?? {}) };
|
|
212
|
+
config.session = { ...config.session, ...stripUndefined(options.session ?? {}) };
|
|
213
|
+
if (options.brief !== undefined) {
|
|
214
|
+
config.intake = {
|
|
215
|
+
completed: true,
|
|
216
|
+
brief: options.brief && options.brief.trim() ? options.brief.trim() : null,
|
|
217
|
+
at: new Date().toISOString(),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
205
220
|
|
|
206
221
|
const write = (path: string, body: string) => {
|
|
207
222
|
const rel = path.slice(targetDir.length + 1);
|
|
@@ -236,6 +251,7 @@ export function initHarness(targetDir: string, options: InitOptions = {}): InitR
|
|
|
236
251
|
copyPackagedDocs(targetDir, write);
|
|
237
252
|
|
|
238
253
|
write(P.architecturePath(targetDir), STARTER_ARCHITECTURE);
|
|
254
|
+
if (phases.includes("research")) write(P.researchPath(targetDir), STARTER_RESEARCH);
|
|
239
255
|
write(P.decisionsPath(targetDir), STARTER_DECISIONS);
|
|
240
256
|
write(P.constraintsPath(targetDir), STARTER_CONSTRAINTS);
|
|
241
257
|
write(resolve(P.docsDir(targetDir), "DOMAIN.md"), STARTER_DOMAIN);
|
|
@@ -312,6 +328,20 @@ const STARTER_ARCHITECTURE = `# Architecture
|
|
|
312
328
|
Modules, what each owns, and how data moves between them.
|
|
313
329
|
`;
|
|
314
330
|
|
|
331
|
+
const STARTER_RESEARCH = `# Research
|
|
332
|
+
|
|
333
|
+
<!--
|
|
334
|
+
The RESEARCH gate wants 400+ characters of real content here, outside headings
|
|
335
|
+
and comments. Write what you found, not what you plan to do:
|
|
336
|
+
|
|
337
|
+
Prior art what already exists, what it gets right, where it stops
|
|
338
|
+
Constraints which were given, which you inferred (an inference is a question)
|
|
339
|
+
Options at least two, with what each costs and what each buys
|
|
340
|
+
Recommendation one of them, and what would have to be true for it to be wrong
|
|
341
|
+
Open questions the ones only a human can answer — these become the DEFINE interview
|
|
342
|
+
-->
|
|
343
|
+
`;
|
|
344
|
+
|
|
315
345
|
const STARTER_DECISIONS = `# Decisions
|
|
316
346
|
|
|
317
347
|
<!-- The REVIEW gate wants 100+ characters of real content here. -->
|
|
@@ -373,7 +403,18 @@ export function describeInit(result: InitResult): string {
|
|
|
373
403
|
lines.push(`Created ${result.created.length} file(s) under harness/.`);
|
|
374
404
|
if (result.kept.length) lines.push(`Left ${result.kept.length} existing file(s) alone.`);
|
|
375
405
|
lines.push("");
|
|
376
|
-
|
|
377
|
-
|
|
406
|
+
|
|
407
|
+
// The old copy said "describe what you are building" — advice from before
|
|
408
|
+
// the wizard asked for that up front. Telling a human to do a thing they
|
|
409
|
+
// have already done is how they learn to stop reading the output.
|
|
410
|
+
const goal = (result.config.intake as { brief?: unknown } | undefined)?.brief;
|
|
411
|
+
if (typeof goal === "string" && goal.trim()) {
|
|
412
|
+
lines.push(`Goal ${goal.trim()}`);
|
|
413
|
+
lines.push("");
|
|
414
|
+
lines.push(`Next: /infinity:run hands it the wheel. /infinity:next prints the brief first.`);
|
|
415
|
+
} else {
|
|
416
|
+
lines.push("Next: say what you are building — the run will ask before it does anything.");
|
|
417
|
+
lines.push(" /infinity:run hands it the wheel once there is a plan.");
|
|
418
|
+
}
|
|
378
419
|
return lines.join("\n");
|
|
379
420
|
}
|
package/src/core/paths.ts
CHANGED
|
@@ -69,10 +69,24 @@ export function decisionsPath(targetDir: string): string {
|
|
|
69
69
|
return resolve(docsDir(targetDir), "DECISIONS.md");
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
export function researchPath(targetDir: string): string {
|
|
73
|
+
return resolve(docsDir(targetDir), "RESEARCH.md");
|
|
74
|
+
}
|
|
75
|
+
|
|
72
76
|
export function constraintsPath(targetDir: string): string {
|
|
73
77
|
return resolve(docsDir(targetDir), "CONSTRAINTS.md");
|
|
74
78
|
}
|
|
75
79
|
|
|
80
|
+
/** Whether a continuous run is armed, and which run it is. Survives sessions. */
|
|
81
|
+
export function runStatePath(targetDir: string): string {
|
|
82
|
+
return resolve(harnessDir(targetDir), "run.json");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A handoff waiting to be picked up by the session that replaces this one. */
|
|
86
|
+
export function pendingSessionPath(targetDir: string): string {
|
|
87
|
+
return resolve(harnessDir(targetDir), "next-session.json");
|
|
88
|
+
}
|
|
89
|
+
|
|
76
90
|
export function skillsDir(targetDir: string): string {
|
|
77
91
|
return resolve(harnessDir(targetDir), "skills");
|
|
78
92
|
}
|