@mgiles/perk 1.0.1 → 2.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 (157) hide show
  1. package/README.md +67 -57
  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 +21 -13
  6. package/extension/doors/address.ts +6 -5
  7. package/extension/doors/askUser.ts +12 -8
  8. package/extension/doors/ciExecutor.ts +23 -15
  9. package/extension/doors/hunkHandoff.ts +202 -0
  10. package/extension/doors/land.ts +33 -10
  11. package/extension/doors/learn.ts +240 -34
  12. package/extension/doors/learnFactory.ts +144 -0
  13. package/extension/doors/lifecycleGates.ts +2 -1
  14. package/extension/doors/plannotatorHandoff.ts +509 -0
  15. package/extension/doors/prReview.ts +22 -37
  16. package/extension/doors/prReviewBrowser.ts +341 -0
  17. package/extension/doors/prReviewTerminal.ts +267 -0
  18. package/extension/doors/ready.ts +2 -1
  19. package/extension/doors/selfcheck.ts +240 -6
  20. package/extension/doors/submit.ts +37 -19
  21. package/extension/doors/submitPrReview.ts +408 -0
  22. package/extension/factories/implementHere.ts +116 -0
  23. package/extension/factories/objective.ts +17 -6
  24. package/extension/factories/objectiveAuthor.ts +15 -32
  25. package/extension/factories/objectiveDraft.ts +1 -1
  26. package/extension/factories/objectivePlan.ts +16 -34
  27. package/extension/factories/objectiveSave.ts +7 -17
  28. package/extension/factories/planMode.ts +24 -38
  29. package/extension/factories/planReview.ts +291 -177
  30. package/extension/factories/planSave.ts +23 -8
  31. package/extension/index.ts +117 -20
  32. package/extension/substrate/bindingDelivery.ts +32 -10
  33. package/extension/substrate/bindings.ts +4 -2
  34. package/extension/substrate/cache.ts +36 -9
  35. package/extension/substrate/clipboard.ts +81 -0
  36. package/extension/substrate/command.ts +25 -0
  37. package/extension/substrate/config.ts +98 -76
  38. package/extension/substrate/consoleCapture.ts +90 -0
  39. package/extension/substrate/git.ts +77 -0
  40. package/extension/substrate/miniJinja.ts +480 -0
  41. package/extension/substrate/paths.ts +38 -0
  42. package/extension/substrate/prompts.ts +15 -24
  43. package/extension/substrate/providers.ts +62 -8
  44. package/extension/substrate/sessionData.ts +1 -1
  45. package/extension/substrate/sessionPointers.ts +184 -0
  46. package/extension/substrate/structuredOutput.ts +3 -1
  47. package/extension/substrate/terminalLaunch.ts +178 -0
  48. package/extension/substrate/toolGating.ts +332 -73
  49. package/extension/substrate/toolParams.ts +7 -0
  50. package/extension/substrate/workflowState.ts +54 -2
  51. package/extension/surfaces/footerProvider.ts +8 -4
  52. package/extension/surfaces/surfaces.ts +338 -13
  53. package/extension/vendor/btw/btw.ts +10 -0
  54. package/extension/worker/readOnlySession.ts +19 -6
  55. package/extension/worker/worker.ts +187 -31
  56. package/extension/workerMain.ts +14 -13
  57. package/package.json +3 -7
  58. package/prompts/README.md +56 -5
  59. package/prompts/_fixtures/cases.yaml +52 -131
  60. package/prompts/_fixtures/golden/cond_elif-a.txt +3 -0
  61. package/prompts/_fixtures/golden/cond_elif-b.txt +3 -0
  62. package/prompts/_fixtures/golden/cond_elif-c.txt +3 -0
  63. package/prompts/_fixtures/golden/cond_if-false.txt +3 -0
  64. package/prompts/_fixtures/golden/cond_if-true.txt +3 -0
  65. package/prompts/_fixtures/golden/cond_ops-1.txt +3 -0
  66. package/prompts/_fixtures/golden/cond_ops-2.txt +3 -0
  67. package/prompts/_fixtures/golden/no_trailing_nl.txt +2 -0
  68. package/prompts/_fixtures/golden/trailing_nl.txt +2 -0
  69. package/prompts/_fixtures/golden/trim_block.txt +3 -0
  70. package/prompts/_fixtures/golden/trim_inline.txt +1 -0
  71. package/prompts/_fixtures/live.yaml +396 -0
  72. package/prompts/_fixtures/templates/cond_elif.md +9 -0
  73. package/prompts/_fixtures/templates/cond_if.md +7 -0
  74. package/prompts/_fixtures/templates/cond_ops.md +3 -0
  75. package/prompts/_fixtures/templates/no_trailing_nl.md +2 -0
  76. package/prompts/_fixtures/templates/trailing_nl.md +2 -0
  77. package/prompts/_fixtures/templates/trim_block.md +5 -0
  78. package/prompts/_fixtures/templates/trim_inline.md +1 -0
  79. package/prompts/contexts/adapters/juicesharp-todo.md +7 -0
  80. package/prompts/contexts/adapters/plannotator-objective.md +7 -0
  81. package/prompts/contexts/adapters/plannotator-plan.md +6 -0
  82. package/prompts/contexts/adapters/tombell-plan.md +17 -0
  83. package/prompts/contexts/objective-authoring.md +20 -0
  84. package/prompts/contexts/plan-authoring.md +24 -0
  85. package/prompts/contexts/read-only.md +10 -0
  86. package/prompts/stages/conflict-resolution.md +4 -0
  87. package/prompts/stages/learn-code.md +8 -0
  88. package/prompts/stages/learn-docs.md +7 -6
  89. package/prompts/stages/learn-orchestrate.md +6 -0
  90. package/prompts/stages/learn.md +1 -1
  91. package/prompts/stages/objective-author/adopt.md +12 -0
  92. package/prompts/stages/objective-author/file.md +9 -0
  93. package/prompts/stages/objective-author/seed.md +9 -0
  94. package/prompts/stages/objective-plan/guidance.md +1 -1
  95. package/prompts/stages/objective-plan/seed.md +3 -2
  96. package/prompts/stages/objective-reconcile.md +7 -0
  97. package/prompts/stages/objective-replan.md +14 -0
  98. package/prompts/stages/objective-save.md +9 -0
  99. package/prompts/stages/plan-from/adopt.md +10 -0
  100. package/prompts/stages/plan-from/file.md +9 -0
  101. package/prompts/stages/pr-review-browser/active.md +11 -0
  102. package/prompts/stages/pr-review-browser/foreign.md +11 -0
  103. package/prompts/stages/pr-review-terminal/active.md +12 -0
  104. package/prompts/stages/pr-review-terminal/foreign.md +13 -0
  105. package/prompts/stages/pr-review-terminal/local.md +4 -0
  106. package/prompts/stages/pr-review.md +6 -0
  107. package/prompts/stages/replan.md +13 -0
  108. package/prompts/stages/skills/create-from.md +15 -0
  109. package/prompts/stages/skills/create.md +9 -0
  110. package/prompts/stages/skills/refine.md +9 -0
  111. package/shared/README.md +23 -13
  112. package/shared/bindings.yaml +22 -2
  113. package/shared/contracts-history.md +191 -0
  114. package/shared/contracts.md +2258 -1638
  115. package/shared/providers.yaml +8 -1
  116. package/shared/registry.yaml +10 -11
  117. package/shared/schemas/contracts/bindings.schema.json +38 -0
  118. package/shared/schemas/contracts/providers.schema.json +89 -0
  119. package/shared/schemas/contracts/registry.schema.json +98 -0
  120. package/shared/schemas/inputs/handoff-arg.schema.json +6 -0
  121. package/shared/schemas/inputs/resolve-threads-batch.schema.json +37 -0
  122. package/shared/schemas/inputs/review-post-batch.schema.json +84 -0
  123. package/shared/schemas/inputs/review-submit-batch.schema.json +66 -0
  124. package/shared/schemas/inputs/structured-roadmap-node.schema.json +102 -0
  125. package/shared/schemas/outputs/doctor-report.schema.json +236 -0
  126. package/shared/schemas/outputs/init-report.schema.json +419 -0
  127. package/shared/schemas/outputs/learn-capture.schema.json +90 -0
  128. package/shared/schemas/outputs/learn-skip.schema.json +59 -0
  129. package/shared/schemas/outputs/plan-save.schema.json +209 -0
  130. package/shared/schemas/outputs/pr-feedback.schema.json +334 -0
  131. package/shared/schemas/outputs/pr-land.schema.json +187 -0
  132. package/shared/schemas/outputs/pr-ready.schema.json +75 -0
  133. package/shared/schemas/outputs/pr-review-checkout.schema.json +69 -0
  134. package/shared/schemas/outputs/pr-review-cleanup.schema.json +54 -0
  135. package/shared/schemas/outputs/pr-review-context.schema.json +86 -0
  136. package/shared/schemas/outputs/pr-review-submit.schema.json +64 -0
  137. package/shared/schemas/outputs/pr-submit.schema.json +147 -0
  138. package/extension/doors/learnDocs.ts +0 -99
  139. package/prompts/_fixtures/golden/address-action-model.txt +0 -10
  140. package/prompts/_fixtures/golden/address-action.txt +0 -10
  141. package/prompts/_fixtures/golden/address-preview-model.txt +0 -6
  142. package/prompts/_fixtures/golden/address-preview.txt +0 -6
  143. package/prompts/_fixtures/golden/implement-github.txt +0 -8
  144. package/prompts/_fixtures/golden/learn-docs.txt +0 -8
  145. package/prompts/_fixtures/golden/learn-github.txt +0 -11
  146. package/prompts/_fixtures/golden/learn-linear.txt +0 -11
  147. package/prompts/_fixtures/golden/learn-no-ref.txt +0 -8
  148. package/prompts/_fixtures/golden/learn-other.txt +0 -8
  149. package/prompts/_fixtures/golden/objective-plan-guidance-linear.txt +0 -8
  150. package/prompts/_fixtures/golden/objective-plan-guidance.txt +0 -8
  151. package/prompts/_fixtures/golden/objective-plan-seed-linear.txt +0 -20
  152. package/prompts/_fixtures/golden/objective-plan-seed.txt +0 -15
  153. package/prompts/_fixtures/golden/objective-read-linear-nourl.txt +0 -1
  154. package/prompts/_fixtures/golden/objective-read-linear.txt +0 -1
  155. package/prompts/_fixtures/golden/plan-read-github.txt +0 -1
  156. package/prompts/_fixtures/golden/plan-read-linear.txt +0 -1
  157. package/prompts/_fixtures/golden/plan-read-other.txt +0 -1
