@mgiles/perk 1.1.0 → 2.1.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 (93) hide show
  1. package/README.md +68 -44
  2. package/extension/adapters/planAdapterPlannotator.ts +27 -41
  3. package/extension/adapters/planAdapterTombell.ts +15 -28
  4. package/extension/adapters/todoAdapterJuicesharp.ts +10 -13
  5. package/extension/checkpoints/checkpoints.ts +19 -12
  6. package/extension/doors/address.ts +4 -4
  7. package/extension/doors/askUser.ts +12 -8
  8. package/extension/doors/ciExecutor.ts +21 -14
  9. package/extension/doors/hunkHandoff.ts +202 -0
  10. package/extension/doors/land.ts +31 -9
  11. package/extension/doors/learn.ts +2 -2
  12. package/extension/doors/learnFactory.ts +144 -0
  13. package/extension/doors/plannotatorHandoff.ts +509 -0
  14. package/extension/doors/prReview.ts +4 -4
  15. package/extension/doors/prReviewBrowser.ts +341 -0
  16. package/extension/doors/prReviewTerminal.ts +267 -0
  17. package/extension/doors/selfcheck.ts +238 -5
  18. package/extension/doors/submit.ts +20 -0
  19. package/extension/doors/submitPrReview.ts +408 -0
  20. package/extension/factories/objective.ts +15 -5
  21. package/extension/factories/objectiveAuthor.ts +15 -32
  22. package/extension/factories/objectiveDraft.ts +1 -1
  23. package/extension/factories/objectivePlan.ts +12 -10
  24. package/extension/factories/objectiveSave.ts +2 -2
  25. package/extension/factories/planMode.ts +22 -40
  26. package/extension/factories/planReview.ts +213 -191
  27. package/extension/factories/planSave.ts +7 -7
  28. package/extension/index.ts +83 -25
  29. package/extension/substrate/bindingDelivery.ts +32 -10
  30. package/extension/substrate/bindings.ts +4 -2
  31. package/extension/substrate/cache.ts +34 -7
  32. package/extension/substrate/clipboard.ts +81 -0
  33. package/extension/substrate/config.ts +97 -70
  34. package/extension/substrate/git.ts +43 -0
  35. package/extension/substrate/paths.ts +1 -1
  36. package/extension/substrate/prompts.ts +2 -2
  37. package/extension/substrate/providers.ts +62 -8
  38. package/extension/substrate/sessionPointers.ts +35 -6
  39. package/extension/substrate/structuredOutput.ts +3 -1
  40. package/extension/substrate/terminalLaunch.ts +178 -0
  41. package/extension/substrate/toolGating.ts +330 -79
  42. package/extension/substrate/toolParams.ts +7 -0
  43. package/extension/substrate/workflowState.ts +54 -2
  44. package/extension/surfaces/footerProvider.ts +8 -4
  45. package/extension/surfaces/surfaces.ts +330 -12
  46. package/extension/vendor/btw/btw.ts +10 -0
  47. package/extension/worker/readOnlySession.ts +19 -6
  48. package/extension/worker/worker.ts +77 -7
  49. package/extension/workerMain.ts +12 -13
  50. package/package.json +3 -3
  51. package/prompts/_fixtures/live.yaml +117 -2
  52. package/prompts/contexts/adapters/juicesharp-todo.md +7 -0
  53. package/prompts/contexts/adapters/plannotator-objective.md +7 -0
  54. package/prompts/contexts/adapters/plannotator-plan.md +6 -0
  55. package/prompts/contexts/adapters/tombell-plan.md +17 -0
  56. package/prompts/contexts/objective-authoring.md +20 -0
  57. package/prompts/contexts/plan-authoring.md +24 -0
  58. package/prompts/contexts/read-only.md +10 -0
  59. package/prompts/stages/conflict-resolution.md +1 -1
  60. package/prompts/stages/learn-code.md +1 -1
  61. package/prompts/stages/learn-docs.md +2 -2
  62. package/prompts/stages/learn-orchestrate.md +1 -1
  63. package/prompts/stages/objective-author/adopt.md +1 -1
  64. package/prompts/stages/objective-author/file.md +1 -1
  65. package/prompts/stages/objective-plan/guidance.md +1 -1
  66. package/prompts/stages/objective-plan/seed.md +1 -1
  67. package/prompts/stages/objective-reconcile.md +1 -1
  68. package/prompts/stages/objective-replan.md +1 -1
  69. package/prompts/stages/plan-from/adopt.md +2 -2
  70. package/prompts/stages/plan-from/file.md +2 -2
  71. package/prompts/stages/pr-review-browser/active.md +11 -0
  72. package/prompts/stages/pr-review-browser/foreign.md +11 -0
  73. package/prompts/stages/pr-review-terminal/active.md +12 -0
  74. package/prompts/stages/pr-review-terminal/foreign.md +13 -0
  75. package/prompts/stages/pr-review-terminal/local.md +4 -0
  76. package/prompts/stages/pr-review.md +1 -1
  77. package/prompts/stages/replan.md +2 -2
  78. package/prompts/stages/skills/create-from.md +1 -1
  79. package/prompts/stages/skills/create.md +1 -1
  80. package/prompts/stages/skills/refine.md +1 -1
  81. package/shared/README.md +22 -18
  82. package/shared/bindings.yaml +10 -2
  83. package/shared/contracts-history.md +24 -0
  84. package/shared/contracts.md +1538 -1803
  85. package/shared/providers.yaml +8 -1
  86. package/shared/registry.yaml +7 -8
  87. package/shared/schemas/inputs/review-submit-batch.schema.json +66 -0
  88. package/shared/schemas/outputs/pr-review-checkout.schema.json +69 -0
  89. package/shared/schemas/outputs/pr-review-cleanup.schema.json +54 -0
  90. package/shared/schemas/outputs/pr-review-submit.schema.json +64 -0
  91. package/extension/doors/learnCode.ts +0 -100
  92. package/extension/doors/learnDocs.ts +0 -100
  93. package/extension/doors/prReviewLocal.ts +0 -229
