infinity-harness 2.3.1 → 2.5.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/src/core/init.ts CHANGED
@@ -29,6 +29,7 @@ import { DEFAULT_ENABLED_PHASES, PHASE_ORDER, PHASE_ROLE } from "./types.ts";
29
29
  import { defaultConfig, saveConfig } from "./config.ts";
30
30
  import { emptyFeatureList, saveFeatureList } from "./featureList.ts";
31
31
  import * as P from "./paths.ts";
32
+ import { normalizeDisplay } from "../ui/display.ts";
32
33
 
33
34
  export type StackId = "node" | "python" | "rust" | "go" | "unknown";
34
35
 
@@ -149,12 +150,21 @@ export type InitOptions = {
149
150
  commands?: Partial<ProjectCommands>;
150
151
  /** Re-scaffold missing files in a project that already has a config. */
151
152
  force?: boolean;
152
- /** Which phases stop for a human signature. See SessionPolicy / ApprovalPolicy. */
153
+ /** Legacy three-phase approval switch, kept in step with `phaseModes`. */
153
154
  approvals?: Partial<HarnessConfig["approvals"]>;
155
+ /** Mode per phase — which of them stop for a human signature. */
156
+ phaseModes?: HarnessConfig["phaseModes"];
157
+ /** Which named workflow those modes came from. */
158
+ workflow?: HarnessConfig["workflow"];
159
+ /** What the widget and the dashboard draw. */
160
+ display?: HarnessConfig["display"];
154
161
  /** Session-handoff policy. Defaults to a fresh session per phase. */
155
162
  session?: Partial<HarnessConfig["session"]>;
156
163
  /** What the human said they want built. Recorded, and read by the first brief. */
157
164
  brief?: string | null;
165
+ /** Model routing for difficulty tiers and consulting. */
166
+ router?: Partial<import("../../src/modelRouter.ts").RouterConfig>;
167
+
158
168
  };
159
169
 
