pi-legion 4.0.0 → 5.0.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +168 -1
  2. package/README.md +101 -44
  3. package/agents/centurion.md +20 -16
  4. package/agents/scout.md +3 -1
  5. package/agents/worker.md +5 -7
  6. package/docs/contracts/declarative-phase-machine-v5.md +128 -0
  7. package/docs/contracts/subagent-renewal-v1.md +260 -0
  8. package/docs/headless-child-contract.md +6 -3
  9. package/docs/inprocess-supervisor.md +70 -24
  10. package/docs/legion-v2-contract.md +9 -0
  11. package/docs/spawn-mode-visibility-v1.md +4 -1
  12. package/index.ts +598 -44
  13. package/package.json +2 -2
  14. package/src/agents/agents.ts +293 -36
  15. package/src/agents/overrides.ts +3 -1
  16. package/src/agents/settings.ts +6 -18
  17. package/src/extension/child-guard.ts +25 -1
  18. package/src/extension/legion-tools.ts +66 -21
  19. package/src/extension/notify.ts +136 -4
  20. package/src/extension/playbook.ts +2 -2
  21. package/src/extension/runtime.ts +56 -1
  22. package/src/extension/summary.ts +13 -4
  23. package/src/extension/timing.ts +8 -4
  24. package/src/legion/phase-conditions.ts +164 -0
  25. package/src/legion/phase-machine.ts +403 -0
  26. package/src/legion/state-machine.ts +219 -115
  27. package/src/runs/args.ts +32 -18
  28. package/src/runs/extension-inheritance.ts +220 -0
  29. package/src/runs/orchestrator.ts +137 -44
  30. package/src/runs/progress-signal.ts +137 -0
  31. package/src/runs/store-lookup.ts +270 -0
  32. package/src/shared/types.ts +88 -3
  33. package/src/supervisor/child-context.ts +19 -0
  34. package/src/supervisor/inprocess-supervisor.ts +29 -5
  35. package/src/supervisor/routing-supervisor.ts +2 -1
  36. package/src/supervisor/rpc-supervisor.ts +2 -2
  37. package/src/tui/tree-view.ts +56 -0
  38. package/src/tui/viz/palette.ts +0 -3
  39. package/agents/advisor.md +0 -110
  40. package/agents/designer.md +0 -87
  41. package/agents/planner.md +0 -53
  42. package/agents/prototype.md +0 -72
  43. package/agents/reviewer.md +0 -113