@@ -1,15 +1,134 @@
1
1
  // The tool-gating primitive (the keystone). Structural read-only enforcement, NOT
2
2
  // prompting. Mirrors pi's authoritative `examples/extensions/plan-mode/` recipe (the
3
- // `setActiveTools` allowlist + `tool_call` bash sub-allowlist + `before_agent_start` injection +
3
+ // `setActiveTools` allowlist + `tool_call` bash sub-allowlist + `before_agent_start` injection
4
+ // (once-only: branch-scan dedup'd on the marker, so a session carries ONE live copy) +
4
5
  // `context` strip-when-off) and `preset.ts`'s snapshot-then-restore. The gate attaches to the
5
6
  // existing `perk:workflow-state.mode` field (`read-only`/`read-write`) — no new registry stage.
7
+ // Beside the gate lives STAGE_TOOLS: per-stage active-tool scoping for the scoped universe
8
+ // (perk's OWN registered tools + the enumerated borrowed-package census), keyed off the
9
+ // workflow-state `stage` field and applied at the same rebuild points (contracts.md §8.40) —
10
+ // fail-open where the gate is fail-closed.
6
11
  //
7
12
  // Substrate only: perk-owned plan mode and the read-only CI executor are the consumers of the
8
13
  // `enter`/`exit` surface; the allowlist-restore is wired into the existing
9
14
  // `session_start`/`session_tree` rebuild points.
10
15
 
11
16
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
- import { WORKFLOW_STATE_TYPE } from "./workflowState.ts";
17
+ import { render } from "./prompts.ts";
18
+ import { branchCarries, branchOf, WORKFLOW_STATE_TYPE } from "./workflowState.ts";
19
+
20
+ /**
21
+ * The `web` seam providers' research tools: the UNION of all known web-provider tool names,
22
+ * enumerated statically and inert when the package is absent (the plan_review precedent —
23
+ * setActiveTools simply has nothing to enable). None mutate the repo — fetch_content's
24
+ * GitHub-clone path writes only to its own cache outside the worktree, morally equivalent to the
25
+ * already-allowlisted curl. perk does NOT normalize names, so all three providers' divergent
26
+ * names are listed: pi-web-access (default: web_search/code_search/fetch_content/
27
+ * get_search_content — `code_search` is not registered by any current version; kept as an inert
28
+ * static name for version tolerance), @ollama/pi-web-search (ollama_web_search/ollama_web_fetch),
29
+ * and @juicesharp/rpiv-web-tools (web_search shared, web_fetch). All register at load time.
30
+ */
31
+ export const WEB_RESEARCH_TOOLS: readonly string[] = [
32
+ "web_search",
33
+ "code_search",
34
+ "fetch_content",
35
+ "get_search_content",
36
+ "ollama_web_search",
37
+ "ollama_web_fetch",
38
+ "web_fetch",
39
+ ];
40
+
41
+ /**
42
+ * pi-mono-linear's read-only tools (the [issues] backend = "linear" selection): none mutate
43
+ * Linear or the repo. Foreign names are inert when the package is absent (the pi-web-access
44
+ * precedent above). The mutating/sensitive tools live in LINEAR_MUTATING_TOOLS and are
45
+ * deliberately excluded from the read-only gate AND from every stage list. All 25 register at
46
+ * load time (verified against the upstream pi-mono-extensions source).
47
+ */
48
+ export const LINEAR_READ_TOOLS: readonly string[] = [
49
+ "linear_whoami",
50
+ "linear_workspace_metadata",
51
+ "linear_list_teams",
52
+ "linear_get_team",
53
+ "linear_list_users",
54
+ "linear_get_user",
55
+ "linear_list_issues",
56
+ "linear_get_issue",
57
+ "linear_search_issues",
58
+ "linear_list_my_issues",
59
+ "linear_list_projects",
60
+ "linear_get_project",
61
+ "linear_list_issue_statuses",
62
+ "linear_get_issue_status",
63
+ "linear_list_labels",
64
+ "linear_list_cycles",
65
+ "linear_list_documents",
66
+ "linear_get_document",
67
+ "linear_list_comments",
68
+ ];
69
+
70
+ /**
71
+ * pi-mono-linear's mutating/sensitive tools — in the borrowed census so every stage session
72
+ * sheds their schemas, and in NO stage list: Linear mutations are the Python plane's job
73
+ * (`linear_configure_auth` even writes ~/.pi/agent/auth.json). Bare/unscoped sessions keep
74
+ * full access.
75
+ */
76
+ export const LINEAR_MUTATING_TOOLS: readonly string[] = [
77
+ "linear_create_issue",
78
+ "linear_update_issue",
79
+ "linear_create_comment",
80
+ "linear_upload_file",
81
+ "linear_upload_file_to_issue_comment",
82
+ "linear_configure_auth",
83
+ ];
84
+
85
+ /**
86
+ * pi-subagents' delegation family. `subagent`/`wait` register at load time; the parent intercom
87
+ * pair (`subagent_supervisor`, `intercom`) registers during `session_start` — AFTER perk's sync
88
+ * (perk is the first `packages` entry), so it LEAKS past rebuild-point filtering at launch
89
+ * (≈830 schema chars — accepted, documented, test-pinned). A later `session_tree` re-apply
90
+ * filters over the original snapshot (which lacks the late names), so a tree navigation drops
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).
94
+ */
95
+ export const SUBAGENT_TOOLS: readonly string[] = [
96
+ "subagent",
97
+ "wait",
98
+ "subagent_supervisor",
99
+ "intercom",
100
+ ];
101
+
102
+ /**
103
+ * The enumerated borrowed-package tool census (contracts.md §8.40): every foreign tool name perk
104
+ * wires — via `BORROWED_PACKAGES`, a provider package, or the linear issue backend — joins the
105
+ * scoped universe beside PERK_TOOLS. Same static-name posture as READ_ONLY_TOOLS: names are
106
+ * inert when the package is absent, and un-enumerated foreign names always pass through every
107
+ * stage filter (fail-open — enumeration here is diet-completeness, not correctness).
108
+ *
109
+ * Audit records (the per-package census):
110
+ * - Registration timing: every census name registers at load time EXCEPT pi-subagents' parent
111
+ * supervisor pair (see SUBAGENT_TOOLS — session_start, leaks past rebuild-point filtering).
112
+ * - Foreign `setActiveTools` owners: plannotator's phase machinery and @tombell/pi-plan's plan
113
+ * mode run their OWN toggles — perk re-applies only at rebuild points, so a foreign toggle
114
+ * between rebuilds wins (fail-open direction), and a mid-session rebuild re-installs perk's
115
+ * stage set over a foreign restriction. Pre-existing interplay, recorded, not re-engineered.
116
+ * - Zero-tool packages: @tombell/pi-diff (commands only), the footer providers, and the hunk
117
+ * review CLI (not a Pi package) register nothing — nothing to enumerate.
118
+ * - Single-governance rule: `ask_user_question` must stay OUT of this census — the
119
+ * @juicesharp/rpiv-ask-user-question provider registers the IDENTICAL name perk does, so the
120
+ * name-keyed PERK_TOOLS entry already governs both registrations (hygiene-tested).
121
+ */
122
+ export const BORROWED_TOOLS: readonly string[] = [
123
+ ...WEB_RESEARCH_TOOLS,
124
+ ...LINEAR_READ_TOOLS,
125
+ ...LINEAR_MUTATING_TOOLS,
126
+ ...SUBAGENT_TOOLS,
127
+ "todo", // @juicesharp/rpiv-todo (the juicesharp-todo provider) — load-time
128
+ // @plannotator/pi-extension: perk never drives its plan phases (the adapter bridges
129
+ // `plan_review` to its event API), so the submit tool is dead weight in stage sessions.
130
+ "plannotator_submit_plan",
131
+ ];
13
132
 
