@mgiles/perk 1.0.1

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 (98) hide show
  1. package/README.md +105 -0
  2. package/extension/adapters/planAdapterPlannotator.ts +269 -0
  3. package/extension/adapters/planAdapterTombell.ts +147 -0
  4. package/extension/adapters/todoAdapterJuicesharp.ts +105 -0
  5. package/extension/checkpoints/checkpoints.ts +542 -0
  6. package/extension/checkpoints/planSteps.ts +108 -0
  7. package/extension/doors/address.ts +360 -0
  8. package/extension/doors/askUser.ts +194 -0
  9. package/extension/doors/ciExecutor.ts +583 -0
  10. package/extension/doors/land.ts +222 -0
  11. package/extension/doors/learn.ts +235 -0
  12. package/extension/doors/learnDocs.ts +99 -0
  13. package/extension/doors/lifecycleGates.ts +171 -0
  14. package/extension/doors/prReview.ts +339 -0
  15. package/extension/doors/ready.ts +86 -0
  16. package/extension/doors/selfcheck.ts +155 -0
  17. package/extension/doors/submit.ts +253 -0
  18. package/extension/factories/objective.ts +240 -0
  19. package/extension/factories/objectiveAuthor.ts +114 -0
  20. package/extension/factories/objectiveDraft.ts +343 -0
  21. package/extension/factories/objectivePlan.ts +838 -0
  22. package/extension/factories/objectiveSave.ts +285 -0
  23. package/extension/factories/planDraft.ts +140 -0
  24. package/extension/factories/planMode.ts +214 -0
  25. package/extension/factories/planReview.ts +644 -0
  26. package/extension/factories/planSave.ts +589 -0
  27. package/extension/factories/planTitle.ts +123 -0
  28. package/extension/index.ts +459 -0
  29. package/extension/substrate/bindingDelivery.ts +199 -0
  30. package/extension/substrate/bindings.ts +180 -0
  31. package/extension/substrate/cache.ts +163 -0
  32. package/extension/substrate/coldDoor.ts +226 -0
  33. package/extension/substrate/config.ts +339 -0
  34. package/extension/substrate/miniYaml.ts +262 -0
  35. package/extension/substrate/prompts.ts +35 -0
  36. package/extension/substrate/providers.ts +177 -0
  37. package/extension/substrate/registry.ts +62 -0
  38. package/extension/substrate/resources.ts +41 -0
  39. package/extension/substrate/result.ts +72 -0
  40. package/extension/substrate/runId.ts +49 -0
  41. package/extension/substrate/sessionData.ts +229 -0
  42. package/extension/substrate/structuredOutput.ts +141 -0
  43. package/extension/substrate/toolGating.ts +400 -0
  44. package/extension/substrate/toolParams.ts +106 -0
  45. package/extension/substrate/workflowState.ts +233 -0
  46. package/extension/surfaces/footerProvider.ts +43 -0
  47. package/extension/surfaces/report.ts +34 -0
  48. package/extension/surfaces/surfaces.ts +460 -0
  49. package/extension/vendor/btw/btw.ts +964 -0
  50. package/extension/vendor/btw/core.ts +153 -0
  51. package/extension/vendor/whimsical/whimsical.ts +485 -0
  52. package/extension/worker/readOnlySession.ts +282 -0
  53. package/extension/worker/worker.ts +765 -0
  54. package/extension/workerMain.ts +150 -0
  55. package/package.json +55 -0
  56. package/prompts/README.md +15 -0
  57. package/prompts/_fixtures/cases.yaml +140 -0
  58. package/prompts/_fixtures/golden/address-action-model.txt +10 -0
  59. package/prompts/_fixtures/golden/address-action.txt +10 -0
  60. package/prompts/_fixtures/golden/address-preview-model.txt +6 -0
  61. package/prompts/_fixtures/golden/address-preview.txt +6 -0
  62. package/prompts/_fixtures/golden/hello.txt +1 -0
  63. package/prompts/_fixtures/golden/implement-github.txt +8 -0
  64. package/prompts/_fixtures/golden/learn-docs.txt +8 -0
  65. package/prompts/_fixtures/golden/learn-github.txt +11 -0
  66. package/prompts/_fixtures/golden/learn-linear.txt +11 -0
  67. package/prompts/_fixtures/golden/learn-no-ref.txt +8 -0
  68. package/prompts/_fixtures/golden/learn-other.txt +8 -0
  69. package/prompts/_fixtures/golden/objective-plan-guidance-linear.txt +8 -0
  70. package/prompts/_fixtures/golden/objective-plan-guidance.txt +8 -0
  71. package/prompts/_fixtures/golden/objective-plan-seed-linear.txt +20 -0
  72. package/prompts/_fixtures/golden/objective-plan-seed.txt +15 -0
  73. package/prompts/_fixtures/golden/objective-read-linear-nourl.txt +1 -0
  74. package/prompts/_fixtures/golden/objective-read-linear.txt +1 -0
  75. package/prompts/_fixtures/golden/plan-read-github.txt +1 -0
  76. package/prompts/_fixtures/golden/plan-read-linear.txt +1 -0
  77. package/prompts/_fixtures/golden/plan-read-other.txt +1 -0
  78. package/prompts/_fixtures/golden/with_include.txt +4 -0
  79. package/prompts/_fixtures/templates/_greeting.md +1 -0
  80. package/prompts/_fixtures/templates/hello.md +1 -0
  81. package/prompts/_fixtures/templates/with_include.md +4 -0
  82. package/prompts/common/objective-read/linear.md +1 -0
  83. package/prompts/common/plan-read/github.md +1 -0
  84. package/prompts/common/plan-read/linear.md +1 -0
  85. package/prompts/common/plan-read/other.md +1 -0
  86. package/prompts/stages/address/action.md +10 -0
  87. package/prompts/stages/address/preview.md +6 -0
  88. package/prompts/stages/implement.md +8 -0
  89. package/prompts/stages/learn-docs.md +8 -0
  90. package/prompts/stages/learn.md +21 -0
  91. package/prompts/stages/objective-plan/guidance.md +12 -0
  92. package/prompts/stages/objective-plan/seed.md +20 -0
  93. package/shared/README.md +29 -0
  94. package/shared/bindings.yaml +64 -0
  95. package/shared/contracts-history.md +403 -0
  96. package/shared/contracts.md +4172 -0
  97. package/shared/providers.yaml +221 -0
  98. package/shared/registry.yaml +199 -0
