@try-works/dsh-recursive-mode 0.3.1 → 0.4.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 (117) hide show
  1. package/README.md +959 -0
  2. package/lib/client.js +9 -2
  3. package/lib/closeout-report.d.ts +113 -0
  4. package/lib/closeout-standards.d.ts +35 -0
  5. package/lib/closeout.d.ts +12 -0
  6. package/lib/commands.d.ts +1 -1
  7. package/lib/config.d.ts +202 -0
  8. package/lib/delegation.d.ts +123 -3
  9. package/lib/enforcement.d.ts +90 -1
  10. package/lib/errors.d.ts +168 -0
  11. package/lib/git-context.d.ts +17 -0
  12. package/lib/guard-log.d.ts +39 -0
  13. package/lib/handoff.d.ts +29 -0
  14. package/lib/hooks.d.ts +103 -0
  15. package/lib/identity.d.ts +61 -0
  16. package/lib/index.d.ts +32 -12
  17. package/lib/index.js +9819 -3858
  18. package/lib/job-log.d.ts +34 -0
  19. package/lib/jobs-runner.d.ts +105 -0
  20. package/lib/json-safe.d.ts +33 -0
  21. package/lib/lock.d.ts +42 -0
  22. package/lib/memory-feedback.d.ts +52 -0
  23. package/lib/memory-select.d.ts +78 -0
  24. package/lib/memory.d.ts +137 -0
  25. package/lib/model-inventory.d.ts +106 -0
  26. package/lib/phase-graph.d.ts +111 -0
  27. package/lib/phase-rules.d.ts +67 -8
  28. package/lib/plan-gate.d.ts +68 -0
  29. package/lib/policy-globs.d.ts +222 -0
  30. package/lib/policy-write.d.ts +42 -0
  31. package/lib/policy.d.ts +39 -0
  32. package/lib/recursive_ask.tool.d.ts +88 -0
  33. package/lib/recursive_closeout.tool.d.ts +1 -1
  34. package/lib/recursive_delegate.tool.d.ts +22 -0
  35. package/lib/recursive_preview.tool.d.ts +48 -0
  36. package/lib/recursive_review.tool.d.ts +28 -0
  37. package/lib/result-cap.d.ts +70 -0
  38. package/lib/review-round.d.ts +82 -0
  39. package/lib/review.d.ts +9 -0
  40. package/lib/role-route.d.ts +122 -0
  41. package/lib/router.d.ts +90 -5
  42. package/lib/runtime.d.ts +252 -12
  43. package/lib/settlement.d.ts +132 -0
  44. package/lib/skills-phase.d.ts +71 -0
  45. package/lib/status.d.ts +53 -1
  46. package/lib/teams-loop.d.ts +91 -2
  47. package/lib/training.d.ts +211 -0
  48. package/lib/ts-lint.d.ts +15 -0
  49. package/lib/types.d.ts +48 -0
  50. package/lib/workflow-audit.d.ts +207 -0
  51. package/package.json +29 -30
  52. package/preset/recursive.patch.yml +312 -0
  53. package/scripts/e2e-run.mjs +51 -0
  54. package/scripts/link-dsh.mjs +233 -0
  55. package/scripts/live/fake-llm.mjs +150 -0
  56. package/scripts/live-session-plugin.mjs +179 -0
  57. package/scripts/live-session-stock.mjs +106 -0
  58. package/scripts/live-session.mjs +139 -0
  59. package/src/client/derive.ts +18 -2
  60. package/src/closeout-report.ts +274 -0
  61. package/src/closeout-standards.ts +102 -0
  62. package/src/closeout.ts +39 -2
  63. package/src/commands.ts +116 -4
  64. package/src/config.ts +113 -0
  65. package/src/delegation.ts +336 -18
  66. package/src/enforcement.ts +262 -72
  67. package/src/errors.ts +197 -0
  68. package/src/git-context.ts +33 -2
  69. package/src/guard-log.ts +134 -0
  70. package/src/handoff.ts +62 -0
  71. package/src/hooks.ts +316 -0
  72. package/src/identity.ts +230 -0
  73. package/src/index.ts +385 -20
  74. package/src/job-log.ts +112 -0
  75. package/src/jobs-runner.ts +222 -0
  76. package/src/json-safe.ts +75 -0
  77. package/src/lock.ts +153 -16
  78. package/src/memory-feedback.ts +185 -0
  79. package/src/memory-select.ts +187 -0
  80. package/src/memory.ts +309 -0
  81. package/src/model-inventory.ts +196 -0
  82. package/src/phase-graph.ts +191 -0
  83. package/src/phase-rules.ts +236 -0
  84. package/src/plan-gate.ts +111 -0
  85. package/src/policy-globs.ts +636 -0
  86. package/src/policy-write.ts +210 -0
  87. package/src/policy.ts +70 -5
  88. package/src/recursive_ask.tool.ts +276 -0
  89. package/src/recursive_audit_team.tool.ts +7 -3
  90. package/src/recursive_closeout.tool.ts +36 -35
  91. package/src/recursive_delegate.tool.ts +194 -0
  92. package/src/recursive_init.tool.ts +4 -3
  93. package/src/recursive_lint.tool.ts +81 -6
  94. package/src/recursive_lock.tool.ts +21 -4
  95. package/src/recursive_phase.tool.ts +3 -2
  96. package/src/recursive_preview.tool.ts +142 -0
  97. package/src/recursive_review.tool.ts +190 -0
  98. package/src/recursive_scratch.tool.ts +5 -4
  99. package/src/recursive_status.tool.ts +3 -2
  100. package/src/recursive_worktree.tool.ts +6 -5
  101. package/src/result-cap.ts +130 -0
  102. package/src/review-round.ts +335 -0
  103. package/src/review.ts +17 -3
  104. package/src/role-route.ts +230 -0
  105. package/src/router.ts +128 -2
  106. package/src/runtime.ts +968 -39
  107. package/src/settlement.ts +355 -0
  108. package/src/skills-phase.ts +143 -0
  109. package/src/snapshot.ts +39 -8
  110. package/src/status.ts +209 -4
  111. package/src/teams-loop.ts +223 -9
  112. package/src/training.ts +565 -0
  113. package/src/ts-lint.ts +38 -4
  114. package/src/types.ts +51 -0
  115. package/src/workflow-audit.ts +288 -0
  116. package/scripts/install-preset.cmd +0 -7
  117. package/scripts/install-preset.js +0 -101