14
133
  /**
15
134
  * Tools available while read-only mode is active (mirrors plan-mode's PLAN_MODE_TOOLS).
@@ -41,65 +160,154 @@ export const READ_ONLY_TOOLS = [
41
160
  // on — can only be written by calling this tool inside the gated session. Excluding it
42
161
  // silently breaks the warm `/objective-plan` path: the plan saves unlinked.
43
162
  "objective_node",
44
- // The `web` seam providers' research tools: the UNION of all known web-provider tool
45
- // names, allowlisted statically and inert when the package is absent (the plan_review precedent
46
- // — setActiveTools simply has nothing to enable). None mutate the repo — fetch_content's
47
- // GitHub-clone path writes only to its own cache outside the worktree, morally equivalent to the
48
- // already-allowlisted curl. perk does NOT normalize names, so all three providers' divergent
49
- // names are listed: pi-web-access (default: web_search/code_search/fetch_content/
50
- // get_search_content), @ollama/pi-web-search (ollama_web_search/ollama_web_fetch), and
51
- // @juicesharp/rpiv-web-tools (web_search shared, web_fetch).
52
- "web_search",
53
- "code_search",
54
- "fetch_content",
55
- "get_search_content",
56
- "ollama_web_search",
57
- "ollama_web_fetch",
58
- "web_fetch",
59
- // pi-mono-linear's read-only tools (the [issues] backend = "linear" selection):
60
- // none mutate Linear or the repo. Foreign names are inert when the package is absent (the
61
- // pi-web-access precedent above). The mutating/sensitive tools are deliberately excluded:
62
- // linear_create_issue, linear_update_issue, linear_create_comment, linear_upload_file,
63
- // linear_upload_file_to_issue_comment, linear_configure_auth (writes ~/.pi/agent/auth.json).
64
- "linear_whoami",
65
- "linear_workspace_metadata",
66
- "linear_list_teams",
67
- "linear_get_team",
68
- "linear_list_users",
69
- "linear_get_user",
70
- "linear_list_issues",
71
- "linear_get_issue",
72
- "linear_search_issues",
73
- "linear_list_my_issues",
74
- "linear_list_projects",
75
- "linear_get_project",
76
- "linear_list_issue_statuses",
77
- "linear_get_issue_status",
78
- "linear_list_labels",
79
- "linear_list_cycles",
80
- "linear_list_documents",
81
- "linear_get_document",
82
- "linear_list_comments",
163
+ // The borrowed research families (extracted to family constants; set + order byte-identical).
164
+ ...WEB_RESEARCH_TOOLS,
165
+ ...LINEAR_READ_TOOLS,
166
+ // The delegation carve-in: the gated objective-plan seed/guidance names the
167
+ // `perk.objective-explorer` spawn, so `subagent`/`wait` (+ the parent supervisor pair, which
168
+ // already leaks active into cold-door gated sessions via late registration — keeping
169
+ // warm-entered gates consistent, and letting the parent answer child `contact_supervisor`
170
+ // asks) must be reachable while gated. ACCEPTED LENIENCY, deliberately documented: spawned
171
+ // children are unscoped by design (§8.40 adopt-never-impersonates). The explorer's agent def
172
+ // is structurally write-blocked (`tools: read, grep, find, ls, bash` frontmatter in
173
+ // agents/objective-explorer.md), but the `subagent` tool itself can spawn ad-hoc read-write
174
+ // children — a posture choice with NO agent-allowlist backstop, consistent with the arg-blind
175
+ // `curl`/`agent-browser` precedents (contracts.md §8.3).
176
+ ...SUBAGENT_TOOLS,
83
177
  ];
84
178
 
179
+ /**
180
+ * Every tool perk itself registers (contracts.md §8.40). Name-keyed: `setActiveTools` ignores
181
+ * unknown names, so a vacated registration (e.g. `ask_user_question` under a foreign
182
+ * `[providers] askuser` selection registers the IDENTICAL name) or an absent tool is inert.
183
+ * Stage scoping filters the scoped universe `PERK_TOOLS ∪ BORROWED_TOOLS` — builtins and
184
+ * un-enumerated foreign names pass through untouched (fail-open).
185
+ */
186
+ export const PERK_TOOLS: readonly string[] = [
187
+ "plan_review",
188
+ "plan_save",
189
+ "plan_draft",
190
+ "objective_save",
191
+ "objective_node",
192
+ "reconcile_objective",
193
+ "add_objective_node",
194
+ "objective_draft",
195
+ "learn",
196
+ "ask_user_question",
197
+ "land",
198
+ "post_pr_review",
199
+ "ready",
200
+ "resolve_review_threads",
201
+ "submit_pr_review",
202
+ "run_ci",
203
+ "submit",
204
+ ];
205
+
206
+ /**
207
+ * The research bundle EVERY stage list carries: web research + Linear reads are useful in every
208
+ * stage session (authoring and worktree alike) and mutate nothing.
209
+ */
210
+ const RESEARCH_TOOLS: readonly string[] = [...WEB_RESEARCH_TOOLS, ...LINEAR_READ_TOOLS];
211
+
212
+ /**
213
+ * The PR-loop family shared by ALL FIVE worktree stages (implement/submit/address/land/learn) —
214
+ * deliberately one shared list, not per-stage cuts: any PR-loop warm command must work in any
215
+ * worktree session (warm doors inject guidance naming their companion tool, and a per-stage cut
216
+ * would dead-end e.g. `/land` run inside the implement session; the concrete forcing example is
217
+ * the post-land reconcile drive — `/land` auto-drives `/objective-reconcile` in-session, whose
218
+ * guidance names the reconcile trio, so the trio must be active in every worktree stage). The
219
+ * headless worker also REQUIRES the model-invoked `submit` (implement) /
220
+ * `resolve_review_threads` (address) to reach its completion bar. Borrowed additions: delegation
221
+ * (SUBAGENT_TOOLS — the `/pr-review`/`/address`/`/submit`-conflict/`/learn` orchestration flows)
222
+ * and `todo` (the foreign checklist overlay the implement-progress discipline rides) are
223
+ * worktree-family only.
224
+ */
225
+ const WORKTREE_STAGE_TOOLS: readonly string[] = [
226
+ "ask_user_question",
227
+ "submit",
228
+ "ready",
229
+ "run_ci",
230
+ "land",
231
+ "learn",
232
+ "resolve_review_threads",
233
+ "post_pr_review",
234
+ "submit_pr_review",
235
+ // The reconcile trio: `/land` auto-drives the objective-reconcile pass inside the CURRENT
236
+ // worktree session (driveReconcileAfterLand), and the manual `/objective-reconcile` gesture is
237
+ // registered globally — both inject guidance naming these three tools.
238
+ "reconcile_objective",
239
+ "add_objective_node",
240
+ "objective_node",
241
+ ...RESEARCH_TOOLS,
242
+ ...SUBAGENT_TOOLS,
243
+ "todo",
244
+ ];
245
+
246
+ /**
247
+ * Per-stage active perk tools for gate-OFF sessions (contracts.md §8.40). Keys = the registry
248
+ * stage ids; an unknown/absent stage id is fail-open (no filtering — version-skew safety).
249
+ * Rationale pins:
250
+ * - `ask_user_question` is universal (every stage list carries it).
251
+ * - `plan`/`save` cover the plan-family stage borrowers (`plan from`/`plan replan`/
252
+ * `learn docs`/`learn code` borrow `plan`; `skills create/refine` borrow `save`).
253
+ * - `objective-author`/`objective-save` cover `objective replan` + `objective author --from`.
254
+ * - `objective-plan` keeps `objective_node` (the factory's claim/transition door) and
255
+ * `plan_save` (the gate-off manual failsafe for the approval-driven save).
256
+ * - the reconcile trio (`reconcile_objective`/`add_objective_node`/`objective_node`) rides the
257
+ * three objective stages (the post-save `/objective-reconcile` gesture) AND the worktree
258
+ * family (the post-land `driveReconcileAfterLand` drive + the manual `/objective-reconcile`
259
+ * gesture — its guidance names all three).
260
+ */
261
+ export const STAGE_TOOLS: Readonly<Record<string, readonly string[]>> = {
262
+ "objective-author": [
263
+ "ask_user_question",
264
+ "objective_draft",
265
+ "objective_save",
266
+ "reconcile_objective",
267
+ "add_objective_node",
268
+ "objective_node",
269
+ ...RESEARCH_TOOLS,
270
+ ],
271
+ "objective-save": [
272
+ "ask_user_question",
273
+ "objective_draft",
274
+ "objective_save",
275
+ "reconcile_objective",
276
+ "add_objective_node",
277
+ "objective_node",
278
+ ...RESEARCH_TOOLS,
279
+ ],
280
+ "objective-plan": [
281
+ "ask_user_question",
282
+ "plan_draft",
283
+ "plan_review",
284
+ "plan_save",
285
+ "objective_node",
286
+ "reconcile_objective",
287
+ "add_objective_node",
288
+ ...RESEARCH_TOOLS,
289
+ ],
290
+ plan: ["ask_user_question", "plan_draft", "plan_review", "plan_save", ...RESEARCH_TOOLS],
291
+ save: ["ask_user_question", "plan_draft", "plan_review", "plan_save", ...RESEARCH_TOOLS],
292
+ implement: WORKTREE_STAGE_TOOLS,
293
+ submit: WORKTREE_STAGE_TOOLS,
294
+ address: WORKTREE_STAGE_TOOLS,
295
+ land: WORKTREE_STAGE_TOOLS,
296
+ learn: WORKTREE_STAGE_TOOLS,
297
+ };
298
+
85
299
  /** The read-only marker / custom-message type injected into context while active. */