@@ -1,46 +1,34 @@
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";
13
19
 
14
20
  /**
15
- * Tools available while read-only mode is active (mirrors plan-mode's PLAN_MODE_TOOLS).
16
- * `plan_review` is the backend-neutral review door (planReview.ts) allowlisted so the model
17
- * can request a human plan review INSIDE plan mode (review happens before the gate ever comes
18
- * off); fail-open everywhere (headless / dismissed soft-skip), so it is safe on every path.
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.
19
30
  */
20
- export const READ_ONLY_TOOLS = [
21
- "read",
22
- "grep",
23
- "find",
24
- "ls",
25
- "bash",
26
- "ask_user_question",
27
- "plan_review",
28
- // The plan_draft carve-out: plan_draft is structurally limited to the one working-plan
29
- // artifact in the run-scoped session data dir (gitignored scratch), so the read-only invariant
30
- // (worktree untouched) holds; the `tool_call` edit/write/bash blocking below is unchanged.
31
- "plan_draft",
32
- // The objective_draft twin of the plan_draft carve-out: objective_draft writes only the one
33
- // working-objective artifact in the session data dir (fixed artifact name, seam-derived
34
- // path); the gate's edit/write/bash blocking is unchanged.
35
- "objective_draft",
36
- // The `web` seam providers' research tools: the UNION of all known web-provider tool
37
- // names, allowlisted statically and inert when the package is absent (the plan_review precedent
38
- // — setActiveTools simply has nothing to enable). None mutate the repo — fetch_content's
39
- // GitHub-clone path writes only to its own cache outside the worktree, morally equivalent to the
40
- // already-allowlisted curl. perk does NOT normalize names, so all three providers' divergent
41
- // names are listed: pi-web-access (default: web_search/code_search/fetch_content/
42
- // get_search_content), @ollama/pi-web-search (ollama_web_search/ollama_web_fetch), and
43
- // @juicesharp/rpiv-web-tools (web_search shared, web_fetch).
31
+ export const WEB_RESEARCH_TOOLS: readonly string[] = [
44
32
  "web_search",
45
33
  "code_search",
46
34
  "fetch_content",
@@ -48,11 +36,16 @@ export const READ_ONLY_TOOLS = [
48
36
  "ollama_web_search",
49
37
  "ollama_web_fetch",
50
38
  "web_fetch",
51
- // pi-mono-linear's read-only tools (the [issues] backend = "linear" selection):
52
- // none mutate Linear or the repo. Foreign names are inert when the package is absent (the
53
- // pi-web-access precedent above). The mutating/sensitive tools are deliberately excluded:
54
- // linear_create_issue, linear_update_issue, linear_create_comment, linear_upload_file,
55
- // linear_upload_file_to_issue_comment, linear_configure_auth (writes ~/.pi/agent/auth.json).
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[] = [
56
49
  "linear_whoami",
57
50
  "linear_workspace_metadata",
58
51
  "linear_list_teams",
@@ -74,24 +67,247 @@ export const READ_ONLY_TOOLS = [
74
67
  "linear_list_comments",
75
68
  ];
76
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
+ ];
132
+
133
+ /**
134
+ * Tools available while read-only mode is active (mirrors plan-mode's PLAN_MODE_TOOLS).
135
+ * `plan_review` is the backend-neutral review door (planReview.ts) — allowlisted so the model
136
+ * can request a human plan review INSIDE plan mode (review happens before the gate ever comes
137
+ * off); fail-open everywhere (headless / dismissed soft-skip), so it is safe on every path.
138
+ */
139
+ export const READ_ONLY_TOOLS = [
140
+ "read",
141
+ "grep",
142
+ "find",
143
+ "ls",
144
+ "bash",
145
+ "ask_user_question",
146
+ "plan_review",
147
+ // The plan_draft carve-out: plan_draft is structurally limited to the one working-plan
148
+ // artifact in the run-scoped session data dir (gitignored scratch), so the read-only invariant
149
+ // (worktree untouched) holds; the `tool_call` edit/write/bash blocking below is unchanged.
150
+ "plan_draft",
151
+ // The objective_draft twin of the plan_draft carve-out: objective_draft writes only the one
152
+ // working-objective artifact in the session data dir (fixed artifact name, seam-derived
153
+ // path); the gate's edit/write/bash blocking is unchanged.
154
+ "objective_draft",
155
+ // The objective_node carve-out: it never touches the worktree — it delegates a bounded,
156
+ // workflow-owned node transition to the canonical Python plane (`perk objective node`). Both
157
+ // objective-plan factory paths run gated (the cold door hands off `mode: read-only`; the warm
158
+ // `/objective-plan` enters the gate before seeding), and the factory loop's
159
+ // `objective_node_claim` carrier — which the approval-driven save's node-link recovery depends
160
+ // on — can only be written by calling this tool inside the gated session. Excluding it
161
+ // silently breaks the warm `/objective-plan` path: the plan saves unlinked.
162
+ "objective_node",
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,
177
+ ];
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
+
77
299
  /** The read-only marker / custom-message type injected into context while active. */
78
300
  const MODE_CONTEXT_TYPE = "perk:mode-context";
79
301
  const READ_ONLY_MARKER = "[READ-ONLY MODE]";
80
302
 
81
- /** Exported for tests: the injected read-only mode context (interpolates the allowlist). */
82
- export const READ_ONLY_CONTEXT = `${READ_ONLY_MARKER}
83
- You are in perk read-only mode — a structurally enforced exploration mode.
84
-
85
- - You can only use: ${READ_ONLY_TOOLS.join(", ")}.
86
- - You CANNOT use edit or write (file modifications are blocked).
87
- - plan_draft is the sole sanctioned write: it writes only the working-plan artifact in the session data dir.
88
- - bash is restricted to an allowlist of read-only commands.
89
- - 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).
90
-
91
- 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
+ });
92
308
 
93
309
  // --- pure policy (copied from plan-mode/utils.ts so this primitive is self-contained; perk-owned
94
- // 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) -------
95
311
 
96
312
  const DESTRUCTIVE_PATTERNS = [
97
313
  /\brm\b/i,
@@ -126,7 +342,11 @@ const DESTRUCTIVE_PATTERNS = [
126
342
  /\bshutdown\b/i,
127
343
  /\bsystemctl\s+(start|stop|restart|enable|disable)/i,
128
344
  /\bservice\s+\S+\s+(start|stop|restart)/i,
129
- /\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,
130
350
  ];
131
351
 
132
352
  const SAFE_PATTERNS = [
@@ -195,9 +415,11 @@ const SAFE_PATTERNS = [
195
415
  /^\s*npx\s+agent-browser\b/,
196
416
  /^\s*bat\b/,
197
417
  /^\s*eza\b/,
198
- // perk's own read-only objective queries (show/next + their s/n aliases). The trailing \b keeps
199
- // the `n` alias from matching the mutating `node` subcommand; create/node/reconcile stay blocked.
200
- /^\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,
201
423
  // Read-only `gh` queries — the guidance in the managed AGENTS block ("GitHub access goes
202
424
  // through gh") must be followable in read-only sessions. Query-shaped subcommands only;
203
425
  // `gh api` stays blocked (it can POST/PATCH), as do all mutating subcommands (create/edit/
@@ -286,13 +508,16 @@ export function isReadOnlyBashCommand(command: string): boolean {
286
508
 
287
509
  // --- the controller -----------------------------------------------------------------------------
288
510
 
289
- /** 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. */
290
512
  export interface ToolGating {
291
- /** Reapply the allowlist from a rebuilt `mode` (called on session_start AND session_tree). */
292
- syncFromState(mode: string | undefined): void;
293
- /** 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.) */
294
519
  enter(ctx?: ExtensionContext): void;
295
- /** 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.) */
296
521
  exit(ctx?: ExtensionContext): void;
297
522
  /** Whether the gate is currently active (in-memory source of truth for `tool_call`). */
298
523
  isActive(): boolean;
@@ -306,23 +531,54 @@ export function registerToolGating(pi: ExtensionAPI): ToolGating {
306
531
  // In-memory gate (mirrors plan-mode's `planModeEnabled`): the authority `tool_call` consults.
307
532
  // Fail-closed — a failed sync never opens this; tool_call blocks on any internal error.
308
533
  let active = false;
309
- // 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).
310
539
  let snapshot: string[] | null = null;
311
540
 
312
- function applyActive(next: boolean): void {
313
- if (next && !active) {
314
- // 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) {
315
564
  snapshot = pi.getActiveTools();
316
- pi.setActiveTools(READ_ONLY_TOOLS);
317
- } else if (!next && active) {
318
- // on → off: restore the pre-gate snapshot. If none exists (near-unreachable — the off→on
319
- // branch always snapshots first), fall back to the FULL configured tool set
320
- // (pi.getAllTools()) like plan-mode, never a hardcoded list that would silently drop
321
- // grep/find/ls and perk's custom tools (plan_save/submit/land/learn).
322
- 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);
323
578
  snapshot = null;
324
579
  }
325
- active = next;
580
+ active = nextActive;
581
+ stageId = nextStage;
326
582
  }
327
583
 
328
584
  // Structural backstop: block writes + non-allowlisted bash while active. Fail-closed on error.
@@ -352,8 +608,11 @@ export function registerToolGating(pi: ExtensionAPI): ToolGating {
352
608
  });
353
609
 
354
610
  // Inject the hidden read-only mode context while active (display:false → not shown in transcript).
355
- pi.on("before_agent_start", async () => {
611
+ pi.on("before_agent_start", async (_event, ctx) => {
356
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;
357
616
  return {
358
617
  message: { customType: MODE_CONTEXT_TYPE, content: READ_ONLY_CONTEXT, display: false },
359
618
  };
@@ -382,16 +641,16 @@ export function registerToolGating(pi: ExtensionAPI): ToolGating {
382
641
  });
383
642
 
384
643
  return {
385
- syncFromState(mode: string | undefined): void {
386
- applyActive(isReadOnlyMode(mode));
644
+ syncFromState(mode: string | undefined, stage: string | undefined): void {
645
+ apply(isReadOnlyMode(mode), stage ?? null);
387
646
  },
388
647
  enter(_ctx?: ExtensionContext): void {
389
648
  pi.appendEntry(WORKFLOW_STATE_TYPE, { mode: "read-only" });
390
- applyActive(true);
649
+ apply(true, stageId);
391
650
  },
392
651
  exit(_ctx?: ExtensionContext): void {
393
652
  pi.appendEntry(WORKFLOW_STATE_TYPE, { mode: "read-write" });
394
- applyActive(false);
653
+ apply(false, stageId);
395
654
  },
396
655
  isActive(): boolean {
397
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
  }