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.
@@ -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.2.0",
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.",
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",
@@ -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: (list.goals ?? [])[0]?.title ?? null,
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 ?? null,
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,
@@ -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
- if (!phase) return null;
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
- 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.");
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
  }