@mgiles/perk 2.3.0 → 3.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 (128) hide show
  1. package/README.md +1 -1
  2. package/extension/adapters/planAdapterPlannotator.ts +132 -91
  3. package/extension/adapters/planAdapterTombell.ts +5 -3
  4. package/extension/doors/address.ts +235 -37
  5. package/extension/doors/annotationPush.ts +924 -0
  6. package/extension/doors/auditWaveTools.ts +352 -0
  7. package/extension/doors/ciExecutor.ts +220 -55
  8. package/extension/doors/draftReviewWaveTools.ts +384 -0
  9. package/extension/doors/harvestWaveTools.ts +279 -0
  10. package/extension/doors/land.ts +1 -0
  11. package/extension/doors/learn.ts +24 -6
  12. package/extension/doors/learnFactory.ts +33 -7
  13. package/extension/doors/lifecycleGates.ts +1 -1
  14. package/extension/doors/objectiveReviewBrowser.ts +514 -0
  15. package/extension/doors/objectiveStack.ts +1143 -0
  16. package/extension/doors/planReviewBrowser.ts +453 -0
  17. package/extension/doors/plannotatorHandoff.ts +120 -49
  18. package/extension/doors/prReview.ts +26 -16
  19. package/extension/doors/prReviewBrowser.ts +31 -23
  20. package/extension/doors/prReviewDynamic.ts +25 -18
  21. package/extension/doors/prReviewTerminal.ts +6 -11
  22. package/extension/doors/reviewWaveTools.ts +374 -0
  23. package/extension/doors/selfcheck.ts +1 -1
  24. package/extension/doors/submit.ts +82 -7
  25. package/extension/factories/gistAuthor.ts +4 -4
  26. package/extension/factories/gistDraft.ts +1 -1
  27. package/extension/factories/gistSave.ts +2 -2
  28. package/extension/factories/objective.ts +7 -7
  29. package/extension/factories/objectiveAuthor.ts +5 -4
  30. package/extension/factories/objectiveDraft.ts +61 -6
  31. package/extension/factories/objectivePlan.ts +163 -9
  32. package/extension/factories/objectiveSave.ts +16 -1
  33. package/extension/factories/planMode.ts +5 -3
  34. package/extension/factories/planReview.ts +131 -36
  35. package/extension/factories/planTitle.ts +27 -9
  36. package/extension/hunkFeedback/inbox.ts +389 -0
  37. package/extension/hunkFeedback/perkFeedback.ts +364 -0
  38. package/extension/hunkFeedback/receiver.ts +296 -0
  39. package/extension/hunkFeedback/store.ts +533 -0
  40. package/extension/index.ts +109 -44
  41. package/extension/substrate/cache.ts +75 -22
  42. package/extension/substrate/coldDoor.ts +2 -3
  43. package/extension/substrate/config.ts +36 -12
  44. package/extension/substrate/git.ts +10 -0
  45. package/extension/substrate/providers.ts +4 -33
  46. package/extension/substrate/resources.ts +11 -0
  47. package/extension/substrate/result.ts +13 -5
  48. package/extension/substrate/sessionData.ts +3 -3
  49. package/extension/substrate/sessionPointers.ts +3 -3
  50. package/extension/substrate/structuredOutput.ts +78 -19
  51. package/extension/substrate/toolGating.ts +170 -23
  52. package/extension/substrate/workflowState.ts +6 -0
  53. package/extension/surfaces/footerProvider.ts +6 -6
  54. package/extension/surfaces/surfaces.ts +98 -282
  55. package/extension/vendor/btw/btw.ts +57 -12
  56. package/extension/waves/adversarialReviewWave.ts +164 -0
  57. package/extension/waves/auditWave.ts +312 -0
  58. package/extension/waves/draftReviewWave.ts +189 -0
  59. package/extension/waves/harvestWave.ts +399 -0
  60. package/extension/waves/memoryAdapter.ts +14 -1
  61. package/extension/waves/objectiveExplorerWave.ts +120 -0
  62. package/extension/waves/prReviewDynamicWave.ts +291 -61
  63. package/extension/waves/prReviewWave.ts +58 -13
  64. package/extension/waves/reportWave.ts +406 -106
  65. package/extension/waves/reviewClassifierWave.ts +109 -0
  66. package/extension/waves/rpcAdapter.ts +65 -2
  67. package/extension/worker/readOnlySession.ts +3 -3
  68. package/extension/worker/worker.ts +71 -93
  69. package/extension/workerMain.ts +4 -6
  70. package/package.json +15 -5
  71. package/prompts/README.md +19 -0
  72. package/prompts/_fixtures/live.yaml +66 -65
  73. package/prompts/common/resume-advisory.md +1 -0
  74. package/prompts/contexts/adapters/plannotator-gist.md +13 -0
  75. package/prompts/contexts/adapters/plannotator-objective.md +1 -2
  76. package/prompts/contexts/adapters/plannotator-plan.md +1 -2
  77. package/prompts/contexts/gist-authoring.md +6 -20
  78. package/prompts/contexts/objective-authoring.md +6 -18
  79. package/prompts/stages/address/action.md +3 -14
  80. package/prompts/stages/address/preview.md +2 -13
  81. package/prompts/stages/audit.md +18 -0
  82. package/prompts/stages/gist-author/seed.md +4 -4
  83. package/prompts/stages/gist-save.md +1 -1
  84. package/prompts/stages/implement.md +3 -1
  85. package/prompts/stages/learn-code.md +1 -1
  86. package/prompts/stages/learn-docs.md +2 -2
  87. package/prompts/stages/learn-harvest.md +15 -0
  88. package/prompts/stages/learn-orchestrate.md +1 -1
  89. package/prompts/stages/objective-author/adopt.md +5 -3
  90. package/prompts/stages/objective-author/file.md +5 -4
  91. package/prompts/stages/objective-author/seed.md +5 -4
  92. package/prompts/stages/objective-land.md +7 -0
  93. package/prompts/stages/objective-plan/guidance.md +2 -13
  94. package/prompts/stages/objective-plan/seed.md +5 -12
  95. package/prompts/stages/objective-recover.md +8 -0
  96. package/prompts/stages/objective-replan.md +4 -2
  97. package/prompts/stages/objective-review-browser.md +9 -0
  98. package/prompts/stages/objective-sync.md +6 -0
  99. package/prompts/stages/plan-from/adopt.md +2 -2
  100. package/prompts/stages/plan-from/file.md +2 -2
  101. package/prompts/stages/plan-review-browser.md +9 -0
  102. package/prompts/stages/pr-review-browser/active.md +6 -14
  103. package/prompts/stages/pr-review-browser/foreign.md +6 -14
  104. package/prompts/stages/pr-review-dynamic.md +3 -3
  105. package/prompts/stages/pr-review-terminal/active.md +7 -15
  106. package/prompts/stages/pr-review-terminal/foreign.md +7 -15
  107. package/prompts/stages/pr-review.md +2 -2
  108. package/prompts/stages/replan.md +3 -3
  109. package/shared/README.md +2 -2
  110. package/shared/bindings.yaml +12 -0
  111. package/shared/contracts-history.md +11 -0
  112. package/shared/contracts.md +3652 -236
  113. package/shared/providers.yaml +27 -60
  114. package/shared/registry.yaml +32 -8
  115. package/shared/schemas/outputs/objective-doctor.schema.json +449 -0
  116. package/shared/schemas/outputs/objective-stack-land.schema.json +732 -0
  117. package/shared/schemas/outputs/objective-stack-recover.schema.json +397 -0
  118. package/shared/schemas/outputs/objective-stack-status.schema.json +577 -0
  119. package/shared/schemas/outputs/objective-stack-sync.schema.json +184 -0
  120. package/shared/schemas/outputs/plan-save.schema.json +12 -0
  121. package/shared/schemas/outputs/pr-submit.schema.json +177 -1
  122. package/extension/adapters/todoAdapterJuicesharp.ts +0 -102
  123. package/extension/checkpoints/checkpoints.ts +0 -550
  124. package/extension/checkpoints/planSteps.ts +0 -108
  125. package/extension/doors/askUser.ts +0 -198
  126. package/prompts/common/output-schemas/objective-explorer.md +0 -36
  127. package/prompts/common/output-schemas/review-classifier.md +0 -47
  128. package/prompts/contexts/adapters/juicesharp-todo.md +0 -7