@@ -0,0 +1,400 @@
1
+ // The tool-gating primitive (the keystone). Structural read-only enforcement, NOT
2
+ // prompting. Mirrors pi's authoritative `examples/extensions/plan-mode/` recipe (the
3
+ // `setActiveTools` allowlist + `tool_call` bash sub-allowlist + `before_agent_start` injection +
4
+ // `context` strip-when-off) and `preset.ts`'s snapshot-then-restore. The gate attaches to the
5
+ // existing `perk:workflow-state.mode` field (`read-only`/`read-write`) — no new registry stage.
6
+ //
7
+ // Substrate only: perk-owned plan mode and the read-only CI executor are the consumers of the
8
+ // `enter`/`exit` surface; the allowlist-restore is wired into the existing
9
+ // `session_start`/`session_tree` rebuild points.
10
+
11
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
+ import { WORKFLOW_STATE_TYPE } from "./workflowState.ts";
13
+
14
+ /**
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.
19
+ */
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).
44
+ "web_search",
45
+ "code_search",
46
+ "fetch_content",
47
+ "get_search_content",
48
+ "ollama_web_search",
49
+ "ollama_web_fetch",
50
+ "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).
56
+ "linear_whoami",
57
+ "linear_workspace_metadata",
58
+ "linear_list_teams",
59
+ "linear_get_team",
60
+ "linear_list_users",
61
+ "linear_get_user",
62
+ "linear_list_issues",
63
+ "linear_get_issue",
64
+ "linear_search_issues",
65
+ "linear_list_my_issues",
66
+ "linear_list_projects",
67
+ "linear_get_project",
68
+ "linear_list_issue_statuses",
69
+ "linear_get_issue_status",
70
+ "linear_list_labels",
71
+ "linear_list_cycles",
72
+ "linear_list_documents",
73
+ "linear_get_document",
74
+ "linear_list_comments",
75
+ ];
76
+
77
+ /** The read-only marker / custom-message type injected into context while active. */
78
+ const MODE_CONTEXT_TYPE = "perk:mode-context";
79
+ const READ_ONLY_MARKER = "[READ-ONLY MODE]";
80
+
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.`;
92
+
93
+ // --- 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) -------
95
+
96
+ const DESTRUCTIVE_PATTERNS = [
97
+ /\brm\b/i,
98
+ /\brmdir\b/i,
99
+ /\bmv\b/i,
100
+ /\bcp\b/i,
101
+ /\bmkdir\b/i,
102
+ /\btouch\b/i,
103
+ /\bchmod\b/i,
104
+ /\bchown\b/i,
105
+ /\bchgrp\b/i,
106
+ /\bln\b/i,
107
+ /\btee\b/i,
108
+ /\btruncate\b/i,
109
+ /\bdd\b/i,
110
+ /\bshred\b/i,
111
+ /(^|[^<])>(?!>)/,
112
+ />>/,
113
+ /\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
114
+ /\byarn\s+(add|remove|install|publish)/i,
115
+ /\bpnpm\s+(add|remove|install|publish)/i,
116
+ /\bpip\s+(install|uninstall)/i,
117
+ /\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
118
+ /\bbrew\s+(install|uninstall|upgrade)/i,
119
+ /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
120
+ /\bsudo\b/i,
121
+ /\bsu\b/i,
122
+ /\bkill\b/i,
123
+ /\bpkill\b/i,
124
+ /\bkillall\b/i,
125
+ /\breboot\b/i,
126
+ /\bshutdown\b/i,
127
+ /\bsystemctl\s+(start|stop|restart|enable|disable)/i,
128
+ /\bservice\s+\S+\s+(start|stop|restart)/i,
129
+ /\b(vim?|nano|emacs|code|subl)\b/i,
130
+ ];
131
+
132
+ const SAFE_PATTERNS = [
133
+ // `cd` mutates nothing — it is the common prefix for scoping a read-only query
134
+ // (`cd repo && perk objective show …`). Safe under the per-segment model: every other
135
+ // segment is still independently validated and the whole-string destructive veto is unchanged.
136
+ /^\s*cd\b/,
137
+ /^\s*cat\b/,
138
+ /^\s*head\b/,
139
+ /^\s*tail\b/,
140
+ /^\s*less\b/,
141
+ /^\s*more\b/,
142
+ /^\s*grep\b/,
143
+ /^\s*find\b/,
144
+ /^\s*ls\b/,
145
+ /^\s*pwd\b/,
146
+ /^\s*echo\b/,
147
+ /^\s*printf\b/,
148
+ /^\s*wc\b/,
149
+ /^\s*sort\b/,
150
+ /^\s*uniq\b/,
151
+ /^\s*diff\b/,
152
+ /^\s*file\b/,
153
+ /^\s*stat\b/,
154
+ /^\s*du\b/,
155
+ /^\s*df\b/,
156
+ /^\s*tree\b/,
157
+ /^\s*which\b/,
158
+ /^\s*whereis\b/,
159
+ /^\s*type\b/,
160
+ /^\s*env\b/,
161
+ /^\s*printenv\b/,
162
+ /^\s*uname\b/,
163
+ /^\s*whoami\b/,
164
+ /^\s*id\b/,
165
+ /^\s*date\b/,
166
+ /^\s*cal\b/,
167
+ /^\s*uptime\b/,
168
+ /^\s*ps\b/,
169
+ /^\s*top\b/,
170
+ /^\s*htop\b/,
171
+ /^\s*free\b/,
172
+ /^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i,
173
+ /^\s*git\s+ls-/i,
174
+ /^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i,
175
+ /^\s*yarn\s+(list|info|why|audit)/i,
176
+ /^\s*node\s+--version/i,
177
+ /^\s*python\s+--version/i,
178
+ /^\s*curl\s/i,
179
+ /^\s*wget\s+-O\s*-/i,
180
+ /^\s*jq\b/,
181
+ /^\s*sed\s+-n/i,
182
+ /^\s*awk\b/,
183
+ /^\s*rg\b/,
184
+ /^\s*fd\b/,
185
+ /^\s*ast-grep\b/,
186
+ // Browser-automation skill (.agents/skills/agent-browser): a command-keyed entry mirroring
187
+ // `ast-grep` — it gates the command, not its args. Two invocation forms: the bare global
188
+ // install on PATH, and the `npx` fallback anchored to `agent-browser` so bare `npx <anything>`
189
+ // stays blocked. Accepted known leniency: the leading-command model cannot inspect args, so
190
+ // agent-browser's own output flags (screenshot/video `--output`) can write files and its actions
191
+ // can mutate external sites — outside the gate's granularity. This is accepted and documented,
192
+ // consistent with the allowlisted `curl` / `fetch_content` GitHub-clone cache-write precedent
193
+ // (both write outside the gate). The whole-string `>`-redirect destructive veto still applies.
194
+ /^\s*agent-browser\b/,
195
+ /^\s*npx\s+agent-browser\b/,
196
+ /^\s*bat\b/,
197
+ /^\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,
201
+ // Read-only `gh` queries — the guidance in the managed AGENTS block ("GitHub access goes
202
+ // through gh") must be followable in read-only sessions. Query-shaped subcommands only;
203
+ // `gh api` stays blocked (it can POST/PATCH), as do all mutating subcommands (create/edit/
204
+ // merge/close/comment/clone/...). Destructive-wins still blocks `> file` redirects.
205
+ /^\s*gh\s+(issue|pr|repo|run|release|label)\s+(view|list|diff|status|checks)\b/i,
206
+ /^\s*gh\s+search\s+(issues|prs|code|commits|repos)\b/i,
207
+ /^\s*gh\s+auth\s+status\b/i,
208
+ ];
209
+
210
+ /**
211
+ * Split a command into top-level shell segments for the per-segment safe check. Walks the string
212
+ * character by character tracking single- and double-quote state, splitting only on UNQUOTED
213
+ * sequencing operators `;`, `&&`, `||`, and `|` (`&&`/`||` are two-char operators; a lone `|` is
214
+ * the pipe). Quoted operators must not split — load-bearing: a `|` inside `grep -iE 'a|b'` stays
215
+ * in one segment. Segments are trimmed and empties dropped.
216
+ *
217
+ * Known limitation: backslash-escaped quote characters are not handled. This is acceptable — the
218
+ * whole-string destructive veto in isReadOnlyBashCommand remains the backstop.
219
+ */
220
+ function splitTopLevelSegments(command: string): string[] {
221
+ const segments: string[] = [];
222
+ let current = "";
223
+ let quote: '"' | "'" | null = null;
224
+ for (let i = 0; i < command.length; i++) {
225
+ const ch = command[i];
226
+ if (quote) {
227
+ current += ch;
228
+ if (ch === quote) quote = null;
229
+ continue;
230
+ }
231
+ if (ch === '"' || ch === "'") {
232
+ quote = ch;
233
+ current += ch;
234
+ continue;
235
+ }
236
+ if (ch === ";" || ch === "|" || ch === "&") {
237
+ const next = command[i + 1];
238
+ if ((ch === "|" && next === "|") || (ch === "&" && next === "&")) {
239
+ // two-char operator (`||` / `&&`)
240
+ segments.push(current);
241
+ current = "";
242
+ i++;
243
+ continue;
244
+ }
245
+ if (ch === ";" || ch === "|") {
246
+ // single-char sequencing operator (`;` / `|`)
247
+ segments.push(current);
248
+ current = "";
249
+ continue;
250
+ }
251
+ // a lone `&` (background / part of `&>`): keep it in the segment so `&>` redirect detection
252
+ // and the destructive veto see it intact.
253
+ current += ch;
254
+ continue;
255
+ }
256
+ current += ch;
257
+ }
258
+ segments.push(current);
259
+ return segments.map((s) => s.trim()).filter((s) => s.length > 0);
260
+ }
261
+
262
+ /**
263
+ * Whether a bash command is allowed under read-only mode. Two independent checks:
264
+ * - NOT destructive: a WHOLE-STRING scan against DESTRUCTIVE_PATTERNS (destructive-wins — content
265
+ * anywhere in the string, incl. command substitutions, still vetoes). Two redirect carve-outs
266
+ * are neutralized first: FD duplications (`2>&1`, `1>&2`) and redirects to `/dev/null`
267
+ * (`>/dev/null`, `2>/dev/null`, `&>/dev/null`, `>>/dev/null`) — both discard output and write
268
+ * nothing to the filesystem. Redirects to a REAL path (`> file`, `&> file`, `>> file`) are NOT
269
+ * carved out and stay destructive.
270
+ * - SAFE per segment: split into quote-aware top-level segments (on `;`/`&&`/`||`/`|`) and require
271
+ * EVERY segment's leading command to match a SAFE_PATTERNS entry. This unblocks `cd`-prefixed
272
+ * chains and tightens the model — a non-safe command anywhere in a chain is now blocked, not
273
+ * just when it leads.
274
+ * Pure → unit-testable offline.
275
+ */
276
+ export function isReadOnlyBashCommand(command: string): boolean {
277
+ const withoutFdRedirects = command
278
+ .replace(/\d*>&\d+/g, " ")
279
+ .replace(/(?:\d+|&)?>>?\s*\/dev\/null\b/g, " ");
280
+ const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(withoutFdRedirects));
281
+ const segments = splitTopLevelSegments(command);
282
+ const isSafe =
283
+ segments.length > 0 && segments.every((seg) => SAFE_PATTERNS.some((p) => p.test(seg)));
284
+ return !isDestructive && isSafe;
285
+ }
286
+
287
+ // --- the controller -----------------------------------------------------------------------------
288
+
289
+ /** The API T2/T5 consume + the lifecycle hooks index.ts wires. */
290
+ 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.) */
294
+ enter(ctx?: ExtensionContext): void;
295
+ /** Exit read-only mode: persist `mode=read-write` + restore tools. (T2/T5 call site.) */
296
+ exit(ctx?: ExtensionContext): void;
297
+ /** Whether the gate is currently active (in-memory source of truth for `tool_call`). */
298
+ isActive(): boolean;
299
+ }
300
+
301
+ function isReadOnlyMode(mode: string | undefined): boolean {
302
+ return mode === "read-only";
303
+ }
304
+
305
+ export function registerToolGating(pi: ExtensionAPI): ToolGating {
306
+ // In-memory gate (mirrors plan-mode's `planModeEnabled`): the authority `tool_call` consults.
307
+ // Fail-closed — a failed sync never opens this; tool_call blocks on any internal error.
308
+ let active = false;
309
+ // Pre-gate tool snapshot, taken once on the off→on transition (preset.ts discipline).
310
+ let snapshot: string[] | null = null;
311
+
312
+ function applyActive(next: boolean): void {
313
+ if (next && !active) {
314
+ // off → on: snapshot the current tool set, then restrict.
315
+ 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));
323
+ snapshot = null;
324
+ }
325
+ active = next;
326
+ }
327
+
328
+ // Structural backstop: block writes + non-allowlisted bash while active. Fail-closed on error.
329
+ pi.on("tool_call", async (event) => {
330
+ try {
331
+ if (!active) return;
332
+ if (event.toolName === "edit" || event.toolName === "write") {
333
+ return {
334
+ block: true,
335
+ reason: `perk read-only mode: ${event.toolName} is blocked (file modifications disabled).`,
336
+ };
337
+ }
338
+ if (event.toolName === "bash") {
339
+ const command = String((event.input as { command?: unknown }).command ?? "");
340
+ if (!isReadOnlyBashCommand(command)) {
341
+ return {
342
+ block: true,
343
+ reason: `perk read-only mode: command blocked (not allowlisted).\nCommand: ${command}`,
344
+ };
345
+ }
346
+ }
347
+ return;
348
+ } catch {
349
+ // Never let an internal error open the gate — fail closed.
350
+ return { block: true, reason: "perk read-only mode: blocked (internal gating error)." };
351
+ }
352
+ });
353
+
354
+ // Inject the hidden read-only mode context while active (display:false → not shown in transcript).
355
+ pi.on("before_agent_start", async () => {
356
+ if (!active) return;
357
+ return {
358
+ message: { customType: MODE_CONTEXT_TYPE, content: READ_ONLY_CONTEXT, display: false },
359
+ };
360
+ });
361
+
362
+ // Strip the stale read-only marker from context when the gate is off (so it never lingers).
363
+ pi.on("context", async (event) => {
364
+ if (active) return;
365
+ return {
366
+ messages: event.messages.filter((m) => {
367
+ const msg = m as { customType?: string; role?: string; content?: unknown };
368
+ if (msg.customType === MODE_CONTEXT_TYPE) return false;
369
+ if (msg.role !== "user") return true;
370
+ const content = msg.content;
371
+ if (typeof content === "string") return !content.includes(READ_ONLY_MARKER);
372
+ if (Array.isArray(content)) {
373
+ return !content.some(
374
+ (c) =>
375
+ (c as { type?: string; text?: string }).type === "text" &&
376
+ ((c as { text?: string }).text ?? "").includes(READ_ONLY_MARKER),
377
+ );
378
+ }
379
+ return true;
380
+ }),
381
+ };
382
+ });
383
+
384
+ return {
385
+ syncFromState(mode: string | undefined): void {
386
+ applyActive(isReadOnlyMode(mode));
387
+ },
388
+ enter(_ctx?: ExtensionContext): void {
389
+ pi.appendEntry(WORKFLOW_STATE_TYPE, { mode: "read-only" });
390
+ applyActive(true);
391
+ },
392
+ exit(_ctx?: ExtensionContext): void {
393
+ pi.appendEntry(WORKFLOW_STATE_TYPE, { mode: "read-write" });
394
+ applyActive(false);
395
+ },
396
+ isActive(): boolean {
397
+ return active;
398
+ },
399
+ };
400
+ }
@@ -0,0 +1,106 @@
1
+ // The tool-boundary decode seam — the sibling of coldDoor.ts's JSON-boundary decode. Registered
2
+ // tools receive their LLM-supplied `params` as `unknown` (the SDK's
3
+ // `Static<TSchema>` default); each handler NARROWS that unknown here instead of asserting a shape
4
+ // with `params as {…}`. The live LLM path is already schema-validated by pi's agent loop before
5
+ // `execute`, so these helpers are type-honesty plus defense-in-depth for the unvalidated direct
6
+ // paths (the test harness's `invokeTool`; any future programmatic caller).
7
+ //
8
+ // Tri-state by design: the uniform strict-fail policy needs ABSENT (decode to undefined — current
9
+ // optional-field behavior) distinguished from PRESENT-BUT-MISTYPED (the `null` invalid sentinel —
10
+ // fail the call with `bad_input`). That is exactly why coldDoor.ts's lenient two-state field
11
+ // helpers (absent OR mistyped → undefined) are not reused here — different boundary, different
12
+ // semantics.
13
+
14
+ /** A narrowed tool-call params object. */
15
+ export type ToolParams = Record<string, unknown>;
16
+
17
+ /** Narrow unknown tool-call params to a plain object; null when not one. */
18
+ export function paramsOf(params: unknown): ToolParams | null {
19
+ if (typeof params !== "object" || params === null || Array.isArray(params)) return null;
20
+ return params as ToolParams;
21
+ }
22
+
23
+ /** Tri-state string field: undefined = absent; null = present-but-mistyped. */
24
+ export function stringParam(p: ToolParams, key: string): string | undefined | null {
25
+ const value = p[key];
26
+ if (value === undefined) return undefined;
27
+ return typeof value === "string" ? value : null;
28
+ }
29
+
30
+ /** Tri-state number field: undefined = absent; null = present-but-mistyped. */
31
+ export function numberParam(p: ToolParams, key: string): number | undefined | null {
32
+ const value = p[key];
33
+ if (value === undefined) return undefined;
34
+ return typeof value === "number" ? value : null;
35
+ }
36
+
37
+ /** Tri-state string-array field: every element must be a string; null on any mismatch. */
38
+ export function stringArrayParam(p: ToolParams, key: string): string[] | undefined | null {
39
+ const value = p[key];
40
+ if (value === undefined) return undefined;
41
+ if (!Array.isArray(value)) return null;
42
+ const out: string[] = [];
43
+ for (const item of value) {
44
+ if (typeof item !== "string") return null;
45
+ out.push(item);
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /** Tri-state number-array field: every element must be a number; null on any mismatch. */
51
+ export function numberArrayParam(p: ToolParams, key: string): number[] | undefined | null {
52
+ const value = p[key];
53
+ if (value === undefined) return undefined;
54
+ if (!Array.isArray(value)) return null;
55
+ const out: number[] = [];
56
+ for (const item of value) {
57
+ if (typeof item !== "number") return null;
58
+ out.push(item);
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /**
64
+ * Tri-state issue-id field: a string passes through; a number coerces via `String()` (models
65
+ * routinely echo numeric GitHub ids un-quoted); anything else → null. Issue ids are opaque
66
+ * strings at every boundary (contracts §8.21).
67
+ */
68
+ export function idParam(p: ToolParams, key: string): string | undefined | null {
69
+ const value = p[key];
70
+ if (value === undefined) return undefined;
71
+ if (typeof value === "string") return value;
72
+ if (typeof value === "number") return String(value);
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * Tri-state issue-id-array field: strings pass through; numbers coerce via `String()` (models
78
+ * are routinely shown bare numeric ids — e.g. the learn-docs guidance — and may echo them as
79
+ * numbers); anything else → null. Issue ids are opaque strings at every boundary (contracts
80
+ * §8.21).
81
+ */
82
+ export function idArrayParam(p: ToolParams, key: string): string[] | undefined | null {
83
+ const value = p[key];
84
+ if (value === undefined) return undefined;
85
+ if (!Array.isArray(value)) return null;
86
+ const out: string[] = [];
87
+ for (const item of value) {
88
+ if (typeof item !== "string" && typeof item !== "number") return null;
89
+ out.push(String(item));
90
+ }
91
+ return out;
92
+ }
93
+
94
+ /** Tri-state array field: any array passes (elements stay unknown); null on a non-array. */
95
+ export function arrayParam(p: ToolParams, key: string): unknown[] | undefined | null {
96
+ const value = p[key];
97
+ if (value === undefined) return undefined;
98
+ return Array.isArray(value) ? [...value] : null;
99
+ }
100
+
101
+ /** Tri-state plain-object field: arrays/null/non-objects are invalid. */
102
+ export function objectParam(p: ToolParams, key: string): ToolParams | undefined | null {
103
+ const value = p[key];
104
+ if (value === undefined) return undefined;
105
+ return paramsOf(value) ?? null;
106
+ }