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.
- package/CHANGELOG.md +168 -1
- package/README.md +101 -44
- package/agents/centurion.md +20 -16
- package/agents/scout.md +3 -1
- package/agents/worker.md +5 -7
- package/docs/contracts/declarative-phase-machine-v5.md +128 -0
- package/docs/contracts/subagent-renewal-v1.md +260 -0
- package/docs/headless-child-contract.md +6 -3
- package/docs/inprocess-supervisor.md +70 -24
- package/docs/legion-v2-contract.md +9 -0
- package/docs/spawn-mode-visibility-v1.md +4 -1
- package/index.ts +598 -44
- package/package.json +2 -2
- package/src/agents/agents.ts +293 -36
- package/src/agents/overrides.ts +3 -1
- package/src/agents/settings.ts +6 -18
- package/src/extension/child-guard.ts +25 -1
- package/src/extension/legion-tools.ts +66 -21
- package/src/extension/notify.ts +136 -4
- package/src/extension/playbook.ts +2 -2
- package/src/extension/runtime.ts +56 -1
- package/src/extension/summary.ts +13 -4
- package/src/extension/timing.ts +8 -4
- package/src/legion/phase-conditions.ts +164 -0
- package/src/legion/phase-machine.ts +403 -0
- package/src/legion/state-machine.ts +219 -115
- package/src/runs/args.ts +32 -18
- package/src/runs/extension-inheritance.ts +220 -0
- package/src/runs/orchestrator.ts +137 -44
- package/src/runs/progress-signal.ts +137 -0
- package/src/runs/store-lookup.ts +270 -0
- package/src/shared/types.ts +88 -3
- package/src/supervisor/child-context.ts +19 -0
- package/src/supervisor/inprocess-supervisor.ts +29 -5
- package/src/supervisor/routing-supervisor.ts +2 -1
- package/src/supervisor/rpc-supervisor.ts +2 -2
- package/src/tui/tree-view.ts +56 -0
- package/src/tui/viz/palette.ts +0 -3
- package/agents/advisor.md +0 -110
- package/agents/designer.md +0 -87
- package/agents/planner.md +0 -53
- package/agents/prototype.md +0 -72
- 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
|
-
|
|
52
|
-
|
|
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:
|
|
67
|
-
to:
|
|
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:
|
|
76
|
-
to:
|
|
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
|
-
/**
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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 &&
|
|
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
|
|
106
|
-
|
|
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 (
|
|
115
|
-
const
|
|
116
|
-
if (
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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 (
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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(
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
243
|
+
const legacy = legacyReasonForCondition(requirement.failed, rule);
|
|
244
|
+
return denied(
|
|
245
|
+
input,
|
|
246
|
+
legacy,
|
|
247
|
+
describeFailedCondition(requirement.failed),
|
|
248
|
+
);
|
|
146
249
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
259
|
+
return allowed(input, next, now(), rule.notifyParent === true);
|
|
167
260
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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(),
|
|
277
|
+
return allowed(input, reworkCount, now(), false);
|
|
173
278
|
}
|
|
174
|
-
if (
|
|
175
|
-
if (
|
|
176
|
-
return denied(input, "
|
|
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, "
|
|
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
|
-
|
|
321
|
+
/** Phase id; free-form under a declared machine (#298). */
|
|
322
|
+
phase: string;
|
|
188
323
|
phaseSince: number | null;
|
|
189
324
|
now?: number;
|
|
190
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
*
|
|
166
|
-
* `
|
|
167
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
201
|
-
*
|
|
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<
|
|
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
|
-
//
|
|
238
|
-
//
|
|
239
|
-
// (
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
|
|
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
|
}
|