infinity-harness 2.2.1 → 2.3.1
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 +134 -0
- package/README.md +153 -29
- package/extensions/infinity-harness/index.ts +770 -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 +1 -1
- 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/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
package/src/core/settings.ts
CHANGED
|
@@ -151,6 +151,62 @@ export const SETTINGS: SettingsGroup[] = [
|
|
|
151
151
|
},
|
|
152
152
|
],
|
|
153
153
|
},
|
|
154
|
+
{
|
|
155
|
+
id: "approvals",
|
|
156
|
+
label: "Your approvals",
|
|
157
|
+
help: "Which phases stop and wait for your signature. These are the three that decide WHAT gets built — after PLAN, a wrong turn fails a gate and retries.",
|
|
158
|
+
settings: [
|
|
159
|
+
{
|
|
160
|
+
path: "approvals.research",
|
|
161
|
+
file: "config",
|
|
162
|
+
label: "Sign off RESEARCH",
|
|
163
|
+
help: "You read harness/docs/RESEARCH.md and say whether it is looking at the right problem. Only applies when the RESEARCH phase is enabled.",
|
|
164
|
+
type: { kind: "boolean" },
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
path: "approvals.define",
|
|
168
|
+
file: "config",
|
|
169
|
+
label: "Sign off DEFINE",
|
|
170
|
+
help: "The highest-leverage signature: a wrong definition is a weekend building the wrong thing perfectly.",
|
|
171
|
+
type: { kind: "boolean" },
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
path: "approvals.plan",
|
|
175
|
+
file: "config",
|
|
176
|
+
label: "Sign off PLAN",
|
|
177
|
+
help: "You see the whole task list before a line of it is built, and can send it back with a note.",
|
|
178
|
+
type: { kind: "boolean" },
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
id: "sessions",
|
|
184
|
+
label: "Sessions",
|
|
185
|
+
help: "How the run divides itself into pi sessions. A run that never starts a fresh one carries its whole history into every request.",
|
|
186
|
+
settings: [
|
|
187
|
+
{
|
|
188
|
+
path: "session.handoff",
|
|
189
|
+
file: "config",
|
|
190
|
+
label: "Fresh session",
|
|
191
|
+
help: "phase: each phase starts clean · task: cleanest context, best with small models · off: one session for the whole run.",
|
|
192
|
+
type: { kind: "choice", choices: ["off", "phase", "task"] },
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
path: "session.contextThreshold",
|
|
196
|
+
file: "config",
|
|
197
|
+
label: "Context handoff threshold",
|
|
198
|
+
help: "Hand off early once the context is this full, as a fraction of the window. 0 disables it. 0.7 keeps a long BUILD phase out of compaction.",
|
|
199
|
+
type: { kind: "number", min: 0, max: 0.95 },
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
path: "session.carryNotes",
|
|
203
|
+
file: "config",
|
|
204
|
+
label: "Carry a note across",
|
|
205
|
+
help: "Tell the replacement session, in one line, what the previous one got done.",
|
|
206
|
+
type: { kind: "boolean" },
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
},
|
|
154
210
|
{
|
|
155
211
|
id: "commands",
|
|
156
212
|
label: "Project commands",
|
package/src/core/types.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
export const PHASE_ORDER = [
|
|
11
11
|
"init",
|
|
12
|
+
"research",
|
|
12
13
|
"define",
|
|
13
14
|
"plan",
|
|
14
15
|
"build",
|
|
@@ -20,7 +21,7 @@ export const PHASE_ORDER = [
|
|
|
20
21
|
|
|
21
22
|
export type Phase = (typeof PHASE_ORDER)[number];
|
|
22
23
|
|
|
23
|
-
/** Phases enabled by default — SIMPLIFY
|
|
24
|
+
/** Phases enabled by default — RESEARCH and SIMPLIFY are opt-in. */
|
|
24
25
|
export const DEFAULT_ENABLED_PHASES: Phase[] = [
|
|
25
26
|
"define",
|
|
26
27
|
"plan",
|
|
@@ -30,11 +31,23 @@ export const DEFAULT_ENABLED_PHASES: Phase[] = [
|
|
|
30
31
|
"ship",
|
|
31
32
|
];
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Phases whose output a human may want to sign off before the run continues.
|
|
36
|
+
*
|
|
37
|
+
* These are the three that decide *what gets built*. Everything after them is
|
|
38
|
+
* execution: if the definition and the plan are right, an autonomous run that
|
|
39
|
+
* gets BUILD wrong fails a gate and retries, but an autonomous run that got
|
|
40
|
+
* DEFINE wrong spends a weekend building the wrong product perfectly.
|
|
41
|
+
*/
|
|
42
|
+
export const APPROVABLE_PHASES = ["research", "define", "plan"] as const;
|
|
43
|
+
export type ApprovablePhase = (typeof APPROVABLE_PHASES)[number];
|
|
44
|
+
|
|
45
|
+
export type Role = "researcher" | "planner" | "generator" | "evaluator" | "simplifier";
|
|
34
46
|
|
|
35
47
|
/** Which role owns each phase. Drives the "put on this hat" brief line. */
|
|
36
48
|
export const PHASE_ROLE: Record<Phase, Role> = {
|
|
37
49
|
init: "planner",
|
|
50
|
+
research: "researcher",
|
|
38
51
|
define: "planner",
|
|
39
52
|
plan: "planner",
|
|
40
53
|
build: "generator",
|
|
@@ -135,6 +148,51 @@ export type RetryBucket = {
|
|
|
135
148
|
maxRetries: number | null;
|
|
136
149
|
};
|
|
137
150
|
|
|
151
|
+
/**
|
|
152
|
+
* How the run divides itself into pi sessions.
|
|
153
|
+
*
|
|
154
|
+
* A harness that never starts a new session is a harness whose context window
|
|
155
|
+
* only ever grows: by the tenth task the model is reading the whole history of
|
|
156
|
+
* the first nine to do the tenth, paying for it, and — on a small model —
|
|
157
|
+
* drowning in it. The plan, the phase and the gate all live on disk, so a
|
|
158
|
+
* session boundary costs nothing but the brief, and the brief is what the
|
|
159
|
+
* agent should be working from anyway.
|
|
160
|
+
*/
|
|
161
|
+
export type SessionPolicy = {
|
|
162
|
+
/**
|
|
163
|
+
* When to hand off to a fresh session.
|
|
164
|
+
* off never — one session for the whole run (the old behaviour)
|
|
165
|
+
* phase when the pipeline advances a phase
|
|
166
|
+
* task when the pipeline advances a phase or moves to a different task
|
|
167
|
+
*/
|
|
168
|
+
handoff: "off" | "phase" | "task";
|
|
169
|
+
/**
|
|
170
|
+
* Hand off early once the context is this full, as a fraction of the
|
|
171
|
+
* window. 0 disables it. This is what keeps a long BUILD phase — which may
|
|
172
|
+
* never advance for hours — from riding a single session into compaction.
|
|
173
|
+
*/
|
|
174
|
+
contextThreshold: number;
|
|
175
|
+
/** Carry a short "what the last session did" note into the new session. */
|
|
176
|
+
carryNotes: boolean;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/** Which phases stop and wait for a human signature before the run continues. */
|
|
180
|
+
export type ApprovalPolicy = {
|
|
181
|
+
research: boolean;
|
|
182
|
+
define: boolean;
|
|
183
|
+
plan: boolean;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/** What the start-up wizard settled, so it is never asked twice. */
|
|
187
|
+
export type IntakeState = {
|
|
188
|
+
/** True once the wizard has run to completion for this project. */
|
|
189
|
+
completed: boolean;
|
|
190
|
+
/** What the human said they want built, in their words. */
|
|
191
|
+
brief: string | null;
|
|
192
|
+
/** ISO timestamp of the wizard run. */
|
|
193
|
+
at: string | null;
|
|
194
|
+
};
|
|
195
|
+
|
|
138
196
|
export type HarnessConfig = {
|
|
139
197
|
version: string;
|
|
140
198
|
stack: string | null;
|
|
@@ -168,6 +226,11 @@ export type HarnessConfig = {
|
|
|
168
226
|
};
|
|
169
227
|
phases: { enabled: Phase[] };
|
|
170
228
|
roles: { strict: boolean };
|
|
229
|
+
session: SessionPolicy;
|
|
230
|
+
approvals: ApprovalPolicy;
|
|
231
|
+
intake: IntakeState;
|
|
232
|
+
/** Set when a gate passed but the phase needs a human signature first. */
|
|
233
|
+
awaitingApproval: Phase | null;
|
|
171
234
|
/** Budgets that bound an unattended continuous run. See src/loop.ts. */
|
|
172
235
|
loop: {
|
|
173
236
|
maxIterations: number;
|
package/src/handoff.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infinity-harness — session handoff.
|
|
3
|
+
*
|
|
4
|
+
* A harness that never starts a new pi session is a harness whose context
|
|
5
|
+
* window only ever grows. By the tenth task the model is re-reading the whole
|
|
6
|
+
* history of the first nine in order to do the tenth: it pays for those tokens
|
|
7
|
+
* on every call, it compacts them into a lossy summary once the window fills,
|
|
8
|
+
* and on a small model it simply drowns. That is the failure mode this module
|
|
9
|
+
* exists to remove.
|
|
10
|
+
*
|
|
11
|
+
* The trick is that a handoff costs almost nothing here, because the harness
|
|
12
|
+
* never kept its state in the conversation to begin with. The plan, the phase,
|
|
13
|
+
* the gate history, the retry budgets and the escalation ladder are all files.
|
|
14
|
+
* A new session needs one thing to carry on: the brief — which is what the
|
|
15
|
+
* agent should have been working from anyway.
|
|
16
|
+
*
|
|
17
|
+
* What this module owns:
|
|
18
|
+
* - deciding *whether* a handoff is due (`shouldHandoff`)
|
|
19
|
+
* - writing down what the next session must be told (`requestHandoff`)
|
|
20
|
+
* - handing that to whoever starts it, exactly once (`takeHandoff`)
|
|
21
|
+
*
|
|
22
|
+
* What it deliberately does not own: starting the session. Only pi can do
|
|
23
|
+
* that, only from a command handler, and the adapter is where pi lives.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { HarnessConfig, Phase, SessionPolicy } from "./core/types.ts";
|
|
27
|
+
import { pendingSessionPath } from "./core/paths.ts";
|
|
28
|
+
import { readJsonSafe, writeJsonAtomic, removeFile, fileExists } from "./core/fsx.ts";
|
|
29
|
+
|
|
30
|
+
/** Why a session is being replaced. Shown to the human and to the next agent. */
|
|
31
|
+
export type HandoffReason = "phase" | "task" | "context" | "goal-pass" | "manual";
|
|
32
|
+
|
|
33
|
+
export type PendingHandoff = {
|
|
34
|
+
reason: HandoffReason;
|
|
35
|
+
/** Human-readable one-liner: "BUILD → VERIFY", "context 78% full". */
|
|
36
|
+
detail: string;
|
|
37
|
+
/** The message the replacement session is started with. Usually the brief. */
|
|
38
|
+
kickoff: string;
|
|
39
|
+
/** A short note on what the session being replaced actually did. */
|
|
40
|
+
carry: string | null;
|
|
41
|
+
/** The run this handoff belongs to; a stale file from an old run is ignored. */
|
|
42
|
+
runId: string;
|
|
43
|
+
at: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type HandoffSignals = {
|
|
47
|
+
config: HarnessConfig;
|
|
48
|
+
/** Phase before the loop's decision, and after it. */
|
|
49
|
+
fromPhase: Phase | null;
|
|
50
|
+
toPhase: Phase | null;
|
|
51
|
+
/** Composite key of the task in focus before and after. */
|
|
52
|
+
fromTask: string | null;
|
|
53
|
+
toTask: string | null;
|
|
54
|
+
/** Fraction of the context window in use, 0..1, or null when unknown. */
|
|
55
|
+
contextRatio: number | null;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type HandoffDecision = { handoff: false } | { handoff: true; reason: HandoffReason; detail: string };
|
|
59
|
+
|
|
60
|
+
export function defaultSessionPolicy(): SessionPolicy {
|
|
61
|
+
return { handoff: "phase", contextThreshold: 0.7, carryNotes: true };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function policyOf(config: HarnessConfig): SessionPolicy {
|
|
65
|
+
const p = (config.session ?? {}) as Partial<SessionPolicy>;
|
|
66
|
+
const handoff = p.handoff === "off" || p.handoff === "task" || p.handoff === "phase" ? p.handoff : "phase";
|
|
67
|
+
const raw = typeof p.contextThreshold === "number" ? p.contextThreshold : 0.7;
|
|
68
|
+
return {
|
|
69
|
+
handoff,
|
|
70
|
+
// A threshold of 1 or more can never fire and a negative one always would;
|
|
71
|
+
// both are configuration mistakes, and clamping is kinder than either.
|
|
72
|
+
contextThreshold: raw <= 0 ? 0 : Math.min(0.95, raw),
|
|
73
|
+
carryNotes: p.carryNotes !== false,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Should the run continue in a fresh session?
|
|
79
|
+
*
|
|
80
|
+
* Order matters. Context pressure wins over everything, because a handoff that
|
|
81
|
+
* arrives after compaction has already happened has arrived too late to be the
|
|
82
|
+
* thing that prevented it.
|
|
83
|
+
*/
|
|
84
|
+
export function shouldHandoff(signals: HandoffSignals): HandoffDecision {
|
|
85
|
+
const policy = policyOf(signals.config);
|
|
86
|
+
if (policy.handoff === "off") return { handoff: false };
|
|
87
|
+
|
|
88
|
+
const ratio = signals.contextRatio;
|
|
89
|
+
if (policy.contextThreshold > 0 && typeof ratio === "number" && ratio >= policy.contextThreshold) {
|
|
90
|
+
return {
|
|
91
|
+
handoff: true,
|
|
92
|
+
reason: "context",
|
|
93
|
+
detail: `context ${Math.round(ratio * 100)}% full (threshold ${Math.round(policy.contextThreshold * 100)}%)`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (signals.toPhase && signals.fromPhase !== signals.toPhase) {
|
|
98
|
+
return {
|
|
99
|
+
handoff: true,
|
|
100
|
+
reason: "phase",
|
|
101
|
+
detail: `${(signals.fromPhase ?? "start").toUpperCase()} → ${signals.toPhase.toUpperCase()}`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (policy.handoff === "task" && signals.toTask && signals.fromTask !== signals.toTask) {
|
|
106
|
+
return {
|
|
107
|
+
handoff: true,
|
|
108
|
+
reason: "task",
|
|
109
|
+
detail: `${signals.fromTask ?? "no task"} → ${signals.toTask}`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { handoff: false };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Record what the replacement session must be told.
|
|
118
|
+
*
|
|
119
|
+
* Written to disk rather than held in memory because the session that writes
|
|
120
|
+
* it is, by definition, about to stop existing.
|
|
121
|
+
*/
|
|
122
|
+
export function requestHandoff(
|
|
123
|
+
targetDir: string,
|
|
124
|
+
handoff: Omit<PendingHandoff, "at">,
|
|
125
|
+
): PendingHandoff {
|
|
126
|
+
const pending: PendingHandoff = { ...handoff, at: new Date().toISOString() };
|
|
127
|
+
writeJsonAtomic(pendingSessionPath(targetDir), pending);
|
|
128
|
+
return pending;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function peekHandoff(targetDir: string): PendingHandoff | null {
|
|
132
|
+
const raw = readJsonSafe<PendingHandoff | null>(pendingSessionPath(targetDir), null);
|
|
133
|
+
if (!raw || typeof raw.kickoff !== "string" || !raw.kickoff.trim()) return null;
|
|
134
|
+
return raw;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Claim the pending handoff, clearing it.
|
|
139
|
+
*
|
|
140
|
+
* Clearing before returning is deliberate: if the replacement session dies
|
|
141
|
+
* between claiming and sending, the run stalls where a human can see it,
|
|
142
|
+
* rather than looping through handoffs forever on a stale file.
|
|
143
|
+
*/
|
|
144
|
+
export function takeHandoff(targetDir: string): PendingHandoff | null {
|
|
145
|
+
const pending = peekHandoff(targetDir);
|
|
146
|
+
clearHandoff(targetDir);
|
|
147
|
+
return pending;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function clearHandoff(targetDir: string): void {
|
|
151
|
+
removeFile(pendingSessionPath(targetDir));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function hasPendingHandoff(targetDir: string): boolean {
|
|
155
|
+
return fileExists(pendingSessionPath(targetDir));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The message the replacement session opens with.
|
|
160
|
+
*
|
|
161
|
+
* It is the brief, plus one paragraph explaining why the previous session
|
|
162
|
+
* ended — because an agent that wakes up mid-run with no explanation tends to
|
|
163
|
+
* spend its first turn investigating the harness instead of doing the work.
|
|
164
|
+
*/
|
|
165
|
+
export function composeKickoff(
|
|
166
|
+
brief: string,
|
|
167
|
+
reason: HandoffReason,
|
|
168
|
+
detail: string,
|
|
169
|
+
carry: string | null,
|
|
170
|
+
): string {
|
|
171
|
+
const why: Record<HandoffReason, string> = {
|
|
172
|
+
phase: "The pipeline advanced, so the run continues in a clean session.",
|
|
173
|
+
task: "The run moved to a different task, so it continues in a clean session.",
|
|
174
|
+
context: "The previous session's context was filling up, so the run continues in a clean one.",
|
|
175
|
+
"goal-pass": "A goal pass finished, so the next pass starts in a clean session.",
|
|
176
|
+
manual: "A human asked for a fresh session.",
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const lines = [
|
|
180
|
+
`[infinity-harness] Continuing a run in a fresh session — ${detail}.`,
|
|
181
|
+
why[reason],
|
|
182
|
+
"",
|
|
183
|
+
"Nothing is lost: the plan, the phase, the gate history and every retry budget",
|
|
184
|
+
"live in `harness/` and are already loaded. Work from the brief below. Do not",
|
|
185
|
+
"go looking for the previous conversation.",
|
|
186
|
+
];
|
|
187
|
+
if (carry && carry.trim()) {
|
|
188
|
+
lines.push("", "What the previous session did:", carry.trim());
|
|
189
|
+
}
|
|
190
|
+
lines.push("", brief);
|
|
191
|
+
return lines.join("\n");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** One-line summary of a handoff, for a notification or the run journal. */
|
|
195
|
+
export function describeHandoff(h: PendingHandoff): string {
|
|
196
|
+
return `new session — ${h.reason}: ${h.detail}`;
|
|
197
|
+
}
|
package/src/intake.ts
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infinity-harness — the start-up wizard.
|
|
3
|
+
*
|
|
4
|
+
* The bug this module exists to kill: choosing "autopilot" used to start the
|
|
5
|
+
* run *immediately*, with no idea and no scope, so the first thing the harness
|
|
6
|
+
* did was invent a project and start building it. Autopilot was being read as
|
|
7
|
+
* "you decide everything, including what I want", which is not a mode anybody
|
|
8
|
+
* asked for.
|
|
9
|
+
*
|
|
10
|
+
* The fix is to separate two questions that were tangled together:
|
|
11
|
+
*
|
|
12
|
+
* 1. What are we building? — always asked, in both modes
|
|
13
|
+
* 2. Who signs off on what? — the mode's actual meaning
|
|
14
|
+
*
|
|
15
|
+
* copilot the human is in the loop. DEFINE and PLAN are theirs to approve
|
|
16
|
+
* (and RESEARCH too, when it is on). Not negotiable — that is what
|
|
17
|
+
* the word means.
|
|
18
|
+
* autopilot the human is *optionally* in the loop. They pick which of
|
|
19
|
+
* RESEARCH / DEFINE / PLAN they want to sign and which they hand
|
|
20
|
+
* to the model. Forfeiting all three is the "give it a goal and
|
|
21
|
+
* walk away" mode; keeping DEFINE is the common middle.
|
|
22
|
+
*
|
|
23
|
+
* RESEARCH is a separate, optional phase that runs before DEFINE: the human
|
|
24
|
+
* gives an idea, the model goes and finds out what it actually has to be.
|
|
25
|
+
*
|
|
26
|
+
* Nothing here talks to pi. It takes answers and returns a plan of record, so
|
|
27
|
+
* the flow can be unit-tested without a terminal and driven from the adapter,
|
|
28
|
+
* from a test, or from a config file.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { ApprovalPolicy, Phase, SessionPolicy } from "./core/types.ts";
|
|
32
|
+
import { DEFAULT_ENABLED_PHASES, PHASE_ORDER } from "./core/types.ts";
|
|
33
|
+
|
|
34
|
+
export type Mode = "copilot" | "autopilot";
|
|
35
|
+
|
|
36
|
+
/** The wizard's questions, in the order they are asked. */
|
|
37
|
+
export const INTAKE_STEPS = ["mode", "brief", "research", "approvals", "handoff"] as const;
|
|
38
|
+
export type IntakeStep = (typeof INTAKE_STEPS)[number];
|
|
39
|
+
|
|
40
|
+
export type IntakeAnswers = {
|
|
41
|
+
mode: Mode;
|
|
42
|
+
/** What the human wants built, in their words. Empty is allowed but warned about. */
|
|
43
|
+
brief: string;
|
|
44
|
+
/** Run the optional RESEARCH phase before DEFINE. */
|
|
45
|
+
research: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Which phases the human wants to sign, for autopilot only.
|
|
48
|
+
* Ignored in copilot, where all three are always signed.
|
|
49
|
+
*/
|
|
50
|
+
approvals?: Partial<ApprovalPolicy>;
|
|
51
|
+
/** Session handoff policy. Defaults to a fresh session per phase. */
|
|
52
|
+
handoff?: SessionPolicy["handoff"];
|
|
53
|
+
/** Phases the human explicitly chose. Overrides the research toggle. */
|
|
54
|
+
phases?: Phase[];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type IntakePlan = {
|
|
58
|
+
mode: Mode;
|
|
59
|
+
brief: string | null;
|
|
60
|
+
phases: Phase[];
|
|
61
|
+
approvals: ApprovalPolicy;
|
|
62
|
+
session: SessionPolicy;
|
|
63
|
+
/** What the human should be told about what they just chose. */
|
|
64
|
+
summary: string;
|
|
65
|
+
/** Things that will bite later if left as they are. */
|
|
66
|
+
warnings: string[];
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* In copilot the human is in the loop by definition, so every approvable
|
|
71
|
+
* phase that is enabled is theirs. Making this configurable would make
|
|
72
|
+
* "copilot" mean nothing.
|
|
73
|
+
*/
|
|
74
|
+
export function copilotApprovals(phases: Phase[]): ApprovalPolicy {
|
|
75
|
+
return {
|
|
76
|
+
research: phases.includes("research"),
|
|
77
|
+
define: true,
|
|
78
|
+
plan: true,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizePhases(base: Phase[], research: boolean): Phase[] {
|
|
83
|
+
const wanted = new Set<Phase>(base.filter((p) => (PHASE_ORDER as readonly string[]).includes(p)));
|
|
84
|
+
wanted.delete("init");
|
|
85
|
+
if (research) wanted.add("research");
|
|
86
|
+
else wanted.delete("research");
|
|
87
|
+
const ordered = PHASE_ORDER.filter((p) => wanted.has(p));
|
|
88
|
+
return ordered.length ? [...ordered] : [...DEFAULT_ENABLED_PHASES];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Turn the wizard's answers into everything `initHarness` needs. */
|
|
92
|
+
export function planIntake(answers: IntakeAnswers): IntakePlan {
|
|
93
|
+
const research = answers.research === true;
|
|
94
|
+
const phases = normalizePhases(answers.phases ?? [...DEFAULT_ENABLED_PHASES], research);
|
|
95
|
+
|
|
96
|
+
const approvals: ApprovalPolicy =
|
|
97
|
+
answers.mode === "copilot"
|
|
98
|
+
? copilotApprovals(phases)
|
|
99
|
+
: {
|
|
100
|
+
research: phases.includes("research") && answers.approvals?.research === true,
|
|
101
|
+
define: answers.approvals?.define === true,
|
|
102
|
+
plan: answers.approvals?.plan === true,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const handoff = answers.handoff ?? "phase";
|
|
106
|
+
const session: SessionPolicy = {
|
|
107
|
+
handoff,
|
|
108
|
+
contextThreshold: handoff === "off" ? 0 : 0.7,
|
|
109
|
+
carryNotes: true,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const brief = answers.brief?.trim() ? answers.brief.trim() : null;
|
|
113
|
+
|
|
114
|
+
const warnings: string[] = [];
|
|
115
|
+
if (!brief) {
|
|
116
|
+
warnings.push(
|
|
117
|
+
"No goal was given, so the first thing the run does is ask you for one. " +
|
|
118
|
+
"It will not guess a project.",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
if (answers.mode === "autopilot" && !approvals.define && !approvals.plan && !approvals.research) {
|
|
122
|
+
warnings.push(
|
|
123
|
+
"Nothing is being approved by you. The model decides what to build and how, " +
|
|
124
|
+
"and you see it when it is done. This is the right setting for a run you " +
|
|
125
|
+
"want to walk away from — and the wrong one if the goal is vague.",
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (handoff === "off") {
|
|
129
|
+
warnings.push(
|
|
130
|
+
"Session handoff is off, so the whole run shares one context window. " +
|
|
131
|
+
"Expect compaction on anything longer than a few tasks.",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { mode: answers.mode, brief, phases, approvals, session, summary: summarize(answers.mode, phases, approvals, session, brief), warnings };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function summarize(
|
|
139
|
+
mode: Mode,
|
|
140
|
+
phases: Phase[],
|
|
141
|
+
approvals: ApprovalPolicy,
|
|
142
|
+
session: SessionPolicy,
|
|
143
|
+
brief: string | null,
|
|
144
|
+
): string {
|
|
145
|
+
const signed = (["research", "define", "plan"] as const).filter((p) => approvals[p]);
|
|
146
|
+
const L: string[] = [];
|
|
147
|
+
L.push(`Mode ${mode}`);
|
|
148
|
+
L.push(`Pipeline ${phases.join(" → ")}`);
|
|
149
|
+
L.push(
|
|
150
|
+
`You sign ${signed.length ? signed.map((s) => s.toUpperCase()).join(", ") : "nothing — the model decides and runs"}`,
|
|
151
|
+
);
|
|
152
|
+
L.push(
|
|
153
|
+
`Sessions ${
|
|
154
|
+
session.handoff === "off"
|
|
155
|
+
? "one session for the whole run"
|
|
156
|
+
: session.handoff === "task"
|
|
157
|
+
? "fresh session per task and per phase"
|
|
158
|
+
: "fresh session per phase"
|
|
159
|
+
}`,
|
|
160
|
+
);
|
|
161
|
+
L.push(`Goal ${brief ?? "(none yet — you will be asked first thing)"}`);
|
|
162
|
+
return L.join("\n");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── The questions, as data ──────────────────────────────────────────────────
|
|
166
|
+
//
|
|
167
|
+
// Declared here rather than written inline in the adapter so the wizard has
|
|
168
|
+
// one wording, the tests read the same strings the human does, and a
|
|
169
|
+
// non-interactive caller can answer them without a terminal.
|
|
170
|
+
|
|
171
|
+
export type Question = {
|
|
172
|
+
id: IntakeStep;
|
|
173
|
+
title: string;
|
|
174
|
+
/** For choice questions. */
|
|
175
|
+
options?: { value: string; label: string; help: string }[];
|
|
176
|
+
/** For free-text questions. */
|
|
177
|
+
placeholder?: string;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
export const MODE_QUESTION: Question = {
|
|
181
|
+
id: "mode",
|
|
182
|
+
title: "How much do you want to be involved?",
|
|
183
|
+
options: [
|
|
184
|
+
{
|
|
185
|
+
value: "copilot",
|
|
186
|
+
label: "copilot — I approve the definition and the plan",
|
|
187
|
+
help: "The run stops and shows you its work before it starts building. You can send any phase back with a note.",
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
value: "autopilot",
|
|
191
|
+
label: "autopilot — I choose what to approve, if anything",
|
|
192
|
+
help: "You pick which of research, definition and plan you sign. Approve none of them and the run is yours to walk away from.",
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export const BRIEF_QUESTION: Question = {
|
|
198
|
+
id: "brief",
|
|
199
|
+
title: "What are you building? One or two sentences is enough.",
|
|
200
|
+
placeholder: "e.g. a CLI that reconciles Stripe payouts against our ledger",
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
export const RESEARCH_QUESTION: Question = {
|
|
204
|
+
id: "research",
|
|
205
|
+
title: "Research the idea first?",
|
|
206
|
+
options: [
|
|
207
|
+
{
|
|
208
|
+
value: "no",
|
|
209
|
+
label: "no — go straight to defining it",
|
|
210
|
+
help: "Right when you already know what has to be built.",
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
value: "yes",
|
|
214
|
+
label: "yes — find out what it has to be first",
|
|
215
|
+
help: "Adds a RESEARCH phase before DEFINE: prior art, constraints, options with costs, a recommendation, and the questions only you can answer.",
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
export const HANDOFF_QUESTION: Question = {
|
|
221
|
+
id: "handoff",
|
|
222
|
+
title: "When should the run start a fresh session?",
|
|
223
|
+
options: [
|
|
224
|
+
{
|
|
225
|
+
value: "phase",
|
|
226
|
+
label: "every phase (recommended)",
|
|
227
|
+
help: "Each phase starts clean, working from the brief. Keeps the context small on long runs.",
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
value: "task",
|
|
231
|
+
label: "every task",
|
|
232
|
+
help: "The cleanest context per unit of work. Best with small models; costs one extra brief per task.",
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
value: "off",
|
|
236
|
+
label: "never — one long session",
|
|
237
|
+
help: "The old behaviour. The context grows for the whole run and compaction takes over.",
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/** The approval checklist, offered only in autopilot. */
|
|
243
|
+
export function approvalOptions(research: boolean): { value: keyof ApprovalPolicy; label: string; help: string }[] {
|
|
244
|
+
const out: { value: keyof ApprovalPolicy; label: string; help: string }[] = [];
|
|
245
|
+
if (research) {
|
|
246
|
+
out.push({
|
|
247
|
+
value: "research",
|
|
248
|
+
label: "RESEARCH — what it found before anything is specified",
|
|
249
|
+
help: "You read harness/docs/RESEARCH.md and say whether it is looking at the right problem.",
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
out.push({
|
|
253
|
+
value: "define",
|
|
254
|
+
label: "DEFINE — the scope and the acceptance criteria",
|
|
255
|
+
help: "The single highest-leverage signature: a wrong definition is a weekend building the wrong thing perfectly.",
|
|
256
|
+
});
|
|
257
|
+
out.push({
|
|
258
|
+
value: "plan",
|
|
259
|
+
label: "PLAN — the task list before any code is written",
|
|
260
|
+
help: "You see the whole decomposition and can send it back before it is built.",
|
|
261
|
+
});
|
|
262
|
+
return out;
|
|
263
|
+
}
|