@@ -1,3 +1,20 @@
1
+ import {
2
+ type PhaseMachine,
3
+ DEFAULT_PHASE_MACHINE,
4
+ DEFAULT_MAX_REWORK_COUNT,
5
+ findPhase,
6
+ normalizePhaseMachine,
7
+ isTerminalIn,
8
+ legacyReasonForCondition,
9
+ } from "./phase-machine.ts";
10
+ import {
11
+ checkPhaseCondition,
12
+ checkPhaseRequirements,
13
+ describeFailedCondition,
14
+ type PhaseCondition,
15
+ type PhaseConditionInput,
16
+ } from "./phase-conditions.ts";
17
+
1
18
  export const LEGION_PHASES = [
2
19
  "planning",
3
20
  "implementing",
@@ -39,7 +56,11 @@ export type PhaseTransitionReason =
39
56
  | "centurion_required"
40
57
  | "children_not_terminal"
41
58
  | "terminal_reason_required"
42
- | "all_children_not_failed";
59
+ | "all_children_not_failed"
60
+ // #298: a declared machine can refuse for reasons the built-in table had no
61
+ // name for — an unmet custom condition, or an edge reserved to another actor.
62
+ | "condition_not_met"
63
+ | "actor_not_permitted";
43
64
 
44
65
  export interface PhaseChild {
45
66
  id: string;
@@ -48,8 +69,9 @@ export interface PhaseChild {
48
69
  }
49
70
 
50
71
  export interface EvaluatePhaseTransitionInput {
51
- from: LegionPhase | null;
52
- to: LegionPhase;
72
+ /** Phase id. Free-form when a declared machine is supplied (#298). */
73
+ from: string | null;
74
+ to: string;
53
75
  reworkCount?: number;
54
76
  contractPath?: string | null;
55
77
  children?: readonly PhaseChild[];
@@ -58,13 +80,21 @@ export interface EvaluatePhaseTransitionInput {
58
80
  verificationPassed?: boolean;
59
81
  actorRole?: string;
60
82
  terminalReason?: "parent_decision" | "all_children_failed" | "rework_exhausted" | "parent_retire";
83
+ /**
84
+ * #298: a declared machine. When present, transitions are evaluated from it
85
+ * instead of the built-in table, so a team can define its own workflow.
86
+ * Absent means DEFAULT_PHASE_MACHINE — identical to the old behaviour.
87
+ */
88
+ machine?: PhaseMachine;
89
+ /** Whether the contract file exists and is non-empty (resolved by the caller). */
90
+ contractPresent?: boolean;
61
91
  }
62
92
 
63
93
  export type PhaseTransitionDecision =
64
94
  | {
65
95
  allowed: true;
66
- from: LegionPhase | null;
67
- to: LegionPhase;
96
+ from: string | null;
97
+ to: string;
68
98
  reworkCount: number;
69
99
  phaseSince: number;
70
100
  notifyParent: boolean;
@@ -72,8 +102,8 @@ export type PhaseTransitionDecision =
72
102
  }
73
103
  | {
74
104
  allowed: false;
75
- from: LegionPhase | null;
76
- to: LegionPhase;
105
+ from: string | null;
106
+ to: string;
77
107
  reason: PhaseTransitionReason;
78
108
  message: string;
79
109
  forcedTransition?: {
@@ -84,26 +114,70 @@ export type PhaseTransitionDecision =
84
114
  alarm: true;
85
115
  };
86
116
 
87
- /** Pure decision function for the §7.1 transition table and entry invariants. */
88
- export function evaluatePhaseTransition(
117
+ /**
118
+ * Evaluate a transition against a declared machine (#298).
119
+ *
120
+ * Structural invariants are the same ones the built-in path enforces, applied
121
+ * from data instead of a switch:
122
+ *
123
+ * - a new team enters the machine's start phase
124
+ * - terminal phases cannot transition
125
+ * - `to` must be declared in the current phase's `next`
126
+ * - rework edges increment past `maxRework` → forced failure
127
+ * - `failed` still needs a reason, `aborted` still needs a parent retire
128
+ *
129
+ * Everything else — which edge is legal when — comes from the declaration.
130
+ */
131
+ function evaluateDeclarative(
89
132
  input: EvaluatePhaseTransitionInput,
90
- now: () => number = () => Date.now(),
133
+ machine: PhaseMachine,
134
+ now: () => number,
91
135
  ): PhaseTransitionDecision {
92
136
  const { from, to } = input;
93
137
  const children = input.children ?? [];
94
138
  const reworkCount = input.reworkCount ?? 0;
95
- if (!isLegionPhase(to) || (from !== null && !isLegionPhase(from))) {
96
- return denied(input, "invalid_phase", "phase is not part of the legion state machine");
139
+ const start = machine.start ?? machine.phases[0]?.id;
140
+ const maxRework = machine.maxRework ?? DEFAULT_MAX_REWORK_COUNT;
141
+
142
+ if (from !== null && !findPhase(machine, from)) {
143
+ return denied(input, "invalid_phase", `phase '${from}' is not part of this team's machine`);
144
+ }
145
+ if (!findPhase(machine, to)) {
146
+ return denied(input, "invalid_phase", `phase '${to}' is not part of this team's machine`);
97
147
  }
98
148
  if (!Number.isInteger(reworkCount) || reworkCount < 0) {
99
149
  return denied(input, "invalid_phase", "reworkCount must be a non-negative integer");
100
150
  }
101
151
  if (from === to) return denied(input, "same_phase", "phase is already current");
102
- if (from !== null && isTerminalPhase(from)) {
152
+ if (from !== null && isTerminalIn(machine, from)) {
103
153
  return denied(input, "terminal_phase", `terminal phase ${from} cannot transition`);
104
154
  }
105
- if (from === null && to !== "planning") {
106
- return denied(input, "invalid_transition", "a new team must start in planning");
155
+ if (from === null) {
156
+ if (to !== start) {
157
+ return denied(input, "invalid_transition", `a new team must start in ${String(start)}`);
158
+ }
159
+ return allowed(input, reworkCount, now(), false);
160
+ }
161
+
162
+ // A phase-level `require` still binds a FAILURE exit: leaving `implementing`
163
+ // for `failed` while a child is mid-run would strand that child, so the
164
+ // requirement is checked before the terminal shortcut.
165
+ //
166
+ // `aborted` is deliberately exempt. It is the emergency stop — the parent
167
+ // retire path — and gating it on "everything is settled" would mean a team
168
+ // with a wedged child could never be stopped. Availability of the stop button
169
+ // outranks tidiness.
170
+ const fromPhase = findPhase(machine, from);
171
+ const entryRequirement = to === "aborted"
172
+ ? ({ ok: true } as const)
173
+ : checkPhaseRequirements(fromPhase?.require ?? [], conditionInput(input));
174
+ if (!entryRequirement.ok) {
175
+ const legacy = legacyReasonForCondition(entryRequirement.failed, { to });
176
+ return denied(
177
+ input,
178
+ legacy,
179
+ describeFailedCondition(entryRequirement.failed),
180
+ );
107
181
  }
108
182
  if (to === "aborted") {
109
183
  if (input.terminalReason !== "parent_retire") {
@@ -111,87 +185,149 @@ export function evaluatePhaseTransition(
111
185
  }
112
186
  return allowed(input, reworkCount, now(), false);
113
187
  }
114
- if (from === "implementing" || to === "implementing") {
115
- const planner = children.find((child) => child.role === "planner" && child.status === "running");
116
- if (planner) {
117
- return denied(input, "planner_still_active", `planner child ${planner.id} is still running during implementing`);
118
- }
188
+ if (to === "failed") {
189
+ const failure = evaluateDeclaredFailure(input, children, reworkCount, maxRework, now);
190
+ if (failure) return failure;
119
191
  }
120
- if (to === "failed") return evaluateFailure(input, children, reworkCount, now);
121
- if (from === null) return allowed(input, reworkCount, now(), false);
122
192
 
123
- if (from === "planning" && to === "implementing") {
124
- if (!input.contractPath?.trim()) {
125
- return denied(input, "contract_path_required", "planning requires a non-empty contract_path");
126
- }
127
- return allowed(input, reworkCount, now(), false);
193
+ const phase = findPhase(machine, from);
194
+ const rule = phase?.next.find((candidate) => candidate.to === to);
195
+ if (!rule) {
196
+ // Name the legal exits. Without them a model invents plausible ones (measured:
197
+ // a custom `recon -> build` machine was described as offering `recon -> check`
198
+ // and `recon -> done`, neither of which existed), then wastes a turn being
199
+ // refused. The machine already knows the answer; say it.
200
+ const allowed = (phase?.next ?? []).map((candidate) => candidate.to);
201
+ const hint = allowed.length > 0 ? `legal next phases from '${from}': ${allowed.join(", ")}` : `phase '${from}' has no exits`;
202
+ return denied(
203
+ input,
204
+ "invalid_transition",
205
+ `transition ${from} → ${to} is not allowed by this team's machine (${hint})`,
206
+ );
128
207
  }
129
- if (from === "implementing" && to === "reviewing") {
130
- const workers = children.filter((child) => child.role === "worker");
131
- if (workers.some((child) => isActiveChild(child.status))) {
132
- return denied(input, "worker_still_active", "all worker children must be settled before reviewing");
133
- }
134
- if (workers.length > 0 && workers.every((child) => child.status === "failed")) {
208
+ if (rule.actorRole !== undefined && input.actorRole !== rule.actorRole) {
209
+ // Preserve the historical code for the centurion-only edge so existing
210
+ // callers and tests keep their meaning; other reserved edges get the
211
+ // general name.
212
+ return denied(
213
+ input,
214
+ rule.actorRole === "centurion" ? "centurion_required" : "actor_not_permitted",
215
+ `only ${rule.actorRole} may transition ${from} → ${to}`,
216
+ );
217
+ }
218
+
219
+ // Phase-level requirements AND into this edge.
220
+ const requirement = checkPhaseRequirements(
221
+ [...(phase?.require ?? []), ...(rule.require ?? [])],
222
+ conditionInput(input),
223
+ );
224
+ if (!requirement.ok) {
225
+ // A team whose children ALL failed cannot succeed, so refusing the move it
226
+ // asked for while leaving it "running" strands it: nobody will ever satisfy
227
+ // the requirement it just failed. Force the terminal transition instead.
228
+ // The old code special-cased this for workers; the condition is structural,
229
+ // so it now covers any team whose dispatched children all failed.
230
+ if (
231
+ requirement.failed === "children_none_failed" &&
232
+ checkPhaseCondition("children_all_failed", conditionInput(input))
233
+ ) {
135
234
  return {
136
- ...denied(input, "all_workers_failed", "all worker children failed; team must fail"),
235
+ ...denied(
236
+ input,
237
+ "all_workers_failed",
238
+ `all children failed; the team cannot reach ${to} and must fail`,
239
+ ),
137
240
  forcedTransition: { to: "failed", reworkCount, reason: "all_children_failed" },
138
241
  };
139
242
  }
140
- const handled = new Set(input.failedWorkerHandledIds ?? []);
141
- const unhandled = workers.find((child) => child.status === "failed" && !handled.has(child.id));
142
- if (unhandled) {
143
- return denied(input, "failed_worker_unhandled", `failed worker ${unhandled.id} must be reworked or transferred to failed`);
144
- }
145
- return allowed(input, reworkCount, now(), false);
243
+ const legacy = legacyReasonForCondition(requirement.failed, rule);
244
+ return denied(
245
+ input,
246
+ legacy,
247
+ describeFailedCondition(requirement.failed),
248
+ );
146
249
  }
147
- if (from === "reviewing" && to === "verifying") {
148
- if (!children.some((child) => child.role === "reviewer" && child.status === "settled")) {
149
- return denied(input, "reviewer_not_settled", "at least one reviewer must be settled before verifying");
150
- }
151
- if (input.reviewerVerdictOk !== true) {
152
- return denied(input, "review_verdict_not_accepted", "reviewing requires a settled reviewer verdict ok:true");
153
- }
154
- return allowed(input, reworkCount, now(), false);
155
- }
156
- if (from === "reviewing" && to === "implementing") {
157
- if (input.reviewerVerdictOk !== false) {
158
- return denied(input, "review_verdict_not_rejected", "review rework requires reviewer verdict ok:false");
159
- }
160
- return evaluateRework(input, reworkCount, now);
161
- }
162
- if (from === "verifying" && to === "implementing") {
163
- if (input.verificationPassed !== false) {
164
- return denied(input, "verification_not_failed", "verification rework requires a failing verification result");
250
+
251
+ if (rule.rework === true) {
252
+ const next = reworkCount + 1;
253
+ if (next > maxRework) {
254
+ return {
255
+ ...denied(input, "rework_limit_exceeded", `rework count ${next} exceeds limit ${maxRework}`),
256
+ forcedTransition: { to: "failed", reworkCount: next, reason: "rework_exhausted" },
257
+ };
165
258
  }
166
- return evaluateRework(input, reworkCount, now);
259
+ return allowed(input, next, now(), rule.notifyParent === true);
167
260
  }
168
- if (from === "reviewing" && to === "planning") {
169
- if (input.actorRole !== "centurion") {
170
- return denied(input, "centurion_required", "only the centurion may return a bad plan to planning");
261
+ return allowed(input, reworkCount, now(), rule.notifyParent === true);
262
+ }
263
+
264
+ /** `failed` is only reachable for a declared reason; returns undefined to continue. */
265
+ function evaluateDeclaredFailure(
266
+ input: EvaluatePhaseTransitionInput,
267
+ children: readonly PhaseChild[],
268
+ reworkCount: number,
269
+ maxRework: number,
270
+ now: () => number,
271
+ ): PhaseTransitionDecision | undefined {
272
+ if (input.terminalReason === "parent_decision") return allowed(input, reworkCount, now(), false);
273
+ if (input.terminalReason === "rework_exhausted") {
274
+ if (reworkCount <= maxRework) {
275
+ return denied(input, "rework_limit_exceeded", `rework_count must exceed ${maxRework} for rework_exhausted`);
171
276
  }
172
- return allowed(input, reworkCount, now(), true);
277
+ return allowed(input, reworkCount, now(), false);
173
278
  }
174
- if (from === "verifying" && to === "done") {
175
- if (input.verificationPassed !== true) {
176
- return denied(input, "verification_not_passed", "done requires all verification commands to pass");
177
- }
178
- if (children.some((child) => !isTerminalChild(child.status))) {
179
- return denied(input, "children_not_terminal", "DONE requires every child node to be terminal");
279
+ if (input.terminalReason === "all_children_failed") {
280
+ if (children.length === 0 || children.some((child) => child.status !== "failed")) {
281
+ return denied(input, "all_children_not_failed", "all_children_failed requires every child to have failed");
180
282
  }
181
283
  return allowed(input, reworkCount, now(), false);
182
284
  }
183
- return denied(input, "invalid_transition", `transition ${from} → ${to} is not allowed`);
285
+ return denied(input, "terminal_reason_required", "failed requires parent decision, all children failed, or exhausted rework");
286
+ }
287
+
288
+ function conditionInput(input: EvaluatePhaseTransitionInput): PhaseConditionInput {
289
+ return {
290
+ children: input.children ?? [],
291
+ ...(input.failedWorkerHandledIds ? { handledIds: input.failedWorkerHandledIds } : {}),
292
+ ...(input.contractPath !== undefined ? { contractPath: input.contractPath } : {}),
293
+ ...(input.contractPresent !== undefined ? { contractPresent: input.contractPresent } : {}),
294
+ ...(input.reviewerVerdictOk !== undefined ? { verdictOk: input.reviewerVerdictOk } : {}),
295
+ ...(input.verificationPassed !== undefined ? { verificationPassed: input.verificationPassed } : {}),
296
+ reworkCount: input.reworkCount ?? 0,
297
+ ...(input.machine?.maxRework !== undefined ? { maxRework: input.machine.maxRework } : {}),
298
+ };
299
+ }
300
+
301
+ /** Pure decision function for the §7.1 transition table and entry invariants. */
302
+ export function evaluatePhaseTransition(
303
+ input: EvaluatePhaseTransitionInput,
304
+ now: () => number = () => Date.now(),
305
+ ): PhaseTransitionDecision {
306
+ // #298: the declarative path is the ONLY path, with the built-in machine as the
307
+ // default. The old switch was removed rather than kept as a fallback: it gated
308
+ // `reviewing -> verifying` on a settled `role === "reviewer"`, so once the
309
+ // bundled reviewer card is gone a team could never leave review. Conditions
310
+ // express the same intent without naming a role.
311
+ //
312
+ // Normalization happens here, not only at parse time: the universal terminals
313
+ // (`done`/`failed`/`aborted`) must exist for ANY machine reaching this point —
314
+ // including one built in code — because a team that cannot be aborted is a
315
+ // safety hole. Normalization only ADDS those terminals; taking them stays gated.
316
+ return evaluateDeclarative(input, normalizePhaseMachine(input.machine ?? DEFAULT_PHASE_MACHINE), now);
184
317
  }
185
318
 
319
+
186
320
  export interface PhaseTimeoutInput {
187
- phase: LegionPhase;
321
+ /** Phase id; free-form under a declared machine (#298). */
322
+ phase: string;
188
323
  phaseSince: number | null;
189
324
  now?: number;
190
- timeouts?: Partial<Record<LegionPhase, number>>;
325
+ /** Per-phase overrides. Free-form ids when a declared machine is in play. */
326
+ timeouts?: Partial<Record<string, number>>;
191
327
  }
192
328
 
193
329
  export interface PhaseTimeoutResult {
194
- phase: LegionPhase;
330
+ phase: string;
195
331
  timedOut: boolean;
196
332
  phaseSince: number | null;
197
333
  now: number;
@@ -204,20 +340,26 @@ export function checkPhaseTimeout(
204
340
  input: PhaseTimeoutInput,
205
341
  clock: () => number = () => Date.now(),
206
342
  ): PhaseTimeoutResult {
207
- if (!isLegionPhase(input.phase)) throw new TypeError(`invalid legion phase: ${String(input.phase)}`);
343
+ // A declared machine defines its own phase names, so this no longer rejects an
344
+ // unknown id outright — the caller resolves a timeout first and only asks here
345
+ // for a declared phase.
346
+ if (typeof input.phase !== "string" || !input.phase) {
347
+ throw new TypeError(`invalid legion phase: ${String(input.phase)}`);
348
+ }
208
349
  const now = input.now ?? clock();
209
350
  if (!Number.isFinite(now)) throw new TypeError("now must be a finite millisecond timestamp");
210
351
  if (input.phaseSince !== null && !Number.isFinite(input.phaseSince)) {
211
352
  throw new TypeError("phaseSince must be null or a finite millisecond timestamp");
212
353
  }
213
- const timeoutMs = input.timeouts?.[input.phase] ?? DEFAULT_PHASE_TIMEOUTS[input.phase];
354
+ const override = input.timeouts?.[input.phase];
355
+ const timeoutMs = override ?? DEFAULT_PHASE_TIMEOUTS[input.phase as LegionPhase] ?? 0;
214
356
  if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
215
357
  throw new TypeError("phase timeout must be a finite non-negative duration");
216
358
  }
217
359
  const elapsedMs = input.phaseSince === null ? null : Math.max(0, now - input.phaseSince);
218
360
  return {
219
361
  phase: input.phase,
220
- timedOut: elapsedMs !== null && !isTerminalPhase(input.phase) && elapsedMs >= timeoutMs,
362
+ timedOut: elapsedMs !== null && !isTerminalPhase(input.phase) && timeoutMs > 0 && elapsedMs >= timeoutMs,
221
363
  phaseSince: input.phaseSince,
222
364
  now,
223
365
  elapsedMs,
@@ -225,7 +367,7 @@ export function checkPhaseTimeout(
225
367
  };
226
368
  }
227
369
 
228
- export function isTerminalPhase(phase: LegionPhase): boolean {
370
+ export function isTerminalPhase(phase: string): boolean {
229
371
  return phase === "done" || phase === "failed" || phase === "aborted";
230
372
  }
231
373
 
@@ -237,42 +379,7 @@ export function isLegionPhase(value: unknown): value is LegionPhase {
237
379
  return typeof value === "string" && (LEGION_PHASES as readonly string[]).includes(value);
238
380
  }
239
381
 
240
- function evaluateRework(
241
- input: EvaluatePhaseTransitionInput,
242
- reworkCount: number,
243
- now: () => number,
244
- ): PhaseTransitionDecision {
245
- const next = reworkCount + 1;
246
- if (next > MAX_REWORK_COUNT) {
247
- return {
248
- ...denied(input, "rework_limit_exceeded", `rework count ${next} exceeds limit ${MAX_REWORK_COUNT}`),
249
- forcedTransition: { to: "failed", reworkCount: next, reason: "rework_exhausted" },
250
- };
251
- }
252
- return allowed(input, next, now(), false);
253
- }
254
382
 
255
- function evaluateFailure(
256
- input: EvaluatePhaseTransitionInput,
257
- children: readonly PhaseChild[],
258
- reworkCount: number,
259
- now: () => number,
260
- ): PhaseTransitionDecision {
261
- if (input.terminalReason === "parent_decision") return allowed(input, reworkCount, now(), false);
262
- if (input.terminalReason === "rework_exhausted") {
263
- if (reworkCount <= MAX_REWORK_COUNT) {
264
- return denied(input, "rework_limit_exceeded", `rework_count must exceed ${MAX_REWORK_COUNT} for rework_exhausted`);
265
- }
266
- return allowed(input, reworkCount, now(), false);
267
- }
268
- if (input.terminalReason === "all_children_failed") {
269
- if (children.length === 0 || children.some((child) => child.status !== "failed")) {
270
- return denied(input, "all_children_not_failed", "all_children_failed requires every child to have failed");
271
- }
272
- return allowed(input, reworkCount, now(), false);
273
- }
274
- return denied(input, "terminal_reason_required", "failed requires parent decision, all children failed, or exhausted rework");
275
- }
276
383
 
277
384
  function allowed(
278
385
  input: EvaluatePhaseTransitionInput,
@@ -308,6 +415,3 @@ function denied(
308
415
  return { allowed: false, from: input.from, to: input.to, reason, message, alarm: true };
309
416
  }
310
417
 
311
- function isActiveChild(status: string): boolean {
312
- return status === "starting" || status === "running" || status === "blocked";
313
- }
package/src/runs/args.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  isThinkingLevel,
15
15
  } from "../shared/types.ts";
16
16
  import { formatChildTask } from "../extension/child-guard.ts";
17
+ import { childInheritsExtensions, discoveredHostExtensions, inheritedExtensions } from "./extension-inheritance.ts";
17
18
 
18
19
  /** Re-exported for callers that only need the level list. */
19
20
  export { THINKING_LEVELS };
@@ -162,15 +163,11 @@ export function childExtraExtensions(env: NodeJS.ProcessEnv = process.env): stri
162
163
  }
163
164
 
164
165
  /**
165
- * Escape hatch (#43): role frontmatter `inheritExtensions: true`, or
166
- * `PI_LEGION_CHILD_INHERIT_EXTENSIONS=1` for every child, restores the old
167
- * behaviour of loading all host-discovered extensions.
166
+ * Re-exported from `extension-inheritance.ts`, which owns the policy (#280).
167
+ * Kept here because `args.ts` is the historical import site for the rpc argument
168
+ * builder and `legion-doctor`.
168
169
  */
169
- export function childInheritsExtensions(agent: Pick<AgentConfig, "inheritExtensions">, env: NodeJS.ProcessEnv = process.env): boolean {
170
- if (agent.inheritExtensions === true) return true;
171
- const raw = env[CHILD_INHERIT_EXTENSIONS_ENV]?.trim().toLowerCase();
172
- return raw === "1" || raw === "true" || raw === "on" || raw === "yes";
173
- }
170
+ export { childInheritsExtensions } from "./extension-inheritance.ts";
174
171
 
175
172
  /** Light pi builtin that registers the `codemode` tool (`-e builtin:codemode`). */
176
173
  export const BUILTIN_CODEMODE = "builtin:codemode";
@@ -195,16 +192,29 @@ export function isCodemodeExtension(ext: string): boolean {
195
192
  }
196
193
 
197
194
  /**
198
- * Effective `--extension` values for a child under `--no-extensions`.
195
+ * Effective `--extension` values for a child.
196
+ *
197
+ * Order is deliberate: inherited host extensions FIRST (they are ambient
198
+ * capabilities the child would otherwise lose), then the role's own explicit
199
+ * list, then `PI_LEGION_CHILD_EXTENSIONS`, then `builtin:codemode` (#89), and
200
+ * finally the child-guard — which is unconditional and never subject to
201
+ * `inheritExtensions` / `excludeExtensions`, because a child without it breaks
202
+ * the prompt/format contract regardless of what the card asked for.
199
203
  *
200
- * Includes role `extensions` / `subagentOnlyExtensions`, `PI_LEGION_CHILD_EXTENSIONS`,
201
- * auto `builtin:codemode` when `tools` lists `codemode` (#89), and the child-guard.
204
+ * #280: the inherited set is computed by `inheritedExtensions` and is empty when
205
+ * the card sets `inheritExtensions: false`; `excludeExtensions` subtracts from
206
+ * it. Inherited paths are subject to the same dedupe as everything else, so a
207
+ * card that also names an inherited extension does not load it twice.
202
208
  */
203
209
  export function collectChildExtensions(
204
- agent: Pick<AgentConfig, "tools" | "extensions" | "subagentOnlyExtensions">,
210
+ agent: Pick<
211
+ AgentConfig,
212
+ "tools" | "extensions" | "subagentOnlyExtensions" | "inheritExtensions" | "excludeExtensions"
213
+ >,
205
214
  env: NodeJS.ProcessEnv = process.env,
206
215
  ): string[] {
207
216
  const list = [
217
+ ...inheritedExtensions(agent, discoveredHostExtensions() ?? []),
208
218
  ...(agent.extensions ?? []),
209
219
  ...(agent.subagentOnlyExtensions ?? []),
210
220
  ...childExtraExtensions(env),
@@ -234,12 +244,16 @@ function pushToolArgs(args: string[], agent: AgentConfig): void {
234
244
  args.push("--tools", tools.join(","));
235
245
  }
236
246
 
237
- // Only pass through what the agent explicitly lists: a child must not
238
- // silently inherit every extension the parent happens to have loaded
239
- // (#43: each host extension such as an LSP stack costs ~1 GB per child).
240
- // `--no-extensions` stops discovery; explicit `--extension` paths below
241
- // (the role's list + auto builtin:codemode + this package for the guard) still load.
242
- if (!childInheritsExtensions(agent)) args.push("--no-extensions");
247
+ // #280: pi's own discovery is ALWAYS disabled and the full list is passed
248
+ // explicitly. `--no-extensions` only drops the settings-discovered set; `-e`
249
+ // paths (below) and builtins still load, so this is the shape that keeps the
250
+ // child's extension set exactly equal to what we computed — the inherited set
251
+ // minus `excludeExtensions`, plus the role's own list, plus the child-guard.
252
+ //
253
+ // Letting pi discover as well would silently re-add an extension the card
254
+ // excluded, and would make rpc disagree with in-process (which builds its
255
+ // loader with `noExtensions: true` for the same reason).
256
+ args.push("--no-extensions");
243
257
  for (const ext of collectChildExtensions(agent)) {
244
258
  args.push("--extension", ext);
245
259
  }