@mgiles/perk 1.1.0 → 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.
- package/README.md +68 -44
- package/extension/adapters/planAdapterPlannotator.ts +27 -41
- package/extension/adapters/planAdapterTombell.ts +15 -28
- package/extension/adapters/todoAdapterJuicesharp.ts +10 -13
- package/extension/checkpoints/checkpoints.ts +19 -12
- package/extension/doors/address.ts +4 -4
- package/extension/doors/askUser.ts +12 -8
- package/extension/doors/ciExecutor.ts +21 -14
- package/extension/doors/hunkHandoff.ts +202 -0
- package/extension/doors/land.ts +31 -9
- package/extension/doors/learn.ts +2 -2
- package/extension/doors/learnFactory.ts +144 -0
- package/extension/doors/plannotatorHandoff.ts +509 -0
- package/extension/doors/prReview.ts +4 -4
- package/extension/doors/prReviewBrowser.ts +341 -0
- package/extension/doors/prReviewTerminal.ts +267 -0
- package/extension/doors/selfcheck.ts +238 -5
- package/extension/doors/submit.ts +20 -0
- package/extension/doors/submitPrReview.ts +408 -0
- package/extension/factories/objective.ts +15 -5
- package/extension/factories/objectiveAuthor.ts +15 -32
- package/extension/factories/objectiveDraft.ts +1 -1
- package/extension/factories/objectivePlan.ts +12 -10
- package/extension/factories/objectiveSave.ts +2 -2
- package/extension/factories/planMode.ts +22 -40
- package/extension/factories/planReview.ts +213 -191
- package/extension/factories/planSave.ts +7 -7
- package/extension/index.ts +83 -25
- package/extension/substrate/bindingDelivery.ts +32 -10
- package/extension/substrate/bindings.ts +4 -2
- package/extension/substrate/cache.ts +34 -7
- package/extension/substrate/clipboard.ts +81 -0
- package/extension/substrate/config.ts +88 -65
- package/extension/substrate/git.ts +43 -0
- package/extension/substrate/paths.ts +1 -1
- package/extension/substrate/prompts.ts +2 -2
- package/extension/substrate/providers.ts +62 -8
- package/extension/substrate/sessionPointers.ts +35 -6
- package/extension/substrate/structuredOutput.ts +3 -1
- package/extension/substrate/terminalLaunch.ts +178 -0
- package/extension/substrate/toolGating.ts +330 -79
- package/extension/substrate/toolParams.ts +7 -0
- package/extension/substrate/workflowState.ts +54 -2
- package/extension/surfaces/footerProvider.ts +8 -4
- package/extension/surfaces/surfaces.ts +330 -12
- package/extension/vendor/btw/btw.ts +10 -0
- package/extension/worker/readOnlySession.ts +19 -6
- package/extension/worker/worker.ts +77 -7
- package/extension/workerMain.ts +12 -13
- package/package.json +3 -3
- package/prompts/_fixtures/live.yaml +117 -2
- package/prompts/contexts/adapters/juicesharp-todo.md +7 -0
- package/prompts/contexts/adapters/plannotator-objective.md +7 -0
- package/prompts/contexts/adapters/plannotator-plan.md +6 -0
- package/prompts/contexts/adapters/tombell-plan.md +17 -0
- package/prompts/contexts/objective-authoring.md +20 -0
- package/prompts/contexts/plan-authoring.md +24 -0
- package/prompts/contexts/read-only.md +10 -0
- package/prompts/stages/conflict-resolution.md +1 -1
- package/prompts/stages/learn-code.md +1 -1
- package/prompts/stages/learn-docs.md +2 -2
- package/prompts/stages/learn-orchestrate.md +1 -1
- package/prompts/stages/objective-author/adopt.md +1 -1
- package/prompts/stages/objective-author/file.md +1 -1
- package/prompts/stages/objective-plan/guidance.md +1 -1
- package/prompts/stages/objective-plan/seed.md +1 -1
- package/prompts/stages/objective-reconcile.md +1 -1
- package/prompts/stages/objective-replan.md +1 -1
- package/prompts/stages/plan-from/adopt.md +2 -2
- package/prompts/stages/plan-from/file.md +2 -2
- package/prompts/stages/pr-review-browser/active.md +11 -0
- package/prompts/stages/pr-review-browser/foreign.md +11 -0
- package/prompts/stages/pr-review-terminal/active.md +12 -0
- package/prompts/stages/pr-review-terminal/foreign.md +13 -0
- package/prompts/stages/pr-review-terminal/local.md +4 -0
- package/prompts/stages/pr-review.md +1 -1
- package/prompts/stages/replan.md +2 -2
- package/prompts/stages/skills/create-from.md +1 -1
- package/prompts/stages/skills/create.md +1 -1
- package/prompts/stages/skills/refine.md +1 -1
- package/shared/README.md +22 -18
- package/shared/bindings.yaml +10 -2
- package/shared/contracts-history.md +24 -0
- package/shared/contracts.md +1442 -1787
- package/shared/providers.yaml +8 -1
- package/shared/registry.yaml +7 -8
- package/shared/schemas/inputs/review-submit-batch.schema.json +66 -0
- package/shared/schemas/outputs/pr-review-checkout.schema.json +69 -0
- package/shared/schemas/outputs/pr-review-cleanup.schema.json +54 -0
- package/shared/schemas/outputs/pr-review-submit.schema.json +64 -0
- package/extension/doors/learnCode.ts +0 -100
- package/extension/doors/learnDocs.ts +0 -100
- 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 {
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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 (
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
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|
|
|
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
|
|
207
|
-
// the
|
|
208
|
-
|
|
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
|
|
511
|
+
/** The API the plan-mode and read-only-stage consumers use + the lifecycle hooks index.ts wires. */
|
|
298
512
|
export interface ToolGating {
|
|
299
|
-
/**
|
|
300
|
-
|
|
301
|
-
|
|
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. (
|
|
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
|
-
//
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
649
|
+
apply(true, stageId);
|
|
399
650
|
},
|
|
400
651
|
exit(_ctx?: ExtensionContext): void {
|
|
401
652
|
pi.appendEntry(WORKFLOW_STATE_TYPE, { mode: "read-write" });
|
|
402
|
-
|
|
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 (
|
|
201
|
-
* stage
|
|
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
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
}
|