@@ -2,13 +2,12 @@
2
2
  //
3
3
  // Twin of perk/substrate/providers.py: both planes parse the SAME bundled file (no codegen). This is the
4
4
  // THIRD parsed cross-plane contract (after registry.yaml and bindings.yaml). It is the SUPPORTED
5
- // SET — the catalog of plan/todo/askuser/footer/web providers perk knows how to wire —
5
+ // SET — the catalog of plan/footer/web providers perk knows how to wire —
6
6
  // distinct from the per-repo SELECTION (the flat `[providers]` table in .perk/config.toml).
7
7
  //
8
8
  // The Python CLI is the authoritative validator (perk/substrate/providers.py); this side does a thin
9
9
  // structural parse only — no deep content validation here. `resolveProviders` is CONSUMED by
10
- // `planMode` (plan-seam registration-time vacating when a foreign plan provider is selected); the
11
- // todo-seam runtime deferral is likewise live.
10
+ // `planMode` (plan-seam registration-time vacating when a foreign plan provider is selected).
12
11
 
13
12
  import { readFileSync } from "node:fs";
14
13
  import { join } from "node:path";
@@ -24,12 +23,10 @@ export interface Provider {
24
23
  packageFilter?: Record<string, unknown>;
25
24
  }
26
25
 
27
- export const PROVIDER_SEAMS = ["plan", "todo", "askuser", "footer", "web"] as const;
26
+ export const PROVIDER_SEAMS = ["plan", "footer", "web"] as const;
28
27
 
29
28
  /** The bundled reference provider ids (the behavior-preserving no-config defaults per seam). */
30
29
  export const PERK_PLAN_PROVIDER_ID = "perk-plan";
31
- export const PERK_CHECKPOINTS_PROVIDER_ID = "perk-checkpoints";
32
- export const PERK_ASK_USER_PROVIDER_ID = "perk-ask-user";
33
30
  export const PERK_FOOTER_PROVIDER_ID = "perk-footer";
34
31
 
35
32
  /** The foreign `@tombell/pi-plan` plan-provider id. */
@@ -38,12 +35,6 @@ export const TOMBELL_PLAN_PROVIDER_ID = "tombell-plan";
38
35
  /** The foreign `@plannotator/pi-extension` plan-provider id (augment-posture adapter selection check). */
39
36
  export const PLANNOTATOR_PLAN_PROVIDER_ID = "plannotator-plan";
40
37
 
41
- /** The foreign `@juicesharp/rpiv-todo` todo-provider id. */
42
- export const JUICESHARP_TODO_PROVIDER_ID = "juicesharp-todo";
43
-
44
- /** The foreign `@juicesharp/rpiv-ask-user-question` askuser-provider id (vacate-only interface seam). */
45
- export const JUICESHARP_ASK_USER_PROVIDER_ID = "juicesharp-ask-user";
46
-
47
38
  /** The foreign `pi-powerline-footer` footer-provider id (vacate-only interface seam). */
48
39
  export const POWERLINE_FOOTER_PROVIDER_ID = "powerline-footer";
49
40
 