160
170
  export type InitResult = {
@@ -210,6 +220,20 @@ export function initHarness(targetDir: string, options: InitOptions = {}): InitR
210
220
  config.commands = { ...stack.commands, ...stripUndefined(options.commands ?? {}) };
211
221
  config.approvals = { ...config.approvals, ...stripUndefined(options.approvals ?? {}) };
212
222
  config.session = { ...config.session, ...stripUndefined(options.session ?? {}) };
223
+ // Every enabled phase gets a mode, so a phase list and a mode map cannot
224
+ // disagree about which phases exist. A caller that still passes the 2.3
225
+ // `approvals` shape and no modes gets what it asked for rather than silently
226
+ // getting autopilot — the same rule `loadConfig` applies to an older file.
227
+ const legacy = (options.approvals ?? {}) as Record<string, unknown>;
228
+ const hasModes = options.phaseModes && Object.keys(options.phaseModes).length > 0;
229
+ config.phaseModes = Object.fromEntries(
230
+ phases.map((p) => [
231
+ p,
232
+ (hasModes ? options.phaseModes?.[p] === "copilot" : legacy[p] === true) ? "copilot" : "autopilot",
233
+ ]),
234
+ );
235
+ if (options.workflow) config.workflow = options.workflow;
236
+ if (options.display) config.display = normalizeDisplay(options.display);
213
237
  if (options.brief !== undefined) {
214
238
  config.intake = {
215
239
  completed: true,
@@ -217,6 +241,23 @@ export function initHarness(targetDir: string, options: InitOptions = {}): InitR
217
241
  at: new Date().toISOString(),
218
242
  };
219
243
  }
244
+ if (options.router) {
245
+ try {
246
+ const routerPath = P.modelRouterPath(targetDir);
247
+ mkdirSync(dirname(routerPath), { recursive: true });
248
+ let existing: Record<string, unknown> = {};
249
+ try { if (existsSync(routerPath)) existing = JSON.parse(readFileSync(routerPath, "utf-8")); } catch { /* ignore corrupt */ }
250
+ const incoming = options.router as Record<string, unknown>;
251
+ const merged: Record<string, unknown> = { ...existing, ...incoming };
252
+ if ((incoming as { byDifficulty?: unknown }).byDifficulty && typeof (incoming as { byDifficulty?: unknown }).byDifficulty === "object") {
253
+ merged.byDifficulty = { ...((existing.byDifficulty as Record<string,string>) ?? {}), ...(incoming.byDifficulty as Record<string,string>) };
254
+ }
255
+ if ((incoming as { thinkingByDifficulty?: unknown }).thinkingByDifficulty && typeof (incoming as { thinkingByDifficulty?: unknown }).thinkingByDifficulty === "object") {
256
+ merged.thinkingByDifficulty = { ...((existing.thinkingByDifficulty as Record<string,string>) ?? {}), ...(incoming.thinkingByDifficulty as Record<string,string>) };
257
+ }
258
+ writeFileSync(routerPath, JSON.stringify(merged, null, 2), "utf-8");
259
+ } catch { /* best-effort */ }
260
+ }
220
261
 
221
262
  const write = (path: string, body: string) => {
222
263
  const rel = path.slice(targetDir.length + 1);
package/src/core/paths.ts CHANGED
@@ -5,10 +5,35 @@
5
5
  * Nothing outside this module hardcodes a harness path.
6
6
  */
7
7
 
8
+ import { homedir } from "node:os";
8
9
  import { resolve } from "node:path";
9
10
 
10
11
  export const HARNESS_DIRNAME = "harness";
11
12
 
13
+ /**
14
+ * Where things that belong to the *person* live, rather than to a project.
15
+ *
16
+ * A workflow someone designed and named is worth exactly as much on their next
17
+ * project as on this one, so it cannot live under `harness/`. This follows
18
+ * pi's own config directory, honouring the same override pi honours, so a
19
+ * sandboxed or rebranded install keeps everything in one place.
20
+ */
21
+ export function userDir(env: NodeJS.ProcessEnv = process.env): string {
22
+ const override = env.PI_CODING_AGENT_DIR;
23
+ if (override && override.trim()) return resolve(override.trim(), "infinity-harness");
24
+ return resolve(homedir(), ".pi", "agent", "infinity-harness");
25
+ }
26
+
27
+ /** The workflows this person has saved, reusable across every project. */
28
+ export function userWorkflowsPath(env?: NodeJS.ProcessEnv): string {
29
+ return resolve(userDir(env), "workflows.json");
30
+ }
31
+
32
+ /** The display templates this person has saved. */
33
+ export function userDisplayPath(env?: NodeJS.ProcessEnv): string {
34
+ return resolve(userDir(env), "displays.json");
35
+ }
36
+
12
37
  export function harnessDir(targetDir: string): string {
13
38
  return resolve(targetDir, HARNESS_DIRNAME);
14
39
  }
@@ -28,7 +28,8 @@ export type SettingType =
28
28
  | { kind: "choice"; choices: readonly string[] }
29
29
  | { kind: "multi"; choices: readonly string[] }
30
30
  /** Resolved at runtime from the models pi has configured. */
31
- | { kind: "model" };
31
+ | { kind: "model" }
32
+ | { kind: "thinking" };
32
33
 
33
34
  export type Setting = {
34
35
  /** Dotted path within the file. */
@@ -49,6 +50,9 @@ export type SettingsGroup = {
49
50
 
50
51
  // ── The schema ──────────────────────────────────────────────────────────────
51
52
 
53
+ /** The two things a phase can do when its gate passes. */
54
+ const PHASE_MODE_CHOICES = ["autopilot", "copilot"] as const;
55
+
52
56
  const DIFFICULTY_HELP =
53
57
  "Tasks the planner marked at this difficulty run on this model. Empty means: use whatever model pi is already on.";
54
58
 
@@ -72,6 +76,13 @@ export const SETTINGS: SettingsGroup[] = [
72
76
  help: DIFFICULTY_HELP,
73
77
  type: { kind: "model" },
74
78
  },
79
+ {
80
+ path: "thinkingByDifficulty.easy",
81
+ file: "router",
82
+ label: "Easy thinking",
83
+ help: "Thinking level for easy tasks. Empty inherits pi's current level.",
84
+ type: { kind: "thinking" },
85
+ },
75
86
  {
76
87
  path: "byDifficulty.moderate",
77
88
  file: "router",
@@ -79,6 +90,13 @@ export const SETTINGS: SettingsGroup[] = [
79
90
  help: DIFFICULTY_HELP,
80
91
  type: { kind: "model" },
81
92
  },
93
+ {
94
+ path: "thinkingByDifficulty.moderate",
95
+ file: "router",
96
+ label: "Moderate thinking",
97
+ help: "Thinking level for moderate tasks. Empty inherits pi's current level.",
98
+ type: { kind: "thinking" },
99
+ },
82
100
  {
83
101
  path: "byDifficulty.difficult",
84
102
  file: "router",
@@ -86,6 +104,13 @@ export const SETTINGS: SettingsGroup[] = [
86
104
  help: DIFFICULTY_HELP,
87
105
  type: { kind: "model" },
88
106
  },
107
+ {
108
+ path: "thinkingByDifficulty.difficult",
109
+ file: "router",
110
+ label: "Difficult thinking",
111
+ help: "Thinking level for difficult tasks. Empty inherits pi's current level.",
112
+ type: { kind: "thinking" },
113
+ },
89
114
  {
90
115
  path: "master",
91
116
  file: "router",
@@ -93,6 +118,13 @@ export const SETTINGS: SettingsGroup[] = [
93
118
  help: "Never assigned to a task directly — reached only when the ladder is exhausted and the harness asks for one opinion.",
94
119
  type: { kind: "model" },
95
120
  },
121
+ {
122
+ path: "thinkingMaster",
123
+ file: "router",
124
+ label: "Master thinking",
125
+ help: "Thinking level for the master consultation model.",
126
+ type: { kind: "thinking" },
127
+ },
96
128
  {
97
129
  path: "default",
98
130
  file: "router",
@@ -100,6 +132,13 @@ export const SETTINGS: SettingsGroup[] = [
100
132
  help: "Used when nothing more specific matches. Empty means pi's current model.",
101
133
  type: { kind: "model" },
102
134
  },
135
+ {
136
+ path: "thinkingDefault",
137
+ file: "router",
138
+ label: "Default thinking",
139
+ help: "Fallback thinking level when no tier-specific level is set.",
140
+ type: { kind: "thinking" },
141
+ },
103
142
  {
104
143
  path: "consultation.enabled",
105
144
  file: "router",
@@ -152,31 +191,157 @@ export const SETTINGS: SettingsGroup[] = [
152
191
  ],
153
192
  },
154
193
  {
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.",
194
+ id: "workflow",
195
+ label: "Workflow",
196
+ help: "Which phases stop and wait for your signature, one phase at a time. `/infinity:workflow` picks a named one or builds a new one.",
158
197
  settings: [
159
198
  {
160
- path: "approvals.research",
199
+ path: "phaseModes.research",
161
200
  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" },
201
+ label: "RESEARCH",
202
+ help: "copilot stops so you can read harness/docs/RESEARCH.md before anything is specified.",
203
+ type: { kind: "choice", choices: PHASE_MODE_CHOICES },
165
204
  },
166
205
  {
167
- path: "approvals.define",
206
+ path: "phaseModes.define",
168
207
  file: "config",
169
- label: "Sign off DEFINE",
208
+ label: "DEFINE",
170
209
  help: "The highest-leverage signature: a wrong definition is a weekend building the wrong thing perfectly.",
210
+ type: { kind: "choice", choices: PHASE_MODE_CHOICES },
211
+ },
212
+ {
213
+ path: "phaseModes.plan",
214
+ file: "config",
215
+ label: "PLAN",
216
+ help: "copilot shows you the whole task list before a line of it is built.",
217
+ type: { kind: "choice", choices: PHASE_MODE_CHOICES },
218
+ },
219
+ {
220
+ path: "phaseModes.build",
221
+ file: "config",
222
+ label: "BUILD",
223
+ help: "copilot stops once the code passes its gate, so you can read the diff.",
224
+ type: { kind: "choice", choices: PHASE_MODE_CHOICES },
225
+ },
226
+ {
227
+ path: "phaseModes.verify",
228
+ file: "config",
229
+ label: "VERIFY",
230
+ help: "copilot asks whether the tests prove the thing works or only that it runs.",
231
+ type: { kind: "choice", choices: PHASE_MODE_CHOICES },
232
+ },
233
+ {
234
+ path: "phaseModes.simplify",
235
+ file: "config",
236
+ label: "SIMPLIFY",
237
+ help: "copilot shows you what was deleted before it moves on.",
238
+ type: { kind: "choice", choices: PHASE_MODE_CHOICES },
239
+ },
240
+ {
241
+ path: "phaseModes.review",
242
+ file: "config",
243
+ label: "REVIEW",
244
+ help: "copilot asks whether you would approve this if someone else had written it.",
245
+ type: { kind: "choice", choices: PHASE_MODE_CHOICES },
246
+ },
247
+ {
248
+ path: "phaseModes.ship",
249
+ file: "config",
250
+ label: "SHIP",
251
+ help: "copilot stops before the tag goes on. The last chance to say no.",
252
+ type: { kind: "choice", choices: PHASE_MODE_CHOICES },
253
+ },
254
+ ],
255
+ },
256
+ {
257
+ id: "display",
258
+ label: "Display",
259
+ help: "What the terminal widget and the web dashboard draw. `/infinity:display` picks a template or edits this level by level.",
260
+ settings: [
261
+ {
262
+ path: "display.levels.goal",
263
+ file: "config",
264
+ label: "Goals",
265
+ help: "The outermost level. Off on a plan with one goal costs you nothing.",
171
266
  type: { kind: "boolean" },
172
267
  },
173
268
  {
174
- path: "approvals.plan",
269
+ path: "display.levels.sprint",
175
270
  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.",
271
+ label: "Sprints",
272
+ help: "Off hides the sprint rows; the features under them still show, one level shallower.",
178
273
  type: { kind: "boolean" },
179
274
  },
275
+ {
276
+ path: "display.levels.feature",
277
+ file: "config",
278
+ label: "Features",
279
+ help: "Off hides the feature rows; their tasks still show.",
280
+ type: { kind: "boolean" },
281
+ },
282
+ {
283
+ path: "display.levels.task",
284
+ file: "config",
285
+ label: "Tasks",
286
+ help: "Off leaves the shape of the run without the work — see the `overview` template.",
287
+ type: { kind: "boolean" },
288
+ },
289
+ {
290
+ path: "display.levels.subtask",
291
+ file: "config",
292
+ label: "Subtasks",
293
+ help: "active shows them only on the task being worked, which is what fits in a widget.",
294
+ type: { kind: "choice", choices: ["none", "active", "all"] },
295
+ },
296
+ {
297
+ path: "display.counts",
298
+ file: "config",
299
+ label: "Counts",
300
+ help: "The done/total figure on goals, sprints and features.",
301
+ type: { kind: "boolean" },
302
+ },
303
+ {
304
+ path: "display.dependencies",
305
+ file: "config",
306
+ label: "Dependency labels",
307
+ help: "The `← #3` markers that say which task a task is waiting on.",
308
+ type: { kind: "boolean" },
309
+ },
310
+ {
311
+ path: "display.criteria",
312
+ file: "config",
313
+ label: "Acceptance criteria",
314
+ help: "Shown under each feature on the dashboard. No room for them in a terminal widget.",
315
+ type: { kind: "boolean" },
316
+ },
317
+ {
318
+ path: "display.rail",
319
+ file: "config",
320
+ label: "Phase rail",
321
+ help: "The `define ─ plan ─ BUILD ─ …` strip.",
322
+ type: { kind: "boolean" },
323
+ },
324
+ {
325
+ path: "display.progress",
326
+ file: "config",
327
+ label: "Progress meter",
328
+ help: "The bar and the task/feature counts beside it.",
329
+ type: { kind: "boolean" },
330
+ },
331
+ {
332
+ path: "display.alerts",
333
+ file: "config",
334
+ label: "Alert strip",
335
+ help: "Blocked and rework counts, retries, sessions, and any phase waiting for you.",
336
+ type: { kind: "boolean" },
337
+ },
338
+ {
339
+ path: "display.taskWindow",
340
+ file: "config",
341
+ label: "Widget rows",
342
+ help: "How many rows of plan the terminal shows before it starts scrolling.",
343
+ type: { kind: "number", min: 3, max: 60 },
344
+ },
180
345
  ],
181
346
  },
182
347
  {
@@ -188,8 +353,8 @@ export const SETTINGS: SettingsGroup[] = [
188
353
  path: "session.handoff",
189
354
  file: "config",
190
355
  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"] },
356
+ help: "goal: one session · phase: per phase (old) · sprint/feature: when plan grouping changes · task: every task (default) · subtask: every subtask. Coarser levels still fire.",
357
+ type: { kind: "choice", choices: ["off", "goal", "phase", "sprint", "feature", "task", "subtask"] },
193
358
  },
194
359
  {
195
360
  path: "session.contextThreshold",
@@ -409,6 +574,8 @@ export function formatValue(setting: Setting, value: unknown): string {
409
574
  return value ? "on" : "off";
410
575
  case "model":
411
576
  return typeof value === "string" && value.trim() ? value : "(pi's current model)";
577
+ case "thinking":
578
+ return typeof value === "string" && value.trim() ? value : "(inherit)";
412
579
  case "text":
413
580
  return typeof value === "string" && value.trim() ? value : "(not set)";
414
581
  case "multi":
@@ -454,6 +621,8 @@ export function parseDuration(input: string): number | null {
454
621
 
455
622
  export type ValidationResult = { ok: true; value: unknown } | { ok: false; error: string };
456
623
 
624
+ export const THINKING_CHOICES = ["(inherit)", "off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
625
+
457
626
  /** Coerce and bounds-check a raw answer for `setting`. */
458
627
  export function coerce(setting: Setting, raw: string): ValidationResult {
459
628
  const t = setting.type;
@@ -467,6 +636,13 @@ export function coerce(setting: Setting, raw: string): ValidationResult {
467
636
  if (t.max !== undefined && n > t.max) return { ok: false, error: `must be at most ${t.max}` };
468
637
  return { ok: true, value: n };
469
638
  }
639
+ case "thinking": {
640
+ const v = raw.trim();
641
+ if (!v || v === "(inherit)") return { ok: true, value: "" };
642
+ const allowed = new Set(THINKING_CHOICES.slice(1) as readonly string[]);
643
+ if (!allowed.has(v)) return { ok: false, error: `must be one of: ${THINKING_CHOICES.join(", ")}` };
644
+ return { ok: true, value: v };
645
+ }
470
646
  case "text":
471
647
  case "model": {
472
648
  const v = raw.trim();
package/src/core/types.ts CHANGED
@@ -158,14 +158,19 @@ export type RetryBucket = {
158
158
  * session boundary costs nothing but the brief, and the brief is what the
159
159
  * agent should be working from anyway.
160
160
  */
161
+ export type HandoffGranularity = "off" | "goal" | "phase" | "sprint" | "feature" | "task" | "subtask";
162
+
161
163
  export type SessionPolicy = {
162
164
  /**
163
165
  * 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
166
+ * off/goal never — one session for the whole run (the old behaviour)
167
+ * phase when the pipeline advances a phase
168
+ * sprint when the active sprint changes (or phase)
169
+ * feature when the active feature changes (or coarser)
170
+ * task when the active task changes (or coarser) — default
171
+ * subtask when the active subtask changes (or coarser)
167
172
  */
168
- handoff: "off" | "phase" | "task";
173
+ handoff: HandoffGranularity;
169
174
  /**
170
175
  * Hand off early once the context is this full, as a fraction of the
171
176
  * window. 0 disables it. This is what keeps a long BUILD phase — which may
@@ -176,13 +181,55 @@ export type SessionPolicy = {
176
181
  carryNotes: boolean;
177
182
  };
178
183
 
179
- /** Which phases stop and wait for a human signature before the run continues. */
184
+ /**
185
+ * Which phases stop and wait for a human signature before the run continues.
186
+ *
187
+ * Superseded by `HarnessConfig.phaseModes`, which says the same thing for
188
+ * *every* phase rather than only these three. Kept because configs written by
189
+ * 2.3 have it, and `loadConfig` migrates them on read.
190
+ */
180
191
  export type ApprovalPolicy = {
181
192
  research: boolean;
182
193
  define: boolean;
183
194
  plan: boolean;
184
195
  };
185
196
 
197
+ /** What happens when a phase's gate passes: stop for the human, or advance. */
198
+ export type PhaseMode = "copilot" | "autopilot";
199
+
200
+ /**
201
+ * Which parts of the plan a surface draws.
202
+ *
203
+ * Two people watching the same run want different things on screen: one works
204
+ * in sprints and never opens a subtask, the next has no sprints at all and
205
+ * lives in the subtask list. Rather than pick a winner, the levels are a
206
+ * setting, and the widget and the dashboard read the same one.
207
+ */
208
+ export type DisplayPolicy = {
209
+ /** Name of the template this came from, or "custom" once it is edited. */
210
+ preset: string;
211
+ levels: {
212
+ goal: boolean;
213
+ sprint: boolean;
214
+ feature: boolean;
215
+ task: boolean;
216
+ /** "active" shows them only on the task being worked. */
217
+ subtask: "none" | "active" | "all";
218
+ };
219
+ /** `2/5` counts on the grouping rows. */
220
+ counts: boolean;
221
+ /** `← #3` dependency labels on tasks. */
222
+ dependencies: boolean;
223
+ /** The phase rail, the progress meter and the alert strip. */
224
+ rail: boolean;
225
+ progress: boolean;
226
+ alerts: boolean;
227
+ /** Acceptance criteria under each feature. Dashboard only — no room in a widget. */
228
+ criteria: boolean;
229
+ /** Rows of plan in the terminal widget before it starts scrolling. */
230
+ taskWindow: number;
231
+ };
232
+
186
233
  /** What the start-up wizard settled, so it is never asked twice. */
187
234
  export type IntakeState = {
188
235
  /** True once the wizard has run to completion for this project. */
@@ -227,7 +274,13 @@ export type HarnessConfig = {
227
274
  phases: { enabled: Phase[] };
228
275
  roles: { strict: boolean };
229
276
  session: SessionPolicy;
277
+ /** Legacy: the three-phase approval switch 2.3 shipped. Migrated to `phaseModes`. */
230
278
  approvals: ApprovalPolicy;
279
+ /** Mode per phase — the setting `approvals` became. */
280
+ phaseModes: Partial<Record<Phase, PhaseMode>>;
281
+ /** Which named workflow the modes above came from, before any hand-editing. */
282
+ workflow: { id: string; name: string } | null;
283
+ display: DisplayPolicy;
231
284
  intake: IntakeState;
232
285
  /** Set when a gate passed but the phase needs a human signature first. */
233
286
  awaitingApproval: Phase | null;
package/src/handoff.ts CHANGED
@@ -23,12 +23,12 @@
23
23
  * that, only from a command handler, and the adapter is where pi lives.
24
24
  */
25
25
 
26
- import type { HarnessConfig, Phase, SessionPolicy } from "./core/types.ts";
26
+ import type { HarnessConfig, HandoffGranularity, Phase, SessionPolicy } from "./core/types.ts";
27
27
  import { pendingSessionPath } from "./core/paths.ts";
28
28
  import { readJsonSafe, writeJsonAtomic, removeFile, fileExists } from "./core/fsx.ts";
29
29
 
30
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";
31
+ export type HandoffReason = HandoffGranularity | "context" | "goal-pass" | "manual";
32
32
 
33
33
  export type PendingHandoff = {
34
34
  reason: HandoffReason;
@@ -51,22 +51,40 @@ export type HandoffSignals = {
51
51
  /** Composite key of the task in focus before and after. */
52
52
  fromTask: string | null;
53
53
  toTask: string | null;
54
+ /** IDs for the coarser plan levels (goal/feature/sprint/subtask). Null means "no active one". */
55
+ fromGoal?: string | null;
56
+ toGoal?: string | null;
57
+ fromSprint?: string | null;
58
+ toSprint?: string | null;
59
+ fromFeature?: string | null;
60
+ toFeature?: string | null;
61
+ fromSubtask?: string | null;
62
+ toSubtask?: string | null;
54
63
  /** Fraction of the context window in use, 0..1, or null when unknown. */
55
64
  contextRatio: number | null;
56
65
  };
57
66
 
58
67
  export type HandoffDecision = { handoff: false } | { handoff: true; reason: HandoffReason; detail: string };
59
68
 
69
+ const GRANULARITIES: readonly HandoffGranularity[] = ["off", "goal", "phase", "sprint", "feature", "task", "subtask"] as const;
70
+
71
+ export function isHandoffGranularity(v: unknown): v is HandoffGranularity {
72
+ return typeof v === "string" && (GRANULARITIES as readonly string[]).includes(v);
73
+ }
74
+
60
75
  export function defaultSessionPolicy(): SessionPolicy {
61
- return { handoff: "phase", contextThreshold: 0.7, carryNotes: true };
76
+ return { handoff: "task", contextThreshold: 0.6, carryNotes: true };
62
77
  }
63
78
 
64
79
  function policyOf(config: HarnessConfig): SessionPolicy {
65
80
  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;
81
+ const handoff: HandoffGranularity = isHandoffGranularity(p.handoff) ? p.handoff : "task";
82
+ // "goal" is an alias for the single-session behaviour; keep the storage
83
+ // as "goal" so the wizard round-trips, but treat it as "off" here.
84
+ const effective: HandoffGranularity = handoff === "goal" ? "off" : handoff;
85
+ const raw = typeof p.contextThreshold === "number" ? p.contextThreshold : 0.6;
68
86
  return {
69
- handoff,
87
+ handoff: effective,
70
88
  // A threshold of 1 or more can never fire and a negative one always would;
71
89
  // both are configuration mistakes, and clamping is kinder than either.
72
90
  contextThreshold: raw <= 0 ? 0 : Math.min(0.95, raw),
@@ -81,6 +99,14 @@ function policyOf(config: HarnessConfig): SessionPolicy {
81
99
  * arrives after compaction has already happened has arrived too late to be the
82
100
  * thing that prevented it.
83
101
  */
102
+ /** Coarsest → finest. Handoff fires at the chosen level and everything coarser. */
103
+ const LEVEL_ORDER: readonly HandoffGranularity[] = ["off", "goal", "phase", "sprint", "feature", "task", "subtask"] as const;
104
+
105
+ function rank(g: HandoffGranularity): number {
106
+ const i = (LEVEL_ORDER as readonly string[]).indexOf(g);
107
+ return i < 0 ? 5 : i;
108
+ }
109
+
84
110
  export function shouldHandoff(signals: HandoffSignals): HandoffDecision {
85
111
  const policy = policyOf(signals.config);
86
112
  if (policy.handoff === "off") return { handoff: false };
@@ -94,20 +120,62 @@ export function shouldHandoff(signals: HandoffSignals): HandoffDecision {
94
120
  };
95
121
  }
96
122
 
97
- if (signals.toPhase && signals.fromPhase !== signals.toPhase) {
123
+ const lvl = rank(policy.handoff);
124
+
125
+ // Hierarchy: off(0) < goal(1) < phase(2) < sprint(3) < feature(4) < task(5) < subtask(6).
126
+ // Finer granularity implies coarser boundaries too (task change implies feature/sprint/phase
127
+ // may have changed, but we check coarsest first so the reason reflects the highest level).
128
+ // Only boundaries at or coarser than the configured granularity? No —
129
+ // the knob is "how fine do you want to go". Choosing "task" means
130
+ // phase/feature/sprint/goal AND task boundaries fire; choosing "phase"
131
+ // means only phase (and coarser goal) fires. So a boundary fires iff
132
+ // its rank <= chosen rank. task (5) should not fire when handoff is phase (2). Hence <= lvl.
133
+ // Fine-grained choice: the wizard knob is the *coarsest* level that still
134
+ // gets a fresh session. Picking "task" means every task gets its own
135
+ // session (feature/sprint/phase do too, implicitly). So a boundary fires
136
+ // iff chosenRank >= boundaryRank.
137
+ // Phase always hands off (except off/goal) because phases are the harness
138
+ // backbone; a phase change must never ride the old session's context.
139
+ if (signals.toPhase && signals.fromPhase !== signals.toPhase && lvl >= 2) {
98
140
  return {
99
141
  handoff: true,
100
142
  reason: "phase",
101
143
  detail: `${(signals.fromPhase ?? "start").toUpperCase()} → ${signals.toPhase.toUpperCase()}`,
102
144
  };
103
145
  }
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
- };
146
+ // "goal/off" never fires here — off early-returned, "goal" was mapped to off.
147
+ // Keep for completeness if rank comparison changes; guarded by lvl so it
148
+ // doesn't resurrect. retain dead code removed check.
149
+ void lvl;
150
+ if (signals.fromSprint !== undefined || signals.toSprint !== undefined) {
151
+ const sFrom = (signals.fromSprint ?? null)?.trim() || null;
152
+ const sTo = (signals.toSprint ?? null)?.trim() || null;
153
+ if (sFrom !== sTo && (sTo || sFrom) && lvl >= 3) {
154
+ return { handoff: true, reason: "sprint" as HandoffReason, detail: `${sFrom ?? "no sprint"} → ${sTo ?? "no sprint"}` };
155
+ }
156
+ }
157
+ if (signals.fromFeature !== undefined || signals.toFeature !== undefined) {
158
+ const fFrom = (signals.fromFeature ?? null)?.trim() || null;
159
+ const fTo = (signals.toFeature ?? null)?.trim() || null;
160
+ if (fFrom !== fTo && (fTo || fFrom) && lvl >= 4) {
161
+ return { handoff: true, reason: "feature" as HandoffReason, detail: `${fFrom ?? "no feature"} → ${fTo ?? "no feature"}` };
162
+ }
163
+ }
164
+ if (lvl >= 5) {
165
+ if ((signals.fromTask ?? null) !== (signals.toTask ?? null) && (signals.fromTask || signals.toTask)) {
166
+ return {
167
+ handoff: true,
168
+ reason: "task",
169
+ detail: `${signals.fromTask ?? "no task"} → ${signals.toTask ?? "no task"}`,
170
+ };
171
+ }
172
+ }
173
+ if (lvl >= 6) {
174
+ const stFrom = (signals.fromSubtask ?? null)?.trim() || null;
175
+ const stTo = (signals.toSubtask ?? null)?.trim() || null;
176
+ if (stFrom !== stTo && (stTo || stFrom)) {
177
+ return { handoff: true, reason: "subtask" as HandoffReason, detail: `${stFrom ?? "no subtask"} → ${stTo ?? "no subtask"}` };
178
+ }
111
179
  }
112
180
 
113
181
  return { handoff: false };
@@ -168,10 +236,15 @@ export function composeKickoff(
168
236
  detail: string,
169
237
  carry: string | null,
170
238
  ): string {
171
- const why: Record<HandoffReason, string> = {
239
+ const why: Record<string, string> = {
172
240
  phase: "The pipeline advanced, so the run continues in a clean session.",
173
241
  task: "The run moved to a different task, so it continues in a clean session.",
242
+ sprint: "The active sprint changed, so the run continues in a clean session.",
243
+ feature: "The active feature changed, so the run continues in a clean session.",
244
+ goal: "The active goal changed, so the run continues in a clean session.",
245
+ subtask: "The active subtask changed, so the run continues in a clean session.",
174
246
  context: "The previous session's context was filling up, so the run continues in a clean one.",
247
+ off: "Session handoff is off.",
175
248
  "goal-pass": "A goal pass finished, so the next pass starts in a clean session.",
176
249
  manual: "A human asked for a fresh session.",
177
250
  };