86
300
  const MODE_CONTEXT_TYPE = "perk:mode-context";
87
301
  const READ_ONLY_MARKER = "[READ-ONLY MODE]";
88
302
 
89
- /** Exported for tests: the injected read-only mode context (interpolates the allowlist). */
90
- export const READ_ONLY_CONTEXT = `${READ_ONLY_MARKER}
91
- You are in perk read-only mode — a structurally enforced exploration mode.
92
-
93
- - You can only use: ${READ_ONLY_TOOLS.join(", ")}.
94
- - You CANNOT use edit or write (file modifications are blocked).
95
- - plan_draft is the sole sanctioned write: it writes only the working-plan artifact in the session data dir.
96
- - bash is restricted to an allowlist of read-only commands.
97
- - For GitHub data use read-only \`gh\` subcommands (view/list/diff/status/checks/search) — never raw curl/fetch against github.com (private repos reject unauthenticated requests).
98
-
99
- These restrictions are enforced by perk, not advisory. Do not attempt to make changes.`;
303
+ /** Exported for tests: the injected read-only mode context. (No tool enumeration — gate-ON
304
+ * already applies READ_ONLY_TOOLS via setActiveTools, so the active tool set IS the list.) */
305
+ export const READ_ONLY_CONTEXT = render("contexts/read-only.md", {
306
+ marker: READ_ONLY_MARKER,
307
+ });
100
308
 