@@ -0,0 +1,22 @@
1
+ import type { RecursiveRuntime } from './runtime.ts';
2
+ import type { SubagentsRuntimeLike } from './delegation.ts';
3
+ /**
4
+ * ⚠ FU-17 — THE WORK TOOL FILTER, AND WHY IT IS A DENY LIST RATHER THAN AN ALLOW LIST.
5
+ *
6
+ * `delegateReview` applies `input.toolFilter ?? defaultReviewToolFilter()` and that default is
7
+ * `{ allow: ['fs_read', 'grep', 'glob'] }`. So a work delegation that passes nothing receives a child that
8
+ * cannot write — and nothing about the outcome would say so.
9
+ *
10
+ * The obvious fix is an allow list of the writing tools, and it is the wrong one: it has to name EVERY tool a
11
+ * worker might need (`bash`, `pwsh`, `str_replace_editor`, `session_search`, `session_event_read`, `skill`,
12
+ * `load_workspace_dependencies`, `present`, …) and it SILENTLY REMOVES capability for each name forgotten.
13
+ * That is the same defect class this project has already fixed ten times: a surface that cannot express what
14
+ * the system can do, failing quietly.
15
+ *
16
+ * `deny` states the actual posture instead: a delegated worker may use everything EXCEPT spawning its own
17
+ * children and driving the team board. That is what the workflow wants — no recursive fan-out from a worker and
18
+ * no contention over the audit board — and it cannot lose a tool by omission. The host accepts `allow` and/or
19
+ * `deny` (`subagent/src/descriptor.ts`), throwing only when neither is declared.
20
+ */
21
+ export declare const WORK_TOOL_FILTER: unknown;
22
+ export declare function createRecursiveDelegateTool(recursive: RecursiveRuntime, subagents?: SubagentsRuntimeLike): import("@deepseek-ai/dsh-tools").ToolDefinition;
@@ -0,0 +1,48 @@
1
+ import type { EnforcementConfig } from './enforcement.ts';
2
+ import type { RecursiveRuntime } from './runtime.ts';
3
+ /** A tool call the caller wants to preview the guard's decision for. */
4
+ export interface PreviewProbe {
5
+ name: string;
6
+ arguments?: Record<string, unknown>;
7
+ }
8
+ export interface PreviewResult {
9
+ /** T22: the byte-identical prefix, the per-phase tail, and the local identifier. */
10
+ policy: {
11
+ stable: string;
12
+ tail: string;
13
+ digest: string;
14
+ };
15
+ /** What the phase being worked on requires, from the same rules the linter enforces. */
16
+ phase: {
17
+ file: string;
18
+ requiredSections: string[];
19
+ audited: boolean;
20
+ tdd: boolean;
21
+ qa: boolean;
22
+ } | null;
23
+ /** The next legal transition, or null when nothing is pending. */
24
+ next: {
25
+ phase: string;
26
+ requiredSections: string[];
27
+ } | null;
28
+ /** What the guard WOULD decide for a probe call — the rule that would fire, by name. */
29
+ probe: {
30
+ kind: string;
31
+ rule: string;
32
+ reason?: string;
33
+ } | null;
34
+ }
35
+ /**
36
+ * Build the preview. Pure apart from the run-directory reads it is given.
37
+ *
38
+ * `next` is `null` for a completed run rather than a fabricated phase: "nothing is pending" is a fact
39
+ * a reader needs, and inventing the last phase as "next" would be the opposite of a preview.
40
+ */
41
+ export declare function buildPreview(input: {
42
+ root: string;
43
+ runId: string;
44
+ config: EnforcementConfig;
45
+ probe?: PreviewProbe;
46
+ }): PreviewResult;
47
+ /** `recursive_preview` — the read-only view, registered as a tool so it is one call away. */
48
+ export declare function createRecursivePreviewTool(recursive: RecursiveRuntime): import("@deepseek-ai/dsh-tools").ToolDefinition;
@@ -0,0 +1,28 @@
1
+ import type { RecursiveRuntime } from './runtime.ts';
2
+ import type { ContinuableDelegationLike, SubagentsRuntimeLike } from './delegation.ts';
3
+ export declare function createRecursiveReviewTool(recursive: RecursiveRuntime, subagents?: SubagentsRuntimeLike): import("@deepseek-ai/dsh-tools").ToolDefinition;
4
+ /** The child's reply file, or '' when it has not written one (an empty reply is not an approval). */
5
+ export declare function readReplyText(root: string, runId: string, delegationId: string, childId: string): string;
6
+ /**
7
+ * Project `delegateReview`'s result onto the loop shape the driver reads.
8
+ *
9
+ * The mapping is where the honest failure modes live: no continuable result at all
10
+ * (self-audit, or the delegation never ran) becomes `fellBackToOneShot`, which the
11
+ * driver reports as `unavailable` — the review happened without the repair path, and
12
+ * saying so beats reporting a success that cannot be acted on.
13
+ */
14
+ export declare function toContinuable(review: {
15
+ continuable: {
16
+ rounds: unknown[];
17
+ childId?: string;
18
+ fellBackToOneShot?: boolean;
19
+ parked?: boolean;
20
+ ok?: boolean;
21
+ reason?: string;
22
+ } | null;
23
+ evaluation?: {
24
+ accepted?: boolean;
25
+ };
26
+ error?: string | null;
27
+ parked?: boolean;
28
+ }): ContinuableDelegationLike;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * T24 — bounded tool results.
3
+ *
4
+ * A linter over a 126 KB port can emit hundreds of findings, and `recursive_lint`
5
+ * returned `errors[]`/`warnings[]` unbounded, so a single tool result could grow
6
+ * without limit. Every result this plugin returns must be BOUNDED, and when a
7
+ * bound bites it must say so in a way the reader can act on:
8
+ *
9
+ * - WHAT was removed (which list, how many),
10
+ * - what is SHOWN, and
11
+ * - HOW to see the rest.
12
+ *
13
+ * A silent truncation is worse than no cap at all: the reader cannot tell a
14
+ * complete result from a clipped one. Hence {@link ElisionMeta} accompanies every
15
+ * truncation, and the caller surfaces it next to the findings.
16
+ *
17
+ * The "how to see the rest" route is deliberately ITERATIVE rather than a
18
+ * bypass argument: the cap is a hard bound, so telling the caller to pass
19
+ * `mode: 'full'` when `mode: 'full'` is already the capped mode would be a lie.
20
+ * Fixing the shown findings and re-running genuinely reveals the next batch,
21
+ * which is also the order the workflow wants them fixed in.
22
+ */
23
+ /** Findings kept by `mode: 'full'`. */
24
+ export declare const MAX_FINDINGS_FULL = 200;
25
+ /** Findings kept by `mode: 'summary'` — enough to orient, not enough to drown a turn. */
26
+ export declare const MAX_FINDINGS_SUMMARY = 10;
27
+ export interface ElisionMeta {
28
+ /** Which list was clipped. */
29
+ kind: 'errors' | 'warnings';
30
+ /** Findings the source produced. */
31
+ total: number;
32
+ /** Findings actually returned. */
33
+ shown: number;
34
+ /** `total - shown`; stated so a consumer need not compute it. */
35
+ omitted: number;
36
+ /** One self-sufficient sentence: what was removed and how to see it. */
37
+ hint: string;
38
+ }
39
+ export interface ElideResult {
40
+ kept: string[];
41
+ /** Present only when the list was clipped. */
42
+ meta: ElisionMeta | null;
43
+ }
44
+ /** The payload shape the byte budget knows how to trim. */
45
+ export interface CappedPayload {
46
+ errors: string[];
47
+ warnings: string[];
48
+ elided: ElisionMeta[];
49
+ }
50
+ /** Serialized size of a payload, measured the way the byte budget is enforced. */
51
+ export declare function payloadBytes(value: unknown): number;
52
+ /**
53
+ * T28 — cap a payload by BYTES, which T24's count cap cannot do.
54
+ *
55
+ * `MAX_FINDINGS_FULL` bounds HOW MANY findings come back; it cannot see SIZE, so a
56
+ * handful of enormous findings still passes. This trims the longer list repeatedly
57
+ * until the payload fits, and reports every trim through the same `ElisionMeta` T24
58
+ * established — a silent truncation is worse than no cap, because the reader cannot
59
+ * tell a complete result from a clipped one.
60
+ *
61
+ * Terminates: each pass at least halves the longer list, and an empty pair of lists
62
+ * ends the loop. A payload that still exceeds the budget once both lists are empty is
63
+ * returned as-is — the alternative would be deleting fields the caller needs.
64
+ */
65
+ export declare function capPayloadBytes<T extends CappedPayload>(payload: T, maxBytes: number): T;
66
+ /**
67
+ * Keep at most `max` findings. Clipping is reported, never silent.
68
+ * A `max` of 0 or less is treated as "keep nothing" but still reports honestly.
69
+ */
70
+ export declare function elideFindings(kind: 'errors' | 'warnings', findings: readonly string[], max: number): ElideResult;
@@ -0,0 +1,82 @@
1
+ import { type ContinuableDelegationLike, type DelegationVerdict } from './delegation.ts';
2
+ /** The durable record of an in-flight review, so the next turn can resume it. */
3
+ export interface ReviewState {
4
+ delegationId: string;
5
+ /** The durable child carrying this review, stable across turns. */
6
+ childId: string;
7
+ phase: string;
8
+ role: string;
9
+ /** How many rounds the loop has observed so far. */
10
+ rounds: number;
11
+ startedAt: string;
12
+ lastVerdict?: DelegationVerdict;
13
+ /**
14
+ * ⚠ FU-17 — whether this round carries a VERDICT (`review`) or a DELIVERABLE (`work`). Optional and defaulted
15
+ * on read, so a state file written before this field existed still means what it always meant.
16
+ */
17
+ kind?: 'review' | 'work';
18
+ }
19
+ export interface AdvanceReviewOutcome {
20
+ status: 'reviewing' | 'revised' | 'approved' | 'rejected' | 'unavailable' | 'submitted';
21
+ childId?: string;
22
+ verdict?: DelegationVerdict;
23
+ rounds: number;
24
+ /** One sentence for the agent, saying what to do next. */
25
+ message: string;
26
+ }
27
+ /** Where one delegation's review state lives (beside its handoff and replies). */
28
+ export declare function reviewStatePath(runDir: string, delegationId: string): string;
29
+ /** Read an in-flight review, or null when there is none (or it is unreadable). */
30
+ export declare function readReviewState(runDir: string, delegationId: string): ReviewState | null;
31
+ export declare function writeReviewState(runDir: string, state: ReviewState): string;
32
+ /** Forget a finished review. A finished review must not be resumable by accident. */
33
+ export declare function clearReviewState(runDir: string, delegationId: string): void;
34
+ /**
35
+ * Advance one review by exactly one turn.
36
+ *
37
+ * `delegate` is injected rather than called directly so this driver can be tested
38
+ * against a scripted loop, and so the caller owns how the review is dispatched
39
+ * (which provider, which bundle, which parent). It receives `resumeChild` when a
40
+ * child is already carrying the review — a resumed round must NEVER re-establish
41
+ * the child, which would orphan the one already doing the work.
42
+ */
43
+ export declare function advanceReview(input: {
44
+ runDir: string;
45
+ delegationId: string;
46
+ phase: string;
47
+ role: string;
48
+ /** Dispatch one turn of the continuable loop, resuming `resumeChild` when given. */
49
+ delegate: (args: {
50
+ resumeChild?: string;
51
+ }) => Promise<ContinuableDelegationLike>;
52
+ /** The child's reply text for the settled round, or '' when it has not written one. */
53
+ readReply: (childId: string) => string;
54
+ /**
55
+ * Deliver a repair instruction to the child.
56
+ *
57
+ * NEEDED BECAUSE THE LOOP CAN STOP EARLY ON A MISREAD. The loop's own verdict
58
+ * reader falls back to APPROVE when a result carries no structured verdict, so a
59
+ * child that answered in prose can make the loop believe it was approved and
60
+ * END — leaving no repair sent and the child idle. This driver re-reads the reply
61
+ * fail-closed and disagrees, so it must be able to send the repair itself;
62
+ * otherwise the round would sit in `revised` forever with nothing ever asking the
63
+ * child to fix anything.
64
+ */
65
+ sendRepair?: (childId: string, instruction: string) => Promise<void>;
66
+ now?: () => string;
67
+ /**
68
+ * ⚠ FU-17 — WHICH KIND OF ROUND THIS IS, and it defaults to `'review'`.
69
+ *
70
+ * The kind is a PARAMETER rather than a forked code path, so every existing caller and spec keeps the exact
71
+ * behaviour it had — that is what makes "review is unchanged" a proof instead of a hope. A `'work'` round is
72
+ * the same round: same child, same state file, same park-and-resume mechanics, same repair delivery. The one
73
+ * difference is what a settlement MEANS: a work round's settlement is a DELIVERABLE, so the driver must not
74
+ * read an APPROVE out of prose that was never a verdict.
75
+ */
76
+ kind?: 'review' | 'work';
77
+ /**
78
+ * The main agent's feedback for a `'work'` round — delivered to the SAME child, which then repairs with its
79
+ * working set intact. Ignored for reviews, whose repair text is read from the reviewer's own reply.
80
+ */
81
+ instruction?: string;
82
+ }): Promise<AdvanceReviewOutcome>;
package/lib/review.d.ts CHANGED
@@ -19,6 +19,15 @@ export interface ReviewBundleInput {
19
19
  addenda?: string[];
20
20
  priorEvidence?: string[];
21
21
  memoryRefs?: string[];
22
+ /**
23
+ * T14 — the RETRIEVED prior-run memory, rendered, written into the bundle body.
24
+ *
25
+ * ⚠ `memoryRefs` above is the slot the bundle already had, and nothing ever filled it — so the
26
+ * memory section a reviewer was supposed to see did not exist. The two are complementary, not
27
+ * duplicates: `memoryRefs` is WHERE the memory lives (traceability), and this is the CONTENT,
28
+ * because a reviewer cannot cite a path whose contents it was never given.
29
+ */
30
+ memory?: string;
22
31
  changedFiles?: string[];
23
32
  }