@@ -106,8 +97,6 @@ export function loadProviders(): Provider[] {
106
97
  */
107
98
  export interface ResolvedProviders {
108
99
  plan: Provider;
109
- todo: Provider;
110
- askuser: Provider;
111
100
  footer: Provider;
112
101
  web: Provider;
113
102
  issues: string[];
@@ -124,20 +113,6 @@ export interface ResolvedProviders {
124
113
  */
125
114
  const REFERENCE_FALLBACKS: Record<(typeof PROVIDER_SEAMS)[number], Provider> = {
126
115
  plan: { id: PERK_PLAN_PROVIDER_ID, seam: "plan", package: null, adapter: null, default: true },
127
- todo: {
128
- id: PERK_CHECKPOINTS_PROVIDER_ID,
129
- seam: "todo",
130
- package: null,
131
- adapter: null,
132
- default: true,
133
- },
134
- askuser: {
135
- id: PERK_ASK_USER_PROVIDER_ID,
136
- seam: "askuser",
137
- package: null,
138
- adapter: null,
139
- default: true,
140
- },
141
116
  footer: {
142
117
  id: PERK_FOOTER_PROVIDER_ID,
143
118
  seam: "footer",
@@ -183,8 +158,6 @@ function byId(set: Provider[]): Map<string, Provider> {
183
158
  export function resolveProviders(
184
159
  selection: {
185
160
  plan?: string;
186
- todo?: string;
187
- askuser?: string;
188
161
  footer?: string;
189
162
  web?: string;
190
163
  },
@@ -205,7 +178,7 @@ export function resolveProviders(
205
178
  return def;
206
179
  };
207
180
 
208
- const resolveSeam = (seam: "plan" | "todo" | "askuser" | "footer" | "web"): Provider => {
181
+ const resolveSeam = (seam: "plan" | "footer" | "web"): Provider => {
209
182
  const selected = selection[seam];
210
183
  if (selected == null) return requireDefault(seam);
211
184
  const provider = ids.get(selected);
@@ -222,8 +195,6 @@ export function resolveProviders(
222
195
 
223
196
  return {
224
197
  plan: resolveSeam("plan"),
225
- todo: resolveSeam("todo"),
226
- askuser: resolveSeam("askuser"),
227
198
  footer: resolveSeam("footer"),
228
199
  web: resolveSeam("web"),
229
200
  issues,
@@ -39,3 +39,14 @@ export function perkVersion(): string {
39
39
  return "0.0.0";
40
40
  }
41
41
  }
42
+
43
+ /**
44
+ * The vintage-stamp filter over a perkVersion() value: the version, or undefined for the
45
+ * "0.0.0" failure sentinel. A `perk_version` stamp claims an EXACT session vintage
46
+ * (contracts §8.3 — the session-audit read side treats it as the exact basis), so the
47
+ * self-confessed-unknown sentinel must not masquerade as one; omitting the stamp leaves the
48
+ * session on the honest timestamp-estimate arm.
49
+ */
50
+ export function versionStamp(version: string): string | undefined {
51
+ return version === "0.0.0" ? undefined : version;
52
+ }
@@ -55,18 +55,26 @@ export function ok<D extends object>(
55
55
  * `failFor(ctx, scope, label)` when the content label differs from the report scope). Each call
56
56
  * reports loudly (`report(ctx, scope, "error", message, { alsoLog: true })`) and returns the
57
57
  * canonical soft failure: content `"<label> failed: <message>"`, details
58
- * `{ ok: false, error: message, error_type: errorType }`, no `terminate`.
58
+ * `{ ok: false, error: message, error_type: errorType }`, no `terminate`. `X` is the
59
+ * `FailDetails` extras hook: `failFor<X>(…)` lets a call attach module-specific fail details
60
+ * (spread AFTER `error`/`error_type`) — e.g. the learn wave's attempt receipts. Extras stay
61
+ * optional, so existing extras-free call sites are unchanged.
59
62
  */
60
- export function failFor(
63
+ export function failFor<X extends object = Record<never, never>>(
61
64
  target: ReportTarget,
62
65
  scope: string,
63
66
  label: string = scope,
64
- ): (message: string, errorType: string) => FailResult {
65
- return (message, errorType) => {
67
+ ): (message: string, errorType: string, extras?: X) => FailResult<X> {
68
+ return (message, errorType, extras) => {
66
69
  report(target, scope, "error", message, { alsoLog: true });
67
70
  return {
68
71
  content: [{ type: "text", text: `${label} failed: ${message}` }],
69
- details: { ok: false, error: message, error_type: errorType },
72
+ details: {
73
+ ok: false,
74
+ error: message,
75
+ error_type: errorType,
76
+ ...(extras ?? {}),
77
+ } as FailDetails<X>,
70
78
  };
71
79
  };
72
80
  }
@@ -33,10 +33,10 @@
33
33
  // loadable under `node --test`; accepts a minimal structural ctx (`BranchSource & { cwd }`).
34
34
 
35
35
  import { createHash } from "node:crypto";
36
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
36
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
37
37
  import { join, relative } from "node:path";
38
38
  import type { ReportTarget } from "../surfaces/report.ts";
39
- import { sessionDataDir } from "./cache.ts";
39
+ import { atomicWriteFileSync, sessionDataDir } from "./cache.ts";
40
40
  import {
41
41
  appendWorkflowState,
42
42
  type BranchSource,
@@ -115,7 +115,7 @@ export function writeSessionData(
115
115
  if (dir === null) return null;
116
116
  const path = join(dir, name);
117
117
  try {
118
- writeFileSync(path, content, "utf8");
118
+ atomicWriteFileSync(path, content);
119
119
  } catch (error) {
120
120
  console.error(`perk: warning: could not write session data ${path}: ${error}`);
121
121
  return null;
@@ -13,9 +13,9 @@
13
13
  // wedge the save/launch/drive it rides on. Node builtins + cache.ts + git.ts only (loads under
14
14
  // `node --test`).
15
15
 
16
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
16
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
17
17
  import { basename, join } from "node:path";
18
- import { runScratchDir } from "./cache.ts";
18
+ import { atomicWriteFileSync, runScratchDir } from "./cache.ts";
19
19
  import { mainCheckoutRoot } from "./git.ts";
20
20
 
21
21
  export const SESSION_POINTERS_FILE = "session-pointers.json";
@@ -121,7 +121,7 @@ export function recordSessionPointer(
121
121
  record[klass][site] = pointer;
122
122
  const dir = runScratchDir(root, runId);
123
123
  mkdirSync(dir, { recursive: true });
124
- writeFileSync(sessionPointersPath(root, runId), serialize(record), "utf8");
124
+ atomicWriteFileSync(sessionPointersPath(root, runId), serialize(record));
125
125
  return true;
126
126
  } catch (error) {
127
127
  console.error(`perk: warning: could not record session pointer (${klass}.${site}): ${error}`);
@@ -2,16 +2,19 @@
2
2
  //
3
3
  // pi-ai has no dedicated JSON-mode; structured output is done via tool calling. This module wraps
4
4
  // that idiom into two pure, dependency-light, NEVER-throwing helpers:
5
- // - `resolveModelAuth(ctx)` reuses the session's configured + authenticated model (the sanctioned
6
- // `ModelRegistry.getApiKeyAndHeaders` path), and
7
- // - `completeStructured(opts)` builds a single-tool `Context`, calls `complete`, and validates the
8
- // returned tool-call arguments against a TypeBox schema.
5
+ // - `resolveModelAuth(ctx)` reuses the session's configured + authenticated model (the compat
6
+ // `ModelRegistry.getApiKeyAndHeaders` path — the fallback for hosts without registry
7
+ // dispatch), and
8
+ // - `completeStructured(opts)` builds a single-tool `Context`, completes it (via the injected
9
+ // registry `dispatch` when the host provides one, else the compat `complete`), and validates
10
+ // the returned tool-call arguments against a TypeBox schema.
9
11
  // Both report failure via a soft `{ ok:false, error }` outcome — no throws ever reach the caller, so
10
12
  // every consumer can stay fail-safe with a deterministic fallback. The first consumer is
11
13
  // `extension/factories/planTitle.ts` (LLM-generated plan-issue titles).
12
14
 
13
15
  import {
14
16
  type Api,
17
+ type AssistantMessage,
15
18
  type Context,
16
19
  type Model,
17
20
  type Static,
@@ -24,21 +27,54 @@ import {
24
27
  // keeps the types. Pi's extension loader aliases both the root and /compat to the compat entry.
25
28
  import { complete } from "@earendil-works/pi-ai/compat";
26
29
 
27
- /** Structurally-minimal slice of `ExtensionContext` needed to reuse the session's model + auth. */
30
+ /**
31
+ * Registry dispatch (mirrors pi ≥ 0.84's `ModelRegistry.complete`): pi owns final request
32
+ * assembly — resolved auth, nullable headers, credential-resolved `baseUrl`, provider `env` —
33
+ * end to end. Feature-detect it (`typeof … === "function"`); absent on older hosts.
34
+ */
35
+ export type ModelDispatch = (
36
+ model: Model<Api>,
37
+ context: Context,
38
+ options: { signal?: AbortSignal; timeoutMs?: number },
39
+ ) => Promise<AssistantMessage>;
40
+
41
+ /**
42
+ * Structurally-minimal slice of `ExtensionContext` needed to reuse the session's model + auth.
43
+ * Header values mirror pi-ai's `ProviderHeaders` (`string | null` — null is a header-deletion
44
+ * marker); `baseUrl`/`env` mirror pi 0.84's `ResolvedRequestAuth`.
45
+ */
28
46
  export interface ModelAuthContext {
29
47
  model: Model<Api> | undefined;
30
48
  modelRegistry: {
31
- getApiKeyAndHeaders(
32
- model: Model<Api>,
33
- ): Promise<
34
- { ok: true; apiKey?: string; headers?: Record<string, string> } | { ok: false; error: string }
49
+ getApiKeyAndHeaders(model: Model<Api>): Promise<
50
+ | {
51
+ ok: true;
52
+ apiKey?: string;
53
+ headers?: Record<string, string | null>;
54
+ baseUrl?: string;
55
+ env?: Record<string, string>;
56
+ }
57
+ | { ok: false; error: string }
35
58
  >;
59
+ /** pi ≥ 0.84 registry dispatch; absent on older hosts (feature-detected, never assumed). */
60
+ complete?(
61
+ model: Model<Api>,
62
+ context: Context,
63
+ options?: { signal?: AbortSignal; timeoutMs?: number },
64
+ ): Promise<AssistantMessage>;
36
65
  };
37
66
  }
38
67
 
39
68
  /** Resolved model + auth, or a soft failure (no model / unresolved auth). */
40
69
  export type ResolvedModelAuth =
41
- | { ok: true; model: Model<Api>; apiKey?: string; headers?: Record<string, string> }
70
+ | {
71
+ ok: true;
72
+ model: Model<Api>;
73
+ apiKey?: string;
74
+ headers?: Record<string, string | null>;
75
+ baseUrl?: string;
76
+ env?: Record<string, string>;
77
+ }
42
78
  | { ok: false; error: string };
43
79
 
44
80
  /** The generic structured-output outcome — soft success/failure, never a throw. */
@@ -60,7 +96,14 @@ export async function resolveModelAuth(ctx: ModelAuthContext): Promise<ResolvedM
60
96
  try {
61
97
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
62
98
  if (!auth.ok) return { ok: false, error: auth.error };
63
- return { ok: true, model, apiKey: auth.apiKey, headers: auth.headers };
99
+ return {
100
+ ok: true,
101
+ model,
102
+ apiKey: auth.apiKey,
103
+ headers: auth.headers,
104
+ baseUrl: auth.baseUrl,
105
+ env: auth.env,
106
+ };
64
107
  } catch (err) {
65
108
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
66
109
  }
@@ -79,8 +122,17 @@ export interface CompleteStructuredOptions<S extends TSchema> {
79
122
  instruction: string;
80
123
  /** The payload (e.g. the document to summarize/classify). */
81
124
  input: string;
125
+ /**
126
+ * Registry dispatch (pi ≥ 0.84). When present it REPLACES the compat `complete` +
127
+ * apiKey/headers assembly below — pi owns auth end to end, including the credential-resolved
128
+ * `baseUrl` the fallback path cannot carry.
129
+ */
130
+ dispatch?: ModelDispatch;
82
131
  apiKey?: string;
83
- headers?: Record<string, string>;
132
+ /** `string | null` mirrors pi-ai's `ProviderHeaders` — a null value deletes a default header. */
133
+ headers?: Record<string, string | null>;
134
+ /** Provider-scoped environment values (pi 0.84 `ResolvedRequestAuth.env`), fallback path only. */
135
+ env?: Record<string, string>;
84
136
  signal?: AbortSignal;
85
137
  timeoutMs?: number;
86
138
  }
@@ -114,14 +166,21 @@ export async function completeStructured<S extends TSchema>(
114
166
  tools: [tool],
115
167
  };
116
168
 
117
- let msg: Awaited<ReturnType<typeof complete>>;
169
+ // Primary: registry dispatch (pi ≥ 0.84 owns auth/headers/baseUrl/env). Fallback: the compat
170
+ // `complete` with caller-resolved auth — it forwards apiKey/headers/env but has NO `baseUrl`
171
+ // option, so a credential-resolved endpoint is silently dropped on old hosts (the fallback's
172
+ // named, pre-existing limitation; the dispatch path is the fix).
173
+ let msg: AssistantMessage;
118
174
  try {
119
- msg = await complete(opts.model, context, {
120
- apiKey: opts.apiKey,
121
- headers: opts.headers,
122
- signal: opts.signal,
123
- timeoutMs: opts.timeoutMs,
124
- });
175
+ msg = opts.dispatch
176
+ ? await opts.dispatch(opts.model, context, { signal: opts.signal, timeoutMs: opts.timeoutMs })
177
+ : await complete(opts.model, context, {
178
+ apiKey: opts.apiKey,
179
+ headers: opts.headers,
180
+ env: opts.env,
181
+ signal: opts.signal,
182
+ timeoutMs: opts.timeoutMs,
183
+ });
125
184
  } catch (err) {
126
185
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
127
186
  }
@@ -89,8 +89,9 @@ export const LINEAR_MUTATING_TOOLS: readonly string[] = [
89
89
  * (≈830 schema chars — accepted, documented, test-pinned). A later `session_tree` re-apply
90
90
  * filters over the original snapshot (which lacks the late names), so a tree navigation drops
91
91
  * them — the pre-existing snapshot behavior, unchanged. Child-side tools (`contact_supervisor`,
92
- * `structured_output`) are out of scope: spawned children are unscoped by design (§8.40
93
- * adopt-never-impersonates).
92
+ * `structured_output`) are out of scope for the STAGE census spawned children stay
93
+ * stage-unscoped by design (§8.40 adopt-never-impersonates) — but they DO ride READ_ONLY_TOOLS,
94
+ * because the read-only gate IS inherited by adopted children (see SUBAGENT_CHILD_TOOLS).
94
95
  */
95
96
  export const SUBAGENT_TOOLS: readonly string[] = [
96
97
  "subagent",
@@ -99,6 +100,31 @@ export const SUBAGENT_TOOLS: readonly string[] = [
99
100
  "intercom",
100
101
  ];
101
102
 
103
+ /**
104
+ * pi-subagents' CHILD-side engine tools. `structured_output` and `contact_supervisor` register
105
+ * only inside spawned child sessions (the env-keyed prompt-runtime registration — never in
106
+ * parents, so those names are inert in every gated parent session; `setActiveTools` ignores
107
+ * unknown names). `subagent_wait` is ALSO registered by the top-level parent extension
108
+ * (pi-subagents 0.45.1's `registerWaitTool`) — the accepted widening: a wait-only,
109
+ * non-repo-mutating tool active in gated parents. A gated ADOPTED child (mode inherited via the
110
+ * `adopt` arm, contracts.md §8.3) must keep them active:
111
+ * - `structured_output` is the engine-REQUIRED completion call when the launch carries an
112
+ * `outputSchema` — stripping it makes the child physically unable to finish and fails the
113
+ * run with `structuredOutputFailed`;
114
+ * - `contact_supervisor` is the child→parent intercom door;
115
+ * - `subagent_wait` is the fanout-child wait door.
116
+ * None mutates the repo (`structured_output` writes only the engine's capture file under
117
+ * `.pi-subagents/` scratch). Census decision, recorded: these names deliberately join NEITHER
118
+ * PERK_TOOLS nor BORROWED_TOOLS — the stage-filter universe never sees them because children
119
+ * are stage-unscoped by design (adopt never impersonates a stage), so gate membership is their
120
+ * only governance surface.
121
+ */
122
+ export const SUBAGENT_CHILD_TOOLS: readonly string[] = [
123
+ "structured_output",
124
+ "contact_supervisor",
125
+ "subagent_wait",
126
+ ];
127
+
102
128
  /**
103
129
  * @ff-labs/pi-fff's search tools. BOTH mode name-sets are enumerated (static names, inert
104
130
  * when absent — the code_search version-tolerance precedent): warm sessions run pi-fff's
@@ -131,9 +157,12 @@ export const FFF_SEARCH_TOOLS: readonly string[] = [
131
157
  * stage set over a foreign restriction. Pre-existing interplay, recorded, not re-engineered.
132
158
  * - Zero-tool packages: @tombell/pi-diff (commands only), the footer providers, and the hunk
133
159
  * review CLI (not a Pi package) register nothing — nothing to enumerate.
134
- * - Single-governance rule: `ask_user_question` must stay OUT of this census — the
135
- * @juicesharp/rpiv-ask-user-question provider registers the IDENTICAL name perk does, so the
136
- * name-keyed PERK_TOOLS entry already governs both registrations (hygiene-tested).
160
+ * - Single-governance rule: a name is governed ONCE it lives in exactly one census. perk
161
+ * registers no same-named `ask_user_question` anymore (the first-party tool is deleted), so
162
+ * the name lives HERE, in the borrowed census, not in PERK_TOOLS (hygiene-tested).
163
+ * Registration timing nuance: @juicesharp/rpiv-ask-user-question registers the tool at load
164
+ * time, then a `hasUI`-keyed reconcile strips/restores it — headless sessions carry no
165
+ * `ask_user_question` schema at all.
137
166
  * - @ff-labs/pi-fff (FFF_SEARCH_TOOLS): registration timing load-time (both modes); no
138
167
  * `setFooter` (only a keyed optional-chained `setStatus`); zero bundled skills.
139
168
  */
@@ -143,7 +172,12 @@ export const BORROWED_TOOLS: readonly string[] = [
143
172
  ...LINEAR_MUTATING_TOOLS,
144
173
  ...SUBAGENT_TOOLS,
145
174
  ...FFF_SEARCH_TOOLS,
146
- "todo", // @juicesharp/rpiv-todo (the juicesharp-todo provider) — load-time
175
+ // @juicesharp/rpiv-todo (required borrow) — registers at load; its checklist overlay is
176
+ // `hasUI`-gated (headless-safe).
177
+ "todo",
178
+ // @juicesharp/rpiv-ask-user-question (required borrow) — registers at load; strips itself
179
+ // headlessly (!hasUI reconcile).
180
+ "ask_user_question",
147
181
  // @plannotator/pi-extension: perk never drives its plan phases (the adapter bridges
148
182
  // `plan_review` to its event API), so the submit tool is dead weight in stage sessions.
149
183
  "plannotator_submit_plan",
@@ -189,23 +223,52 @@ export const READ_ONLY_TOOLS = [
189
223
  // FFF local search belongs in read-only exploration (the override names find/grep are
190
224
  // already present above; these are the additive tools-and-ui names + multi_grep).
191
225
  ...FFF_SEARCH_TOOLS,
192
- // The delegation carve-in: the gated objective-plan seed/guidance names the
193
- // `perk.objective-explorer` spawn, so `subagent`/`wait` (+ the parent supervisor pair, which
194
- // already leaks active into cold-door gated sessions via late registration — keeping
195
- // warm-entered gates consistent, and letting the parent answer child `contact_supervisor`
196
- // asks) must be reachable while gated. ACCEPTED LENIENCY, deliberately documented: spawned
197
- // children are unscoped by design (§8.40 adopt-never-impersonates). The explorer's agent def
198
- // is structurally write-blocked (`tools: read, grep, find, ls, bash` frontmatter in
199
- // agents/objective-explorer.md), but the `subagent` tool itself can spawn ad-hoc read-write
226
+ // The delegation carve-in: `subagent`/`wait` (+ the parent supervisor pair, which already
227
+ // leaks active into cold-door gated sessions via late registration keeping warm-entered
228
+ // gates consistent, and letting the parent answer child `contact_supervisor` asks) stay
229
+ // reachable while gated for the other delegation flows (the gated objective-plan guidance now
230
+ // names the `explore_objective_node` tool below, not a direct spawn). ACCEPTED LENIENCY,
231
+ // deliberately documented: spawned children are unscoped by design (§8.40
232
+ // adopt-never-impersonates), and the `subagent` tool itself can spawn ad-hoc read-write
200
233
  // children — a posture choice with NO agent-allowlist backstop, consistent with the arg-blind
201
234
  // `curl`/`agent-browser` precedents (contracts.md §8.3).
202
235
  ...SUBAGENT_TOOLS,
236
+ // The explorer-wave carve-in: the gated objective-plan session's OPTIONAL explore step is the
237
+ // `explore_objective_node` tool — it spawns the read-only `perk.objective-explorer` child over
238
+ // the already-carved-in SUBAGENT tools (the draft-review-wave precedent) and writes nothing to
239
+ // the worktree.
240
+ "explore_objective_node",
241
+ // The child-side carve-in: gated adopt-children must keep the engine's injected tools — see
242
+ // SUBAGENT_CHILD_TOOLS.
243
+ ...SUBAGENT_CHILD_TOOLS,
244
+ // The draft-review-door carve-in: plan-authoring sessions run GATED, so the
245
+ // /plan-review-browser companions must be reachable while read-only. `push_annotations` only
246
+ // POSTs findings to the door-primed local plannotator server (no worktree writes — the
247
+ // fetch_content cache-write precedent class); the wave pair spawns the read-only
248
+ // `perk.draft-reviewer` over the already-carved-in SUBAGENT_TOOLS/SUBAGENT_CHILD_TOOLS.
249
+ "push_annotations",
250
+ "start_draft_review_wave",
251
+ "collect_draft_review_wave",
252
+ // The audit-wave carve-in: the seeded `perk-dev audit judge` session runs GATED (the `audit`
253
+ // stage is read-only), so `run_audit_wave` must be reachable while read-only. Its one write
254
+ // (`<bundle>/verdicts.json`) is structurally bound to the cold door's workflow-state
255
+ // `audit_bundle_dir` — the tool takes NO parameters, so no caller-supplied path exists and a
256
+ // gated session cannot aim the writer anywhere (contracts.md §8.50).
257
+ "run_audit_wave",
258
+ // The harvest-wave carve-in: the seeded learn-harvest session runs GATED (the read-only
259
+ // objective-author borrow), so `run_harvest_wave` must be reachable while read-only. Its
260
+ // manifest read is structurally bound to the session's claimed run-scoped scratch path (the
261
+ // `manifest_path` param is verified against it and any other path refused — the
262
+ // `run_audit_wave` no-aimable-writer posture, read-side), it spawns the read-only
263
+ // `perk.harvest-analyst` over the already-carved-in SUBAGENT_TOOLS/SUBAGENT_CHILD_TOOLS, and
264
+ // it writes nothing to the worktree (contracts.md §8.48).
265
+ "run_harvest_wave",
203
266
  ];
204
267
 
205
268
  /**
206
269
  * Every tool perk itself registers (contracts.md §8.40). Name-keyed: `setActiveTools` ignores
207
- * unknown names, so a vacated registration (e.g. `ask_user_question` under a foreign
208
- * `[providers] askuser` selection registers the IDENTICAL name) or an absent tool is inert.
270
+ * unknown names, so an absent tool is inert (e.g. a borrowed census name whose package stripped
271
+ * or never registered it `ask_user_question` in a headless session has nothing to enable).
209
272
  * Stage scoping filters the scoped universe `PERK_TOOLS ∪ BORROWED_TOOLS` — builtins and
210
273
  * un-enumerated foreign names pass through untouched (fail-open).
211
274
  */
@@ -222,16 +285,33 @@ export const PERK_TOOLS: readonly string[] = [
222
285
  "gist_save",
223
286
  "learn",
224
287
  "run_learn_wave",
225
- "ask_user_question",
288
+ "run_audit_wave",
289
+ "run_harvest_wave",
226
290
  "land",
227
291
  "post_pr_review",
228
292
  "ready",
229
- "resolve_review_threads",
293
+ "classify_review_feedback",
294
+ "finalize_address",
295
+ "explore_objective_node",
230
296
  "run_pr_review_wave",
231
297
  "run_pr_review_dynamic_wave",
232
298
  "submit_pr_review",
299
+ "start_review_wave",
300
+ "collect_review_wave",
301
+ "push_annotations",
302
+ "start_draft_review_wave",
303
+ "collect_draft_review_wave",
233
304
  "run_ci",
234
305
  "submit",
306
+ // The stacked-delivery warm surface (contracts.md §8.51/§8.56): read + control tools over
307
+ // the cold `objective stack` workers. Never in READ_ONLY_TOOLS — sync/adopt/recover/land
308
+ // mutate published branches and PRs; the gated posture is the driving commands' soft
309
+ // refusal.
310
+ "objective_stack_status",
311
+ "objective_stack_sync",
312
+ "objective_stack_adopt",
313
+ "objective_stack_recover",
314
+ "objective_stack_land",
235
315
  ];
236
316
 
237
317
  /**
@@ -253,7 +333,7 @@ const RESEARCH_TOOLS: readonly string[] = [
253
333
  * the post-land reconcile drive — `/land` auto-drives `/objective-reconcile` in-session, whose
254
334
  * guidance names the reconcile trio, so the trio must be active in every worktree stage). The
255
335
  * headless worker also REQUIRES the model-invoked `submit` (implement) /
256
- * `resolve_review_threads` (address) to reach its completion bar. Borrowed additions: delegation
336
+ * `finalize_address` (address) to reach its completion bar. Borrowed additions: delegation
257
337
  * (SUBAGENT_TOOLS — the `/pr-review`/`/address`/`/submit`-conflict/`/learn` orchestration flows)
258
338
  * and `todo` (the foreign checklist overlay the implement-progress discipline rides) are
259
339
  * worktree-family only.
@@ -266,17 +346,34 @@ const WORKTREE_STAGE_TOOLS: readonly string[] = [
266
346
  "land",
267
347
  "learn",
268
348
  "run_learn_wave",
269
- "resolve_review_threads",
349
+ "classify_review_feedback",
350
+ "finalize_address",
270
351
  "post_pr_review",
271
352
  "run_pr_review_wave",
272
353
  "run_pr_review_dynamic_wave",
273
354
  "submit_pr_review",
355
+ // The human review doors' companion tools (/pr-review-terminal, /pr-review-browser): the
356
+ // review-wave pair + the door-primed annotation push. The plan-stage widening landed via the
357
+ // draft-review door (/plan-review-browser): the plan-family stage lists carry the draft-wave
358
+ // pair + push_annotations.
359
+ "start_review_wave",
360
+ "collect_review_wave",
361
+ "push_annotations",
274
362
  // The reconcile trio: `/land` auto-drives the objective-reconcile pass inside the CURRENT
275
363
  // worktree session (driveReconcileAfterLand), and the manual `/objective-reconcile` gesture is
276
364
  // registered globally — both inject guidance naming these three tools.
277
365
  "reconcile_objective",
278
366
  "add_objective_node",
279
367
  "objective_node",
368
+ // The stacked-delivery quintet: `/objective-sync`/`/objective-recover`/`/objective-land`
369
+ // drive worktree sessions (post-amend sync from implement/address; recovery and the atomic
370
+ // landing from anywhere in the PR loop), so their guidance-named tools must be active
371
+ // across the whole family.
372
+ "objective_stack_status",
373
+ "objective_stack_sync",
374
+ "objective_stack_adopt",
375
+ "objective_stack_recover",
376
+ "objective_stack_land",
280
377
  ...RESEARCH_TOOLS,
281
378
  ...SUBAGENT_TOOLS,
282
379
  "todo",
@@ -286,7 +383,8 @@ const WORKTREE_STAGE_TOOLS: readonly string[] = [
286
383
  * Per-stage active perk tools for gate-OFF sessions (contracts.md §8.40). Keys = the registry
287
384
  * stage ids; an unknown/absent stage id is fail-open (no filtering — version-skew safety).
288
385
  * Rationale pins:
289
- * - `ask_user_question` is universal (every stage list carries it).
386
+ * - `ask_user_question` is universal (every stage list carries it); the name is BORROWED now
387
+ * (the @juicesharp questionnaire, via BORROWED_TOOLS — headless sessions carry no schema).
290
388
  * - `plan`/`save` cover the plan-family stage borrowers (`plan from`/`plan replan`/
291
389
  * `learn docs`/`learn code` borrow `plan`; `skills create/refine` borrow `save`).
292
390
  * - `objective-author`/`objective-save` cover `objective replan` + `objective author --from`.
@@ -296,6 +394,12 @@ const WORKTREE_STAGE_TOOLS: readonly string[] = [
296
394
  * three objective stages (the post-save `/objective-reconcile` gesture) AND the worktree
297
395
  * family (the post-land `driveReconcileAfterLand` drive + the manual `/objective-reconcile`
298
396
  * gesture — its guidance names all three).
397
+ * - the draft-review companions (`start_draft_review_wave`/`collect_draft_review_wave`/
398
+ * `push_annotations`) also ride the two objective stages (§8.23's
399
+ * `/objective-review-browser` — gate-OFF coverage: after `objectiveApprovalSave` exits the
400
+ * gate mid-flow, late collects/pushes must not dead-end), and `plan_review` rides them
401
+ * because the door guidance names it (in both objective stages it routes to the objective
402
+ * review arm; the drive-coverage guard forces both the moment the guidance names them).
299
403
  */
300
404
  export const STAGE_TOOLS: Readonly<Record<string, readonly string[]>> = {
301
405
  "gist-author": ["ask_user_question", "gist_draft", "gist_save", ...RESEARCH_TOOLS],
@@ -307,6 +411,13 @@ export const STAGE_TOOLS: Readonly<Record<string, readonly string[]>> = {
307
411
  "reconcile_objective",
308
412
  "add_objective_node",
309
413
  "objective_node",
414
+ // The /objective-review-browser companions (gate-OFF coverage: after objectiveApprovalSave
415
+ // exits the gate mid-flow, late collects/pushes must not dead-end) + plan_review (the door
416
+ // guidance names it; it routes to the objective review arm here).
417
+ "start_draft_review_wave",
418
+ "collect_draft_review_wave",
419
+ "push_annotations",
420
+ "plan_review",
310
421
  ...RESEARCH_TOOLS,
311
422
  ],
312
423
  "objective-save": [
@@ -316,6 +427,11 @@ export const STAGE_TOOLS: Readonly<Record<string, readonly string[]>> = {
316
427
  "reconcile_objective",
317
428
  "add_objective_node",
318
429
  "objective_node",
430
+ // The /objective-review-browser companions + plan_review (see the objective-author note).
431
+ "start_draft_review_wave",
432
+ "collect_draft_review_wave",
433
+ "push_annotations",
434
+ "plan_review",
319
435
  ...RESEARCH_TOOLS,
320
436
  ],
321
437
  "objective-plan": [
@@ -324,17 +440,48 @@ export const STAGE_TOOLS: Readonly<Record<string, readonly string[]>> = {
324
440
  "plan_review",
325
441
  "plan_save",
326
442
  "objective_node",
443
+ "explore_objective_node",
327
444
  "reconcile_objective",
328
445
  "add_objective_node",
446
+ // The /plan-review-browser companions (gate-OFF coverage: after approvalSave exits the gate
447
+ // mid-flow, late collects/pushes must not dead-end — the drive-coverage guard forces this
448
+ // the moment the guidance names them).
449
+ "start_draft_review_wave",
450
+ "collect_draft_review_wave",
451
+ "push_annotations",
452
+ ...RESEARCH_TOOLS,
453
+ ],
454
+ plan: [
455
+ "ask_user_question",
456
+ "plan_draft",
457
+ "plan_review",
458
+ "plan_save",
459
+ // The /plan-review-browser companions (see the objective-plan note).
460
+ "start_draft_review_wave",
461
+ "collect_draft_review_wave",
462
+ "push_annotations",
463
+ ...RESEARCH_TOOLS,
464
+ ],
465
+ save: [
466
+ "ask_user_question",
467
+ "plan_draft",
468
+ "plan_review",
469
+ "plan_save",
470
+ // The /plan-review-browser companions (see the objective-plan note).
471
+ "start_draft_review_wave",
472
+ "collect_draft_review_wave",
473
+ "push_annotations",
329
474
  ...RESEARCH_TOOLS,
330
475
  ],
331
- plan: ["ask_user_question", "plan_draft", "plan_review", "plan_save", ...RESEARCH_TOOLS],
332
- save: ["ask_user_question", "plan_draft", "plan_review", "plan_save", ...RESEARCH_TOOLS],
333
476
  implement: WORKTREE_STAGE_TOOLS,
334
477
  submit: WORKTREE_STAGE_TOOLS,
335
478
  address: WORKTREE_STAGE_TOOLS,
336
479
  land: WORKTREE_STAGE_TOOLS,
337
480
  learn: WORKTREE_STAGE_TOOLS,
481
+ // The dev-only session-audit orchestrator (`perk-dev audit judge`). The session runs GATED
482
+ // (read-only mode), where this list is inert; it exists for the keys≡registry pin and the
483
+ // defensive gate-off arm.
484
+ audit: ["ask_user_question", "run_audit_wave", ...RESEARCH_TOOLS],
338
485
  };
339
486
 
340
487
  /** The read-only marker / custom-message type injected into context while active. */