101
309
  // --- pure policy (copied from plan-mode/utils.ts so this primitive is self-contained; perk-owned
102
- // so T2's eventual retirement of the borrowed pi-plan extension leaves no dangling import) -------
310
+ // so retiring the borrowed pi-plan extension leaves no dangling import) -------
103
311
 
104
312
  const DESTRUCTIVE_PATTERNS = [
105
313
  /\brm\b/i,
@@ -134,7 +342,11 @@ const DESTRUCTIVE_PATTERNS = [
134
342
  /\bshutdown\b/i,
135
343
  /\bsystemctl\s+(start|stop|restart|enable|disable)/i,
136
344
  /\bservice\s+\S+\s+(start|stop|restart)/i,
137
- /\b(vim?|nano|emacs|code|subl)\b/i,
345
+ /\b(vim?|nano|emacs|subl)\b/i,
346
+ // `code` (the editor) is vetoed in command position only — the old bare \bcode\b veto blocked
347
+ // every command CONTAINING the word (destructive-wins), so the allowlisted `gh search code`
348
+ // could never run. `code f.ts`, `ls; code .`, `x && code .`, `$(code y)` stay blocked.
349
+ /(^|[;&|(]|\$\()\s*code\b/i,
138
350
  ];
139
351
 
140
352
  const SAFE_PATTERNS = [
@@ -203,9 +415,11 @@ const SAFE_PATTERNS = [
203
415
  /^\s*npx\s+agent-browser\b/,
204
416
  /^\s*bat\b/,
205
417
  /^\s*eza\b/,
206
- // perk's own read-only objective queries (show/next + their s/n aliases). The trailing \b keeps
207
- // the `n` alias from matching the mutating `node` subcommand; create/node/reconcile stay blocked.
208
- /^\s*perk\s+(objective|obj)\s+(show|s|next|n)\b/i,
418
+ // perk's own read-only objective queries (show/next + their s/n aliases, plus the non-mutating
419
+ // node-engagement read the objective-plan factory needs). The trailing \b keeps the `n` alias
420
+ // from matching the mutating `node` subcommand; node-engagement allowed; create/node/reconcile
421
+ // stay blocked.
422
+ /^\s*perk\s+(objective|obj)\s+(show|s|next|n|node-engagement)\b/i,
209
423
  // Read-only `gh` queries — the guidance in the managed AGENTS block ("GitHub access goes
210
424
  // through gh") must be followable in read-only sessions. Query-shaped subcommands only;
211
425
  // `gh api` stays blocked (it can POST/PATCH), as do all mutating subcommands (create/edit/
@@ -294,13 +508,16 @@ export function isReadOnlyBashCommand(command: string): boolean {
294
508
 
295
509
  // --- the controller -----------------------------------------------------------------------------
296
510
 
297
- /** The API T2/T5 consume + the lifecycle hooks index.ts wires. */
511
+ /** The API the plan-mode and read-only-stage consumers use + the lifecycle hooks index.ts wires. */
298
512
  export interface ToolGating {
299
- /** Reapply the allowlist from a rebuilt `mode` (called on session_start AND session_tree). */
300
- syncFromState(mode: string | undefined): void;
301
- /** Enter read-only mode: persist `mode=read-only` + snapshot/restrict tools. (T2/T5 call site.) */
513
+ /**
514
+ * Reapply the gate + stage scoping from a rebuilt `mode` + `stage` (called on session_start
515
+ * AND session_tree). `stage` is the branch-LWW workflow-state stage id (undefined = unscoped).
516
+ */
517
+ syncFromState(mode: string | undefined, stage: string | undefined): void;
518
+ /** Enter read-only mode: persist `mode=read-only` + snapshot/restrict tools. (Called by the plan-mode toggle and the objective-plan factory.) */
302
519
  enter(ctx?: ExtensionContext): void;
303
- /** Exit read-only mode: persist `mode=read-write` + restore tools. (T2/T5 call site.) */
520
+ /** Exit read-only mode: persist `mode=read-write` + restore tools. (Called by the plan-mode toggle and the save/exit doors.) */
304
521
  exit(ctx?: ExtensionContext): void;
305
522
  /** Whether the gate is currently active (in-memory source of truth for `tool_call`). */
306
523
  isActive(): boolean;
@@ -314,23 +531,54 @@ export function registerToolGating(pi: ExtensionAPI): ToolGating {
314
531
  // In-memory gate (mirrors plan-mode's `planModeEnabled`): the authority `tool_call` consults.
315
532
  // Fail-closed — a failed sync never opens this; tool_call blocks on any internal error.
316
533
  let active = false;
317
- // Pre-gate tool snapshot, taken once on the off→on transition (preset.ts discipline).
534
+ // The branch-LWW stage id this session is scoped to (null = unscoped). Fail-open by contrast
535
+ // with `active`: no stage / unknown stage / any lookup miss → no filtering.
536
+ let stageId: string | null = null;
537
+ // Pre-engagement tool snapshot, taken ONCE on the first engagement of either concern (the
538
+ // preset.ts discipline, shared by the gate and stage scoping).
318
539
  let snapshot: string[] | null = null;
319
540
 
320
- function applyActive(next: boolean): void {
321
- if (next && !active) {
322
- // offon: snapshot the current tool set, then restrict.
541
+ /**
542
+ * Recompute + install the active tool set from both concerns (contracts.md §8.40):
543
+ * - gate ON exactly READ_ONLY_TOOLS, NO stage filter (the decided composition: the gated
544
+ * set is already the diet, and a strict intersection would break the documented warm
545
+ * `/objective-plan` carve-out and recreate the seed/gate contradiction class). "The gate
546
+ * never widens a stage's set and vice versa" still holds: engaging the gate only ever
547
+ * narrows, and stage scoping never adds a tool.
548
+ * - gate OFF + stage scoped → a SUBTRACTIVE filter over the snapshot: names outside the
549
+ * scoped universe (builtins, un-enumerated foreign tools) pass through untouched; scoped
550
+ * names (PERK_TOOLS ∪ BORROWED_TOOLS) survive only when the stage's list carries them.
551
+ * - neither engaged → restore the snapshot if one exists (a session that never engages gets
552
+ * ZERO setActiveTools calls — bare warm sessions stay byte-identical).
553
+ * While engaged the set is re-installed on every sync (tree navigation across mode entries
554
+ * must recompute correctly).
555
+ */
556
+ // The scoped universe: perk's own tools + the enumerated borrowed census. Builtins and
557
+ // un-enumerated foreign names pass through every stage filter untouched (fail-open).
558
+ const SCOPED_TOOL_NAMES: ReadonlySet<string> = new Set([...PERK_TOOLS, ...BORROWED_TOOLS]);
559
+
560
+ function apply(nextActive: boolean, nextStage: string | null): void {
561
+ const stageList = nextStage === null ? undefined : STAGE_TOOLS[nextStage];
562
+ // First engagement of either concern: take the one snapshot.
563
+ if ((nextActive || stageList !== undefined) && snapshot === null) {
323
564
  snapshot = pi.getActiveTools();
324
- pi.setActiveTools(READ_ONLY_TOOLS);
325
- } else if (!next && active) {
326
- // on → off: restore the pre-gate snapshot. If none exists (near-unreachable — the off→on
327
- // branch always snapshots first), fall back to the FULL configured tool set
328
- // (pi.getAllTools()) like plan-mode, never a hardcoded list that would silently drop
329
- // grep/find/ls and perk's custom tools (plan_save/submit/land/learn).
330
- pi.setActiveTools(snapshot ?? pi.getAllTools().map((t) => t.name));
565
+ }
566
+ if (nextActive) {
567
+ pi.setActiveTools([...READ_ONLY_TOOLS]);
568
+ } else if (stageList !== undefined) {
569
+ // The snapshot-missing fallback mirrors the restore path below: the FULL configured tool
570
+ // set (pi.getAllTools()), never a hardcoded list that would silently drop grep/find/ls
571
+ // and perk's custom tools (plan_save/submit/land/learn).
572
+ const base = snapshot ?? pi.getAllTools().map((t) => t.name);
573
+ pi.setActiveTools(
574
+ base.filter((name) => !SCOPED_TOOL_NAMES.has(name) || stageList.includes(name)),
575
+ );
576
+ } else if (snapshot !== null) {
577
+ pi.setActiveTools(snapshot);
331
578
  snapshot = null;
332
579
  }
333
- active = next;
580
+ active = nextActive;
581
+ stageId = nextStage;
334
582
  }
335
583
 
336
584
  // Structural backstop: block writes + non-allowlisted bash while active. Fail-closed on error.
@@ -360,8 +608,11 @@ export function registerToolGating(pi: ExtensionAPI): ToolGating {
360
608
  });
361
609
 
362
610
  // Inject the hidden read-only mode context while active (display:false → not shown in transcript).
363
- pi.on("before_agent_start", async () => {
611
+ pi.on("before_agent_start", async (_event, ctx) => {
364
612
  if (!active) return;
613
+ // Once-only: injected customs persist to the branch, so a live copy suppresses re-injection;
614
+ // compaction dropping it makes the scan come up clean and the next turn re-injects.
615
+ if (branchCarries(branchOf(ctx), READ_ONLY_MARKER)) return;
365
616
  return {
366
617
  message: { customType: MODE_CONTEXT_TYPE, content: READ_ONLY_CONTEXT, display: false },
367
618
  };
@@ -390,16 +641,16 @@ export function registerToolGating(pi: ExtensionAPI): ToolGating {
390
641
  });
391
642
 
392
643
  return {
393
- syncFromState(mode: string | undefined): void {
394
- applyActive(isReadOnlyMode(mode));
644
+ syncFromState(mode: string | undefined, stage: string | undefined): void {
645
+ apply(isReadOnlyMode(mode), stage ?? null);
395
646
  },
396
647
  enter(_ctx?: ExtensionContext): void {
397
648
  pi.appendEntry(WORKFLOW_STATE_TYPE, { mode: "read-only" });
398
- applyActive(true);
649
+ apply(true, stageId);
399
650
  },
400
651
  exit(_ctx?: ExtensionContext): void {
401
652
  pi.appendEntry(WORKFLOW_STATE_TYPE, { mode: "read-write" });
402
- applyActive(false);
653
+ apply(false, stageId);
403
654
  },
404
655
  isActive(): boolean {
405
656
  return active;
@@ -27,6 +27,13 @@ export function stringParam(p: ToolParams, key: string): string | undefined | nu
27
27
  return typeof value === "string" ? value : null;
28
28
  }
29
29
 
30
+ /** Tri-state boolean field: undefined = absent; null = present-but-mistyped. */
31
+ export function booleanParam(p: ToolParams, key: string): boolean | undefined | null {
32
+ const value = p[key];
33
+ if (value === undefined) return undefined;
34
+ return typeof value === "boolean" ? value : null;
35
+ }
36
+
30
37
  /** Tri-state number field: undefined = absent; null = present-but-mistyped. */
31
38
  export function numberParam(p: ToolParams, key: string): number | undefined | null {
32
39
  const value = p[key];
@@ -43,6 +43,12 @@ export interface WorkflowState {
43
43
  * `rebuildWorkflowState`, no rebuild change). The PR comment stays canonical.
44
44
  */
45
45
  last_pr_review?: unknown;
46
+ /**
47
+ * The last review-door outcome posted via the `submit_pr_review` warm tool (§8.3):
48
+ * `{pr, event, comment_count, mode, at}`. Best-effort tier (per-field LWW in
49
+ * `rebuildWorkflowState`, no rebuild change). The submitted PR review stays canonical.
50
+ */
51
+ last_review?: unknown;
46
52
  /** Session-artifact provenance pointers, keyed by artifact name (§8.3). */
47
53
  session_artifacts?: Record<string, SessionArtifactPointer> | null;
48
54
  /**
@@ -84,6 +90,17 @@ export function branchOf(source: BranchSource): BranchEntry[] {
84
90
  return source.sessionManager.getBranch() as BranchEntry[];
85
91
  }
86
92
 
93
+ /**
94
+ * Whether any entry on the branch already carries `needle` — the once-only injection dedup guard
95
+ * (the bindingDelivery `branchHasHeader` form). Serializing each entry is the robust,
96
+ * shape-agnostic scan; safe while the needle is a distinctive literal that other entries' data
97
+ * can't casually contain (known accepted false positive: a tool result quoting perk's own source;
98
+ * the typed customType scan is the escalation if that bites — docs/learned/pi/context-injection.md).
99
+ */
100
+ export function branchCarries(branch: readonly BranchEntry[], needle: string): boolean {
101
+ return branch.some((entry) => JSON.stringify(entry).includes(needle));
102
+ }
103
+
87
104
  /**
88
105
  * Per-field last-write-wins over the `perk:workflow-state` custom entries on a branch.
89
106
  * Non-perk entries are ignored; `undefined` fields never clobber (but explicit `null` does).
@@ -185,6 +202,14 @@ export type ClaimDecision =
185
202
  state: WorkflowState;
186
203
  }
187
204
  | { action: "claim"; source: "env"; runId: string }
205
+ | {
206
+ action: "adopt";
207
+ source: "env-child";
208
+ childRunId: string;
209
+ parentRunId: string;
210
+ /** Inherited from the parent's handoff so read-only gating survives into the child. */
211
+ mode?: string;
212
+ }
188
213
  | { action: "none"; source: "none"; state: WorkflowState };
189
214
 
190
215
  /**
@@ -193,12 +218,15 @@ export type ClaimDecision =
193
218
  * (NOT `event.reason`, which is "startup" for a headless `pi --fork`): if the branch already
194
219
  * carries a `run_id` whose recorded `pi_session_id` differs from the current session, the id
195
220
  * was inherited across a fork → derive a child; if it matches (or is absent), it's a reload.
221
+ * An env-inherited run id whose handoff was already CONSUMED by a different session is a
222
+ * spawned child, not the launched session → `adopt` (derive a sibling id, inherit `mode`).
196
223
  */
197
224
  /**
198
225
  * The registry stage id the launched run is acting on, read from its handoff blob, or null.
199
226
  * Only `claim` (cold) and `keep` (reload) sessions have a settled run whose handoff records a
200
- * `stage`; `fork` and `none` carry no launched stage (LWW restores their state instead). The
201
- * stage gates whether `session_start` reconciles `cache.plan-ref` into `active_plan_ref`.
227
+ * `stage`; `fork`, `adopt`, and `none` carry no launched stage (an adopted env-child must never
228
+ * impersonate the launched stage; LWW restores fork/none state instead). The stage gates whether
229
+ * `session_start` reconciles `cache.plan-ref` into `active_plan_ref`.
202
230
  */
203
231
  export function resolveRunStage(decision: ClaimDecision, cwd: string): string | null {
204
232
  const runId =
@@ -227,6 +255,30 @@ export function decideClaim(args: {
227
255
  return { action: "fork", source: "fork", childRunId, parentRunId: state.run_id, state };
228
256
  }
229
257
  if (envRunId !== null && envRunId !== "") {
258
+ // Env-child detection (contracts §8.2): subagent children are spawned as separate `pi`
259
+ // processes with the parent's env, so they arrive here carrying the parent's PERK_RUN_ID.
260
+ // A handoff already consumed by a DIFFERENT (or unrecorded) session belongs to someone else:
261
+ // adopt a derived `<run_id>.<n>` child identity instead of re-claiming — never re-consume the
262
+ // handoff, never capture pointers, never impersonate the launched stage. The parent's `mode`
263
+ // is inherited so read-only gating survives into exploration children. Everything else —
264
+ // absent/corrupt/mismatched handoff (the loud unclaimed error), unconsumed (the normal cold
265
+ // claim), or consumed by THIS session (idempotent re-claim after lost branch state) — stays
266
+ // the claim arm.
267
+ const handoff = readHandoff(cwd, envRunId);
268
+ if (
269
+ handoff !== null &&
270
+ handoff.run_id === envRunId &&
271
+ handoff.consumed === true &&
272
+ handoff.pi_session_id !== currentSessionId
273
+ ) {
274
+ return {
275
+ action: "adopt",
276
+ source: "env-child",
277
+ childRunId: deriveForkRunId(envRunId, cwd),
278
+ parentRunId: envRunId,
279
+ mode: handoff.mode,
280
+ };
281
+ }
230
282
  return { action: "claim", source: "env", runId: envRunId };
231
283
  }
232
284
  return { action: "none", source: "none", state };
@@ -21,14 +21,18 @@ import {
21
21
 
22
22
  /**
23
23
  * The resolved `[providers] footer` selection id for `cwd`. Fail-safe to the perk-footer
24
- * reference: any load/resolution failure (corrupt bundled set, etc.) returns the reference id so
25
- * perk keeps installing its own footer — the default path is the hard guarantee. Mirror of
26
- * `resolvedAskUserProviderId`.
24
+ * reference: any load/resolution failure returns the reference id so perk keeps installing its
25
+ * own footer — the default path is the hard guarantee. Mirror of `resolvedAskUserProviderId`: the
26
+ * catch narrows to genuine file-read/parse failures (the resolver is per-seam fail-open) and is
27
+ * logged, never silent.
27
28
  */
28
29
  export function resolvedFooterProviderId(cwd: string): string {
29
30
  try {
30
31
  return resolveProviders(loadPerkConfig(cwd).providers, loadProviders()).footer.id;
31
- } catch {
32
+ } catch (error) {
33
+ console.error(
34
+ `perk: footer provider resolution failed — falling back to ${PERK_FOOTER_PROVIDER_ID}: ${error}`,
35
+ );
32
36
  return PERK_FOOTER_PROVIDER_ID;
33
37
  }
34
38
  }