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
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-legion",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Pi Legion v2: recursive centurion
|
|
3
|
+
"version": "5.0.0",
|
|
4
|
+
"description": "Pi Legion v2: recursive centurion teams on Pi. Children run in-process by default (rpc only for a worktree-owning centurion); shared SQLite WAL legion.db. Declarative per-team phase machine (built-in default: planning/implementing/reviewing/verifying), parent/child/sibling legion_mail, three-layer /legion UI, upward MR trees, squad resume.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
package/src/agents/agents.ts
CHANGED
|
@@ -19,7 +19,6 @@ import {
|
|
|
19
19
|
type AcceptanceCriterion,
|
|
20
20
|
type OnBlockedPolicy,
|
|
21
21
|
type SpawnMode,
|
|
22
|
-
SPAWN_MODES,
|
|
23
22
|
type SystemPromptMode,
|
|
24
23
|
type ToolBudgetConfig,
|
|
25
24
|
type TurnBudgetConfig,
|
|
@@ -31,16 +30,19 @@ import {
|
|
|
31
30
|
} from "./frontmatter.ts";
|
|
32
31
|
import { getAgentDir } from "./paths.ts";
|
|
33
32
|
import { ignoredKindMessage, reportIgnoredOnce } from "../shared/ignored-kind.ts";
|
|
33
|
+
import {
|
|
34
|
+
type PhaseDefinition,
|
|
35
|
+
type PhaseMachine,
|
|
36
|
+
type PhaseTransitionRule,
|
|
37
|
+
normalizePhaseMachine,
|
|
38
|
+
validatePhaseMachine,
|
|
39
|
+
} from "../legion/phase-machine.ts";
|
|
40
|
+
import type { PhaseCondition } from "../legion/phase-conditions.ts";
|
|
34
41
|
|
|
35
42
|
export const BUILTIN_AGENT_NAMES = [
|
|
36
43
|
"scout",
|
|
37
|
-
"planner",
|
|
38
|
-
"reviewer",
|
|
39
44
|
"worker",
|
|
40
45
|
"centurion",
|
|
41
|
-
"advisor",
|
|
42
|
-
"designer",
|
|
43
|
-
"prototype",
|
|
44
46
|
] as const;
|
|
45
47
|
|
|
46
48
|
/**
|
|
@@ -177,15 +179,6 @@ function int(value: unknown): number | undefined {
|
|
|
177
179
|
return undefined;
|
|
178
180
|
}
|
|
179
181
|
|
|
180
|
-
function spawnModeOf(value: unknown, filePath: string): SpawnMode | undefined {
|
|
181
|
-
if (value === undefined || value === null || value === "") return undefined;
|
|
182
|
-
const raw = typeof value === "string" ? value.trim() : String(value);
|
|
183
|
-
if ((SPAWN_MODES as readonly string[]).includes(raw)) return raw as SpawnMode;
|
|
184
|
-
throw new Error(
|
|
185
|
-
`Agent '${filePath}' has invalid spawnMode '${raw}'; expected one of: ${SPAWN_MODES.join(", ")}.`,
|
|
186
|
-
);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
182
|
|
|
190
183
|
/**
|
|
191
184
|
* Accept `tools: read, bash`, `tools: [read, bash]`, and block-list spellings.
|
|
@@ -413,28 +406,281 @@ function parseCriteria(value: unknown): AcceptanceCriterion[] | undefined {
|
|
|
413
406
|
return criteria.length > 0 ? criteria : undefined;
|
|
414
407
|
}
|
|
415
408
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
409
|
+
/**
|
|
410
|
+
* Read an indented `phases:` block into the machine shape (#298).
|
|
411
|
+
*
|
|
412
|
+
* The shared frontmatter parser is flat — nested lists come back as raw text —
|
|
413
|
+
* so this walks the text itself. The grammar is deliberately tiny and matches
|
|
414
|
+
* only what a card can express:
|
|
415
|
+
*
|
|
416
|
+
* ```text
|
|
417
|
+
* - id: planning # a phase
|
|
418
|
+
* require: [contract_file] # inline condition list
|
|
419
|
+
* timeoutMs: 900000
|
|
420
|
+
* terminal: false
|
|
421
|
+
* next: # a list of transitions
|
|
422
|
+
* - to: implementing
|
|
423
|
+
* require: [contract_file]
|
|
424
|
+
* actorRole: centurion
|
|
425
|
+
* rework: true
|
|
426
|
+
* - to: failed
|
|
427
|
+
* ```
|
|
428
|
+
*
|
|
429
|
+
* Anything outside this shape is an error rather than a silent skip, because a
|
|
430
|
+
* half-read machine is worse than none: it would run with the transitions it
|
|
431
|
+
* happened to parse.
|
|
432
|
+
*/
|
|
433
|
+
function parsePhaseBlock(text: string): PhaseMachine | undefined {
|
|
434
|
+
// `id` is filled from the `- id: ...` line; validatePhaseMachine rejects a
|
|
435
|
+
// machine where it is missing, so the assertion at the end is safe.
|
|
436
|
+
type PhaseDraft = Record<string, unknown> & { id: string; next?: Array<Record<string, unknown>> };
|
|
437
|
+
const phases: PhaseDraft[] = [];
|
|
438
|
+
let currentPhase: PhaseDraft | undefined;
|
|
439
|
+
let currentRule: Record<string, unknown> | undefined;
|
|
440
|
+
// Column where list items live. The first `- ` we see sets it, so the block may
|
|
441
|
+
// be indented by any amount relative to the document.
|
|
442
|
+
let itemIndent = -1;
|
|
443
|
+
|
|
444
|
+
const scalar = (value: string): unknown => {
|
|
445
|
+
const trimmed = value.trim();
|
|
446
|
+
if (trimmed === "true") return true;
|
|
447
|
+
if (trimmed === "false") return false;
|
|
448
|
+
if (/^-?\d+$/.test(trimmed)) return Number(trimmed);
|
|
449
|
+
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
450
|
+
const inner = trimmed.slice(1, -1).trim();
|
|
451
|
+
if (!inner) return [];
|
|
452
|
+
return inner.split(",").map((part) => part.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
|
|
453
|
+
}
|
|
454
|
+
return trimmed.replace(/^["']|["']$/g, "");
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
for (const rawLine of text.split("\n")) {
|
|
458
|
+
if (!rawLine.trim()) continue;
|
|
459
|
+
const indent = rawLine.length - rawLine.trimStart().length;
|
|
460
|
+
const line = rawLine.trim();
|
|
461
|
+
|
|
462
|
+
if (line.startsWith("- ")) {
|
|
463
|
+
if (itemIndent === -1) itemIndent = indent;
|
|
464
|
+
const body = line.slice(2).trim();
|
|
465
|
+
const entry: Record<string, unknown> = {};
|
|
466
|
+
const colon = body.indexOf(":");
|
|
467
|
+
if (colon > 0) entry[body.slice(0, colon).trim()] = scalar(body.slice(colon + 1));
|
|
468
|
+
// A `- ` at the item column opens a phase; deeper opens a transition.
|
|
469
|
+
if (indent <= itemIndent) {
|
|
470
|
+
currentPhase = entry as PhaseDraft;
|
|
471
|
+
currentPhase.next = [];
|
|
472
|
+
phases.push(currentPhase);
|
|
473
|
+
currentRule = undefined;
|
|
474
|
+
} else if (currentPhase) {
|
|
475
|
+
currentRule = entry;
|
|
476
|
+
currentPhase.next!.push(entry);
|
|
477
|
+
}
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const colon = line.indexOf(":");
|
|
482
|
+
if (colon <= 0) continue;
|
|
483
|
+
const key = line.slice(0, colon).trim();
|
|
484
|
+
const value = line.slice(colon + 1).trim();
|
|
485
|
+
const target = currentRule ?? currentPhase;
|
|
486
|
+
if (!target) continue;
|
|
487
|
+
if (value === "") {
|
|
488
|
+
// `next:` with nothing after it — the transitions follow as `- to:` lines.
|
|
489
|
+
if (key === "next") target.next = target.next ?? [];
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
target[key] = scalar(value);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (phases.length === 0) return undefined;
|
|
496
|
+
// Convert the walked drafts into the declared shape explicitly instead of
|
|
497
|
+
// asserting it. `validatePhaseMachine` runs right after this and rejects
|
|
498
|
+
// anything malformed, but doing the coercion here means the compiler — not just
|
|
499
|
+
// the validator — has seen every field.
|
|
500
|
+
const converted: PhaseDefinition[] = phases.map((draft) => {
|
|
501
|
+
const rules: PhaseTransitionRule[] = (draft.next ?? []).map((rule) => {
|
|
502
|
+
const out: PhaseTransitionRule = { to: String(rule.to ?? "") };
|
|
503
|
+
if (Array.isArray(rule.require)) out.require = rule.require.map(String) as PhaseCondition[];
|
|
504
|
+
if (typeof rule.actorRole === "string") out.actorRole = rule.actorRole;
|
|
505
|
+
if (rule.rework === true) out.rework = true;
|
|
506
|
+
if (rule.notifyParent === true) out.notifyParent = true;
|
|
507
|
+
return out;
|
|
508
|
+
});
|
|
509
|
+
const phase: PhaseDefinition = { id: String(draft.id ?? ""), next: rules };
|
|
510
|
+
if (Array.isArray(draft.require)) phase.require = draft.require.map(String) as PhaseCondition[];
|
|
511
|
+
if (typeof draft.timeoutMs === "number") phase.timeoutMs = draft.timeoutMs;
|
|
512
|
+
if (draft.terminal === true) phase.terminal = true;
|
|
513
|
+
return phase;
|
|
514
|
+
});
|
|
515
|
+
const machine: PhaseMachine = { phases: converted };
|
|
516
|
+
if (typeof (phases as unknown as { start?: unknown }).start === "string") {
|
|
517
|
+
machine.start = String((phases as unknown as { start: string }).start);
|
|
425
518
|
}
|
|
519
|
+
return machine;
|
|
426
520
|
}
|
|
427
521
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
522
|
+
/**
|
|
523
|
+
* Parse a declared phase machine from `phases:` (#298).
|
|
524
|
+
*
|
|
525
|
+
* Accepts the YAML shape directly (a list of phase objects), or the same thing
|
|
526
|
+
* as a JSON string, matching how `toolBudget` is written.
|
|
527
|
+
*
|
|
528
|
+
* Validation happens HERE, at load, and every problem is reported at once. A
|
|
529
|
+
* machine that only fails mid-run would surface after a team had already spent
|
|
530
|
+
* tokens on a workflow that could never complete.
|
|
531
|
+
*/
|
|
532
|
+
function parsePhaseMachine(value: unknown, filePath: string): PhaseMachine | undefined {
|
|
533
|
+
if (value === undefined || value === null) return undefined;
|
|
534
|
+
let raw: unknown = value;
|
|
535
|
+
if (typeof value === "string") {
|
|
536
|
+
const text = value.trim();
|
|
537
|
+
if (!text) return undefined;
|
|
538
|
+
// The frontmatter parser is FLAT, so a nested `phases:` block arrives here
|
|
539
|
+
// as raw text — exactly the situation `parseAcceptance` already handles.
|
|
540
|
+
// Two spellings are therefore accepted: an indented YAML block (what the
|
|
541
|
+
// card looks like) and a one-line JSON string (easier to generate).
|
|
542
|
+
let parsedText: unknown;
|
|
543
|
+
let wasJson = true;
|
|
544
|
+
try {
|
|
545
|
+
parsedText = JSON.parse(text);
|
|
546
|
+
} catch {
|
|
547
|
+
wasJson = false;
|
|
548
|
+
parsedText = parsePhaseBlock(text);
|
|
549
|
+
}
|
|
550
|
+
if (!wasJson && parsedText === undefined) {
|
|
551
|
+
throw new Error(
|
|
552
|
+
`Agent '${filePath}' has invalid phases: could not read the block. ` +
|
|
553
|
+
`Expected an indented list of " - id: <name>" entries, or a JSON string.`,
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
raw = parsedText;
|
|
437
557
|
}
|
|
558
|
+
// Accept both `[{...}]` and `{ phases: [...] }`.
|
|
559
|
+
const shaped = Array.isArray(raw)
|
|
560
|
+
? ({ phases: raw } as unknown)
|
|
561
|
+
: raw;
|
|
562
|
+
if (shaped === null || typeof shaped !== "object") {
|
|
563
|
+
throw new Error(`Agent '${filePath}' has invalid phases: expected a list or an object with a 'phases' key.`);
|
|
564
|
+
}
|
|
565
|
+
const machine = shaped as PhaseMachine;
|
|
566
|
+
if (!Array.isArray(machine.phases)) {
|
|
567
|
+
throw new Error(`Agent '${filePath}' has invalid phases: 'phases' must be a list.`);
|
|
568
|
+
}
|
|
569
|
+
// Inject the universal terminals before validating, so reachability is judged
|
|
570
|
+
// on the machine as it will actually run.
|
|
571
|
+
const normalized = normalizePhaseMachine(machine);
|
|
572
|
+
const errors = validatePhaseMachine(normalized);
|
|
573
|
+
if (errors.length > 0) {
|
|
574
|
+
const detail = errors.map((error) => ` - ${error.path}: ${error.message}`).join("\n");
|
|
575
|
+
throw new Error(`Agent '${filePath}' has an invalid phase machine:\n${detail}`);
|
|
576
|
+
}
|
|
577
|
+
return normalized;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Parse a child budget (`toolBudget` / `turnBudget`) strictly.
|
|
582
|
+
*
|
|
583
|
+
* These used to swallow every error and return `undefined`, so a typo became
|
|
584
|
+
* "no limit" -- the same silent-acceptance failure the frontmatter field check
|
|
585
|
+
* exists to prevent. Worse, a NEGATIVE number was accepted: `-3` reached the
|
|
586
|
+
* child, `toolCalls > -3` was true immediately, and the first tool call was
|
|
587
|
+
* blocked while the author believed they had set a limit of 3.
|
|
588
|
+
*
|
|
589
|
+
* `0` is meaningful here ("no calls allowed", per `extension/budget.ts`), so it
|
|
590
|
+
* must survive rather than decay into "unset". Anything that is not a
|
|
591
|
+
* non-negative integer is an error naming the file and the field.
|
|
592
|
+
*/
|
|
593
|
+
function parseBudgets(fm: AgentFrontmatter, filePath: string): {
|
|
594
|
+
tool?: ToolBudgetConfig;
|
|
595
|
+
turn?: TurnBudgetConfig;
|
|
596
|
+
} {
|
|
597
|
+
const read = (
|
|
598
|
+
field: "toolBudget" | "turnBudget",
|
|
599
|
+
key: "maxToolCalls" | "maxTurns",
|
|
600
|
+
): number | undefined => {
|
|
601
|
+
const raw = str(fm[field]);
|
|
602
|
+
if (!raw) return undefined;
|
|
603
|
+
let parsed: unknown;
|
|
604
|
+
try {
|
|
605
|
+
parsed = JSON.parse(raw);
|
|
606
|
+
} catch {
|
|
607
|
+
throw new Error(
|
|
608
|
+
`Agent '${filePath}' has invalid ${field}: expected a JSON object like '{"${key}": 10}', got '${raw}'.`,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
612
|
+
throw new Error(
|
|
613
|
+
`Agent '${filePath}' has invalid ${field}: expected a JSON object with a '${key}' key.`,
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
const value = (parsed as Record<string, unknown>)[key];
|
|
617
|
+
if (value === undefined) return undefined;
|
|
618
|
+
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
619
|
+
throw new Error(
|
|
620
|
+
`Agent '${filePath}' has invalid ${field}.${key}: expected a non-negative integer, got ${JSON.stringify(value)}.`,
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
if (value < 0) {
|
|
624
|
+
throw new Error(
|
|
625
|
+
`Agent '${filePath}' has invalid ${field}.${key}: ${value} is negative. ` +
|
|
626
|
+
`0 means "none allowed"; omit the field for "no limit".`,
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
return value;
|
|
630
|
+
};
|
|
631
|
+
const toolMax = read("toolBudget", "maxToolCalls");
|
|
632
|
+
const turnMax = read("turnBudget", "maxTurns");
|
|
633
|
+
return {
|
|
634
|
+
...(toolMax !== undefined ? { tool: { maxToolCalls: toolMax } } : {}),
|
|
635
|
+
...(turnMax !== undefined ? { turn: { maxTurns: turnMax } } : {}),
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Every frontmatter key a role card may declare.
|
|
641
|
+
*
|
|
642
|
+
* Kept next to the parser on purpose: an unknown key used to be silently
|
|
643
|
+
* ignored, so a typo (`maxSubagentdepth`, `inheritSkill`) produced a card that
|
|
644
|
+
* looked accepted but did nothing. That is the worst failure mode for a config
|
|
645
|
+
* file — the author believes the setting is in effect. Cards are now validated
|
|
646
|
+
* against this list and an unknown key is a hard error.
|
|
647
|
+
*
|
|
648
|
+
* When adding a field, add it here AND parse it; a name in this list that no
|
|
649
|
+
* parser reads is just as misleading as the reverse.
|
|
650
|
+
*/
|
|
651
|
+
const AGENT_FRONTMATTER_FIELDS: ReadonlySet<string> = new Set([
|
|
652
|
+
// identity
|
|
653
|
+
"name", "description", "kind", "model", "thinking", "alias", "aliases",
|
|
654
|
+
"preset", "team", "output", "fallbackModels",
|
|
655
|
+
// prompt / context
|
|
656
|
+
"systemPromptMode", "inheritProjectContext", "inheritSkills", "inheritExtensions",
|
|
657
|
+
"excludeExtensions",
|
|
658
|
+
"phases",
|
|
659
|
+
"skillPath", "skills", "defaultReads",
|
|
660
|
+
// tools
|
|
661
|
+
"tools", "extensions", "subagentOnlyExtensions",
|
|
662
|
+
// budgets / guards / lifecycle
|
|
663
|
+
"defaultProgress", "async", "timeoutMs", "toolTimeoutMs", "completionGuard",
|
|
664
|
+
"maxSubagentDepth", "allowNestedSubagents", "renewable", "maxRenewals",
|
|
665
|
+
"utility", "disabled", "worktree", "steer", "onBlocked",
|
|
666
|
+
"acceptance", "toolBudget", "turnBudget",
|
|
667
|
+
]);
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Throw when a card declares a key this parser does not understand.
|
|
671
|
+
*
|
|
672
|
+
* Names are compared case-sensitively: YAML keys are case-sensitive, and a
|
|
673
|
+
* near-miss (`MaxRenewals`) is exactly the typo this is meant to catch.
|
|
674
|
+
*/
|
|
675
|
+
function assertKnownFrontmatter(frontmatter: Record<string, unknown>, filePath: string): void {
|
|
676
|
+
const unknown = Object.keys(frontmatter).filter((key) => !AGENT_FRONTMATTER_FIELDS.has(key));
|
|
677
|
+
if (unknown.length === 0) return;
|
|
678
|
+
const known = [...AGENT_FRONTMATTER_FIELDS].sort().join(", ");
|
|
679
|
+
throw new Error(
|
|
680
|
+
`Agent '${filePath}' has unknown frontmatter ${unknown.length === 1 ? "field" : "fields"}: ` +
|
|
681
|
+
`${unknown.map((key) => `'${key}'`).join(", ")}. ` +
|
|
682
|
+
`Known fields: ${known}.`,
|
|
683
|
+
);
|
|
438
684
|
}
|
|
439
685
|
|
|
440
686
|
/**
|
|
@@ -448,6 +694,7 @@ export function parseAgentDocument(
|
|
|
448
694
|
): AgentConfig | null {
|
|
449
695
|
const { frontmatter, body } = parseFrontmatter(content);
|
|
450
696
|
const fm = frontmatter as AgentFrontmatter;
|
|
697
|
+
assertKnownFrontmatter(fm, filePath);
|
|
451
698
|
|
|
452
699
|
const name = str(fm.name);
|
|
453
700
|
const description = str(fm.description);
|
|
@@ -543,6 +790,7 @@ function applyCapabilityFields(
|
|
|
543
790
|
setIf(config, "skills", listOrFalse(fm.skills));
|
|
544
791
|
setIf(config, "skillPath", list(fm.skillPath));
|
|
545
792
|
setIf(config, "extensions", list(fm.extensions));
|
|
793
|
+
if (list(fm.excludeExtensions)) config.excludeExtensions = list(fm.excludeExtensions);
|
|
546
794
|
setIf(config, "subagentOnlyExtensions", list(fm.subagentOnlyExtensions));
|
|
547
795
|
setIf(config, "alias", list(fm.alias) ?? list(fm.aliases));
|
|
548
796
|
setIf(config, "output", str(fm.output));
|
|
@@ -558,14 +806,23 @@ function applyBehaviorFields(config: AgentConfig, fm: AgentFrontmatter): void {
|
|
|
558
806
|
setIf(config, "completionGuard", bool(fm.completionGuard));
|
|
559
807
|
setIf(config, "maxSubagentDepth", int(fm.maxSubagentDepth));
|
|
560
808
|
setIf(config, "allowNestedSubagents", bool(fm.allowNestedSubagents));
|
|
809
|
+
// D5/D6: parsed verbatim and left absent when undeclared — the defaults live
|
|
810
|
+
// with the consumers (DEFAULT_RENEWABLE / DEFAULT_MAX_RENEWALS), so this
|
|
811
|
+
// layer never has to guess whether the role was explicit. `int()` returns 0
|
|
812
|
+
// for `maxRenewals: 0`, and `setIf` only skips `undefined`, so the
|
|
813
|
+
// "renewal disabled" spelling survives instead of decaying to the default.
|
|
814
|
+
setIf(config, "renewable", bool(fm.renewable));
|
|
815
|
+
setIf(config, "maxRenewals", int(fm.maxRenewals));
|
|
561
816
|
setIf(config, "utility", bool(fm.utility));
|
|
562
817
|
setIf(config, "disabled", bool(fm.disabled));
|
|
563
818
|
setIf(config, "worktree", bool(fm.worktree));
|
|
564
819
|
setIf(config, "steer", bool(fm.steer));
|
|
565
|
-
setIf(config, "spawnMode", spawnModeOf(fm.spawnMode, config.filePath));
|
|
566
820
|
setIf(config, "acceptance", parseAcceptance(fm.acceptance, config.name));
|
|
567
|
-
|
|
568
|
-
|
|
821
|
+
const phases = parsePhaseMachine(fm.phases, config.filePath);
|
|
822
|
+
if (phases) config.phases = phases;
|
|
823
|
+
const budgets = parseBudgets(fm, config.filePath);
|
|
824
|
+
setIf(config, "toolBudget", budgets.tool);
|
|
825
|
+
setIf(config, "turnBudget", budgets.turn);
|
|
569
826
|
// #214 / contract §9: only centurion holds worktree isolation. Writers no
|
|
570
827
|
// longer auto-default to worktree:true — members work in-place; parallel
|
|
571
828
|
// isolated writers are N root-dispatched centurions. Explicit worktree:true
|
package/src/agents/overrides.ts
CHANGED
|
@@ -31,19 +31,21 @@ export const OVERRIDE_FIELDS = [
|
|
|
31
31
|
"inheritProjectContext",
|
|
32
32
|
"inheritSkills",
|
|
33
33
|
"inheritExtensions",
|
|
34
|
+
"excludeExtensions",
|
|
34
35
|
"defaultProgress",
|
|
35
36
|
"async",
|
|
36
37
|
"completionGuard",
|
|
37
38
|
"allowNestedSubagents",
|
|
39
|
+
"renewable",
|
|
38
40
|
"utility",
|
|
39
41
|
"disabled",
|
|
40
42
|
"worktree",
|
|
41
43
|
"steer",
|
|
42
|
-
"spawnMode",
|
|
43
44
|
// numbers
|
|
44
45
|
"timeoutMs",
|
|
45
46
|
"toolTimeoutMs",
|
|
46
47
|
"maxSubagentDepth",
|
|
48
|
+
"maxRenewals",
|
|
47
49
|
// arrays (a `false` value is meaningful for skills/extensions)
|
|
48
50
|
"tools",
|
|
49
51
|
"skills",
|
package/src/agents/settings.ts
CHANGED
|
@@ -18,7 +18,6 @@ import {
|
|
|
18
18
|
type InProcessLimits,
|
|
19
19
|
type RpcSettings,
|
|
20
20
|
type SubagentsSettings,
|
|
21
|
-
SPAWN_MODES,
|
|
22
21
|
type TeamConfig,
|
|
23
22
|
type TeamMember,
|
|
24
23
|
} from "../shared/types.ts";
|
|
@@ -471,13 +470,12 @@ function overrideFieldExpected(field: string): string | undefined {
|
|
|
471
470
|
if (["description", "model", "preset", "output", "systemPrompt"].includes(field)) return "a string";
|
|
472
471
|
if (field === "thinking") return "a string or false";
|
|
473
472
|
if (field === "kind") return `one of: ${AGENT_KINDS.join(", ")}`;
|
|
474
|
-
if (field === "spawnMode") return `one of: ${SPAWN_MODES.join(", ")}`;
|
|
475
473
|
if (field === "onBlocked") return "a valid onBlocked policy";
|
|
476
474
|
if (field === "systemPromptMode") return "replace or append";
|
|
477
|
-
if (["inheritProjectContext", "inheritSkills", "inheritExtensions", "defaultProgress", "async", "completionGuard", "allowNestedSubagents", "utility", "disabled", "worktree", "steer"].includes(field)) return "a boolean";
|
|
478
|
-
if (["timeoutMs", "toolTimeoutMs", "maxSubagentDepth"].includes(field)) return "a number";
|
|
475
|
+
if (["inheritProjectContext", "inheritSkills", "inheritExtensions", "defaultProgress", "async", "completionGuard", "allowNestedSubagents", "utility", "disabled", "worktree", "steer", "renewable"].includes(field)) return "a boolean";
|
|
476
|
+
if (["timeoutMs", "toolTimeoutMs", "maxSubagentDepth", "maxRenewals"].includes(field)) return "a number";
|
|
479
477
|
if (field === "skills") return "an array of strings or false";
|
|
480
|
-
if (["tools", "extensions", "subagentOnlyExtensions", "skillPath", "defaultReads", "fallbackModels", "alias"].includes(field)) return "an array of strings";
|
|
478
|
+
if (["tools", "extensions", "subagentOnlyExtensions", "excludeExtensions", "skillPath", "defaultReads", "fallbackModels", "alias"].includes(field)) return "an array of strings";
|
|
481
479
|
return undefined;
|
|
482
480
|
}
|
|
483
481
|
|
|
@@ -485,13 +483,12 @@ function isValidOverrideField(field: string, value: unknown): boolean {
|
|
|
485
483
|
if (["description", "model", "preset", "output", "systemPrompt"].includes(field)) return typeof value === "string";
|
|
486
484
|
if (field === "thinking") return typeof value === "string" || value === false;
|
|
487
485
|
if (field === "kind") return typeof value === "string" && (AGENT_KINDS as readonly string[]).includes(value);
|
|
488
|
-
if (field === "spawnMode") return typeof value === "string" && (SPAWN_MODES as readonly string[]).includes(value);
|
|
489
486
|
if (field === "onBlocked") return typeof value === "string" && VALID_ON_BLOCKED_POLICIES.has(value);
|
|
490
487
|
if (field === "systemPromptMode") return value === "replace" || value === "append";
|
|
491
|
-
if (["inheritProjectContext", "inheritSkills", "inheritExtensions", "defaultProgress", "async", "completionGuard", "allowNestedSubagents", "utility", "disabled", "worktree", "steer"].includes(field)) return typeof value === "boolean";
|
|
492
|
-
if (["timeoutMs", "toolTimeoutMs", "maxSubagentDepth"].includes(field)) return typeof value === "number";
|
|
488
|
+
if (["inheritProjectContext", "inheritSkills", "inheritExtensions", "defaultProgress", "async", "completionGuard", "allowNestedSubagents", "utility", "disabled", "worktree", "steer", "renewable"].includes(field)) return typeof value === "boolean";
|
|
489
|
+
if (["timeoutMs", "toolTimeoutMs", "maxSubagentDepth", "maxRenewals"].includes(field)) return typeof value === "number";
|
|
493
490
|
if (field === "skills") return value === false || isStringArray(value);
|
|
494
|
-
if (["tools", "extensions", "subagentOnlyExtensions", "skillPath", "defaultReads", "fallbackModels", "alias"].includes(field)) return isStringArray(value);
|
|
491
|
+
if (["tools", "extensions", "subagentOnlyExtensions", "excludeExtensions", "skillPath", "defaultReads", "fallbackModels", "alias"].includes(field)) return isStringArray(value);
|
|
495
492
|
return true;
|
|
496
493
|
}
|
|
497
494
|
|
|
@@ -635,15 +632,6 @@ function parseAgentOverrides(
|
|
|
635
632
|
`Subagent settings in '${filePath}' have invalid 'agentOverrides.${name}.utility'; expected a boolean.`,
|
|
636
633
|
);
|
|
637
634
|
}
|
|
638
|
-
if (
|
|
639
|
-
normalized.spawnMode !== undefined &&
|
|
640
|
-
(typeof normalized.spawnMode !== "string" ||
|
|
641
|
-
!(SPAWN_MODES as readonly string[]).includes(normalized.spawnMode))
|
|
642
|
-
) {
|
|
643
|
-
throw new Error(
|
|
644
|
-
`Subagent settings in '${filePath}' have invalid 'agentOverrides.${name}.spawnMode'; expected one of: ${SPAWN_MODES.join(", ")}.`,
|
|
645
|
-
);
|
|
646
|
-
}
|
|
647
635
|
parsed[name] = normalized as AgentOverride;
|
|
648
636
|
}
|
|
649
637
|
return parsed;
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
budgetExceededReason,
|
|
18
18
|
childBudgetFromEnv,
|
|
19
19
|
wrapBashWithTimeout,
|
|
20
|
+
type ChildBudget,
|
|
20
21
|
} from "./budget.ts";
|
|
21
22
|
import { shellChunks } from "./playbook.ts";
|
|
22
23
|
|
|
@@ -349,9 +350,32 @@ export function effectiveChildGuardEnv(
|
|
|
349
350
|
return childGuardEnvFromProcess(env);
|
|
350
351
|
}
|
|
351
352
|
|
|
353
|
+
/**
|
|
354
|
+
* The budget this child session enforces (#295).
|
|
355
|
+
*
|
|
356
|
+
* Same precedence as {@link effectiveChildGuardEnv}: the async-local child
|
|
357
|
+
* context wins, `process.env` is the fallback for rpc children (whose process
|
|
358
|
+
* really owns those values).
|
|
359
|
+
*
|
|
360
|
+
* Why the context is needed at all: the parent computes budgets into
|
|
361
|
+
* `SpawnInput.env`. An rpc child receives that object as its environment, so
|
|
362
|
+
* `process.env` has it. An IN-PROCESS child shares the host's `process.env`
|
|
363
|
+
* instead — the object never becomes environment — so reading only the process
|
|
364
|
+
* gave the guard the parent's (absent) values and `toolBudget: 3` permitted 12
|
|
365
|
+
* tool calls.
|
|
366
|
+
*/
|
|
367
|
+
export function effectiveChildBudget(
|
|
368
|
+
ctx: ChildLaunchContext | undefined = currentChildContext(),
|
|
369
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
370
|
+
): ChildBudget {
|
|
371
|
+
const fromContext = ctx?.budget;
|
|
372
|
+
if (fromContext !== undefined) return fromContext;
|
|
373
|
+
return childBudgetFromEnv(env);
|
|
374
|
+
}
|
|
375
|
+
|
|
352
376
|
/** Child-only extension: intercept tools; do not register the parent tool. */
|
|
353
377
|
export function registerChildGuard(pi: ExtensionAPI): void {
|
|
354
|
-
const budget =
|
|
378
|
+
const budget = effectiveChildBudget();
|
|
355
379
|
let toolCalls = 0;
|
|
356
380
|
let turns = 0;
|
|
357
381
|
pi.on("turn_start", () => { turns += 1; });
|