24
33
  export interface ReviewBundleResult {
@@ -0,0 +1,122 @@
1
+ /**
2
+ * T9 → FU-19 — per-role model routing, and now a full provider/model ladder.
3
+ *
4
+ * WHY. A delegation would otherwise run on whatever model the child inherits: the reviewer that must be rigorous
5
+ * and the repairer that will iterate many times become the same choice by accident. The item asks for them to
6
+ * differ — and FU-19 extends that to a general default, per-phase overrides, and a per-call choice.
7
+ *
8
+ * ⚠ THE PARAGRAPH THAT USED TO BE HERE WAS TRUE WHEN WRITTEN AND IS NOW FALSE, so it is replaced rather than
9
+ * left to mislead. It said the plugin "cannot by itself force a child onto a model", because
10
+ * `SubagentStartRequestLike` had no model field and model selection was the host's concern. Both halves changed:
11
+ * the harness accepts `agentOptions` on a start, and `delegateReview` now sets `request.agentOptions = { model }`
12
+ * — but ONLY for a provider that declares the `agentOptions` capability, because the harness REJECTS such a start
13
+ * otherwise.
14
+ *
15
+ * SO WHAT THIS MODULE PROMISES TODAY, precisely:
16
+ * - it resolves WHICH provider and model a child should get, from the ladder in `resolveSubagentTarget`, and it
17
+ * reports which level chose each value;
18
+ * - the caller (not this module) applies them, because applying is a decision with consequences — a model on a
19
+ * provider that cannot take overrides is NOT applied and is REPORTED rather than silently dropped;
20
+ * - and the model is checked against what DSH actually has (`model-inventory.ts`), with an `unverified` verdict
21
+ * when there is no inventory to ask, so a choice is never silently approved OR silently replaced.
22
+ *
23
+ * A module that claimed more than that would be lying about where the choice is made. The comment above is the
24
+ * second half of that lesson: a stale rationale is how a working feature gets deleted by accident.
25
+ */
26
+ import type { RouterPolicy } from './router.ts';
27
+ /** What kind of work a role does — the distinction the item is about. */
28
+ export type RoleKind = 'review' | 'repair' | 'unknown';
29
+ export declare function roleKindOf(role: string): RoleKind;
30
+ /** One role's resolved route: what kind of work it is, and which model the policy names. */
31
+ export interface RoleRoute {
32
+ role: string;
33
+ kind: RoleKind;
34
+ /** The policy's model for this role, or null when it names none. */
35
+ model: string | null;
36
+ /** Where the model came from — `unset` is a fact worth carrying, not an absence. */
37
+ source: 'policy' | 'unset';
38
+ /** One self-sufficient sentence, including the unknown-role case. */
39
+ reason: string;
40
+ }
41
+ /**
42
+ * Resolve a role's route from the policy.
43
+ *
44
+ * Never throws and never invents a model: an unknown role, an unconfigured role and a role whose
45
+ * policy entry names no model each produce a route with a reason that says which it is. A caller
46
+ * that wants to refuse an unknown role can; a caller that wants to proceed knows exactly what it
47
+ * is proceeding with.
48
+ */
49
+ export declare function routeForRole(role: string, policy: RouterPolicy): RoleRoute;
50
+ /**
51
+ * The model the caller should use for a role, or null to inherit.
52
+ *
53
+ * A one-line convenience for a caller that only needs the value; {@link routeForRole} is what a
54
+ * caller should use when it wants to SAY why.
55
+ */
56
+ export declare function modelForRole(role: string, policy: RouterPolicy): string | null;
57
+ /**
58
+ * ⚠ FU-19 — WHICH PROVIDER AND MODEL A DELEGATED CHILD WOULD ACTUALLY GET, AND WHO CHOSE EACH.
59
+ *
60
+ * ## The words, because they were doing too much work
61
+ *
62
+ * "Provider" is the thing that CREATES a child. In this harness it is resolved by a tier ladder, and the tiers
63
+ * have names that are not self-explanatory:
64
+ *
65
+ * - **native** — a provider the harness runs ITSELF, in-process. It is what `ctx.subagents` serves; in the
66
+ * session I measured it advertised exactly two: `spawn` and `fork`. "Native" means "the harness's own",
67
+ * as opposed to something it shells out to.
68
+ * - **external-cli** — a registered provider that drives a SEPARATE installed program (Codex, Claude Code and
69
+ * friends). A child still exists, but the work happens in another process the user installed.
70
+ * - **self-audit** — no child is created at all: the main agent reviews its own work. This is the honest
71
+ * fallback, and it is NAMED rather than hidden.
72
+ * - **local-controller** — the host's own controller. I have not measured this tier's behaviour in a live
73
+ * session, so I will not describe it further here.
74
+ *
75
+ * ## Why "a provider but no model" — the part my earlier wording got wrong
76
+ *
77
+ * A MODEL CAN BE LEFT UNSET ON PURPOSE, AND THAT IS NOT THE SAME AS "NO MODEL". Absent means **inherited**: the
78
+ * plugin sends no `agentOptions.model` at all, and the child runs on whatever the session/provider default is.
79
+ * That is the measured behaviour — `delegation.ts` forwards `agentOptions` only when the caller supplies them,
80
+ * and `modelForRole` returns null precisely to mean "inherit".
81
+ *
82
+ * So the floor of the ladder is not a gap. A child cannot exist without a provider, so a provider is always
83
+ * resolved (by the ladder, or by the user); a model is only sent when someone actually chose one, because
84
+ * inventing one would silently override the session's own setting — and overriding a user's session default
85
+ * without being asked is worse than inheriting it.
86
+ *
87
+ * ## The precedence, and the labels it reports
88
+ *
89
+ * per-call override → phase route → role route → general default → inherit
90
+ *
91
+ * Every value carries the label of the level that produced it, so "why did this child run on that model?" is
92
+ * answerable from the decision alone rather than by reading this function.
93
+ */
94
+ export interface SubagentTarget {
95
+ role: string;
96
+ /** The provider that would create the child, or null when nothing resolved one. */
97
+ provider: string | null;
98
+ /** The model to ask for, or null meaning INHERIT — do not send `agentOptions.model` at all. */
99
+ model: string | null;
100
+ /** Plain-language provenance for each value: which level chose it. */
101
+ chosen: {
102
+ provider: string;
103
+ model: string;
104
+ };
105
+ /** One sentence a reader can act on. */
106
+ reason: string;
107
+ }
108
+ /** Which level produced a value. `inherit` is a decision, not an absence. */
109
+ export type ChoiceSource = 'per-call' | 'phase' | 'role' | 'general' | 'ladder' | 'inherit';
110
+ export declare function resolveSubagentTarget(input: {
111
+ role: string;
112
+ /** The phase the delegation is for, when it is known — the narrowest configured level. */
113
+ phase?: string;
114
+ policy: RouterPolicy;
115
+ /** A per-call override from the tool: wins over every configured level. */
116
+ override?: {
117
+ provider?: string | null;
118
+ model?: string | null;
119
+ };
120
+ /** What the tier ladder resolved. Used when no level named a provider, and labelled as the ladder's. */
121
+ ladderProvider?: string | null;
122
+ }): SubagentTarget;
package/lib/router.d.ts CHANGED
@@ -1,3 +1,33 @@
1
+ /**
2
+ * ⚠ FU-19 — WHAT A CHILD RUNS ON, WHEN THE USER SAYS SO AND NOTHING MORE SPECIFIC DOES.
3
+ *
4
+ * Optional on purpose, and the reason is the same one the override layer gives below: an absent field must mean
5
+ * "defer to the next level in the precedence", never "reset to nothing". A default here would make every field
6
+ * present and silently shadow the role routes and phase routes forever.
7
+ */
8
+ export interface SubagentDefault {
9
+ /** The SUBAGENT provider — who creates the child (`spawn`, `fork`). NOT the LLM provider; see below. */
10
+ provider?: string | null;
11
+ /** The model id to ask for. */
12
+ model?: string | null;
13
+ /**
14
+ * ⚠ THE LLM PROVIDER — WHO SERVES THE MODEL (`deepseek-official`), which is a DIFFERENT thing from the
15
+ * `provider` field above. Conflating the two was a real defect in this feature's first schema: a plain
16
+ * `provider` could mean either, and a user setting it had no way to know which.
17
+ *
18
+ * Optional: when only a model is named, the plugin looks its provider up from the inventory DSH exposes.
19
+ */
20
+ modelProvider?: string | null;
21
+ }
22
+ /** Per-phase overrides — the narrowest level, and the one that answers "this phase needs a stronger model". */
23
+ export interface PhaseRoute {
24
+ role?: string;
25
+ /** The subagent provider, as above. */
26
+ provider?: string | null;
27
+ model?: string | null;
28
+ /** The LLM provider serving `model`, as above. */
29
+ modelProvider?: string | null;
30
+ }
1
31
  export interface RouterDefaults {
2
32
  when_role_unconfigured: string;
3
33
  when_cli_unavailable: string;
@@ -5,18 +35,32 @@ export interface RouterDefaults {
5
35
  allow_auto_assign_if_single_cli: boolean;
6
36
  probe_timeout_ms: number;
7
37
  invoke_timeout_ms: number;
38
+ /** FU-19: the general provider/model for delegated subagents, absent when the user has not chosen one. */
39
+ subagent?: SubagentDefault;
8
40
  }
9
41
  export interface RoleRoute {
10
42
  enabled: boolean;
11
43
  mode: string;
12
44
  cli: string | null;
13
45
  model: string | null;
46
+ /**
47
+ * FU-19: the provider this role should be served by, when the user has chosen one. Absent means "let the tier
48
+ * ladder decide", which is what every policy scaffolded before this field did.
49
+ *
50
+ * This is the SUBAGENT provider — who creates the child. The LLM provider that serves the model is its own
51
+ * field below, because one name for two ideas is how a configuration becomes a guess.
52
+ */
53
+ provider?: string | null;
54
+ /** FU-19: the LLM provider serving this role's `model`. Optional; looked up from the inventory when omitted. */
55
+ modelProvider?: string | null;
14
56
  fallback: string;
15
57
  }
16
58
  export interface RouterPolicy {
17
59
  version: number;
18
60
  defaults: RouterDefaults;
19
61
  role_routes: Record<string, RoleRoute>;
62
+ /** FU-19: phase-keyed overrides, e.g. `{ '08': { role: 'memory-auditor' } }`. Absent means none. */
63
+ phase_routes?: Record<string, PhaseRoute>;
20
64
  cli_overrides: Record<string, unknown>;
21
65
  custom_clis: unknown[];
22
66
  }
@@ -25,6 +69,25 @@ export interface RouteDecision {
25
69
  tier: RouteTier;
26
70
  provider?: string;
27
71
  reason: string;
72
+ /**
73
+ * T9: the model the policy names for this role, or null when it names none.
74
+ *
75
+ * Present on EVERY decision because it is attached by the wrapper, never per-return-site.
76
+ *
77
+ * ⚠ THIS COMMENT USED TO SAY THE PLUGIN DOES NOT APPLY IT. That was true when it was written and is false now:
78
+ * `delegateReview` sets `request.agentOptions = { model }` when the provider declares the `agentOptions`
79
+ * capability, and reports a routing note when it does not. So this is still a value a caller can HONOUR, but
80
+ * the plugin now does the honouring — and says so when it cannot.
81
+ */
82
+ model?: string | null;
83
+ /**
84
+ * FU-19: where the provider and model came from, one short label per value, so "why did this child run on
85
+ * that model" is answerable without reading the resolution code.
86
+ */
87
+ chosen?: {
88
+ provider?: string;
89
+ model?: string;
90
+ };
28
91
  }
29
92
  export interface SubagentProviderLike {
30
93
  name: string;
@@ -33,6 +96,12 @@ export interface SubagentProviderLike {
33
96
  depthLimit?: boolean;
34
97
  toolFilter?: boolean;
35
98
  persona?: boolean;
99
+ /**
100
+ * T9: whether this provider accepts `agentOptions` (provider/model/reasoning-effort
101
+ * overrides). The harness REJECTS a start that sends them to a provider without this
102
+ * capability, so the caller must ASK rather than assume — see `delegateReview`.
103
+ */
104
+ agentOptions?: boolean;
36
105
  };
37
106
  }
38
107
  export interface CapabilityProbe {
@@ -46,15 +115,31 @@ export interface CapabilityProbe {
46
115
  };
47
116
  reason: string;
48
117
  }
118
+ /**
119
+ * T7 — the settings OVERRIDE layer over the declarative file.
120
+ *
121
+ * ONE PATH, NOT TWO (the item's own interaction note): `recursive-router.json` remains the
122
+ * declarative source, and the settings namespace can OVERRIDE individual fields without
123
+ * restating the file. That is why every field here is optional and why the schema declares
124
+ * NO defaults for them: an absent field means "defer to the file", and a default would make
125
+ * every field present and silently shadow the file forever.
126
+ */
127
+ export interface RouterPolicyOverrides {
128
+ defaults?: Partial<RouterPolicy['defaults']>;
129
+ }
49
130
  /** Parse recursive-router.json. A missing/invalid file yields a default self-audit policy (never throws). */
50
- export declare function loadRouterPolicy(path?: string): RouterPolicy;
131
+ export declare function loadRouterPolicy(path?: string, overrides?: RouterPolicyOverrides): RouterPolicy;
51
132
  /** Default router policy path inside a workspace root. */
52
133
  export declare function routerPolicyPath(root: string): string;
53
134
  /**
54
- * Resolve a role to a tier, preferring a native provider whose name maps to
55
- * the role (e.g. role 'code-reviewer' -> provider 'code-reviewer' or the
56
- * generic spawn/fork provider). External CLIs ride their provider rows; else
57
- * the policy fallback.
135
+ * T9 — attach the role's model to the decision.
136
+ *
137
+ * ⚠ WHY A WRAPPER AND NOT SIX EDITS. `resolveRoleInner` has six return sites, and adding the
138
+ * model to each would leave a decision shape that carries it on some paths and not others — a
139
+ * trap for the next reader, and exactly the kind of quiet inconsistency this plan keeps
140
+ * refusing to ship. Attaching it ONCE, at the seam where the decision leaves, makes the field
141
+ * present on EVERY path by construction. The policy lookup itself lives in `role-route.ts`
142
+ * (`routeForRole`), so there is one definition of "which model does this role use", not two.
58
143
  */
59
144
  export declare function resolveRole(role: string, policy: RouterPolicy, providers: Record<string, SubagentProviderLike>): RouteDecision;
60
145
  /** Probe a single provider and return its advertised capabilities. */