@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
@@ -0,0 +1,184 @@
1
+ // The run-cache session-pointer carrier (contracts.md §8.35) — the capture side of the cross-run
2
+ // session-pointer record. The TS twin of perk/state/session_pointers.py; both planes read/write the
3
+ // same `session-pointers.json` (the cross-plane contract is the file).
4
+ //
5
+ // Each run writes only its OWN record, keyed by `run_id`, under the SHARED MAIN CHECKOUT
6
+ // (`mainCheckoutRoot(cwd)`) so a linked-worktree run and a later resolver agree on one location. A
7
+ // run fills only the slots it owns (planning runs → `planning.*`; implement runs →
8
+ // `implementation.*`); the four class/site slots are always present (null when unset) so this
9
+ // read-modify-write merges trivially and never clobbers a sibling write.
10
+ //
11
+ // Seam doctrine — best-effort + loud-but-non-fatal: every write is wrapped so a failure (unwritable
12
+ // root, bad disk) warns to stderr and returns false, NEVER throws. A capture failure must never
13
+ // wedge the save/launch/drive it rides on. Node builtins + cache.ts + git.ts only (loads under
14
+ // `node --test`).
15
+
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
17
+ import { basename, join } from "node:path";
18
+ import { runScratchDir } from "./cache.ts";
19
+ import { mainCheckoutRoot } from "./git.ts";
20
+
21
+ export const SESSION_POINTERS_FILE = "session-pointers.json";
22
+
23
+ /** One captured session pointer (a `main` or `worker` slot of a class). */
24
+ export interface SessionPointer {
25
+ /** The session-file basename (matches the `perk:workflow-state` stamp). */
26
+ pi_session_id: string;
27
+ /** The absolute path known at capture (informational). */
28
+ session_file: string;
29
+ /** The inherited parent session (fork/replacement provenance), else null. */
30
+ parent_pi_session_id: string | null;
31
+ /** ISO-8601 capture time. */
32
+ at: string;
33
+ }
34
+
35
+ /** The two capture sites of one session class (`main` = interior, `worker` = headless). */
36
+ export interface SessionClassPointers {
37
+ main: SessionPointer | null;
38
+ worker: SessionPointer | null;
39
+ }
40
+
41
+ /** A run's full session-pointer record (`session-pointers.json`). */
42
+ export interface SessionPointers {
43
+ run_id: string;
44
+ planning: SessionClassPointers;
45
+ implementation: SessionClassPointers;
46
+ }
47
+
48
+ export type SessionClass = "planning" | "implementation";
49
+ export type SessionSite = "main" | "worker";
50
+
51
+ /** The empty four-slot record for a run (all slots null until a capture fills one). */
52
+ function emptyRecord(runId: string): SessionPointers {
53
+ return {
54
+ run_id: runId,
55
+ planning: { main: null, worker: null },
56
+ implementation: { main: null, worker: null },
57
+ };
58
+ }
59
+
60
+ function sessionPointersPath(root: string, runId: string): string {
61
+ return join(runScratchDir(root, runId), SESSION_POINTERS_FILE);
62
+ }
63
+
64
+ /**
65
+ * Read a run's session-pointers record from the shared main checkout; `null` when absent or
66
+ * unparseable (best-effort — a corrupt record is treated as absent, never thrown). `root` is the
67
+ * MAIN checkout (resolve via `mainCheckoutRoot(cwd)` at the call site).
68
+ */
69
+ export function readSessionPointers(root: string, runId: string): SessionPointers | null {
70
+ const path = sessionPointersPath(root, runId);
71
+ if (!existsSync(path)) return null;
72
+ try {
73
+ return JSON.parse(readFileSync(path, "utf8")) as SessionPointers;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Record one session pointer into `<class>.<site>` of the run's record, under the shared main
81
+ * checkout (`mainCheckoutRoot(cwd)`). Read-modify-write: an existing record is loaded (or a fresh
82
+ * four-slot record minted), only the named slot is set, and the whole record is written back — so
83
+ * a planning write and an implementation write to the SAME run record never clobber each other.
84
+ * Best-effort: returns `true` on a successful write, `false` (with a stderr warning) on any
85
+ * failure. Never throws. Serialized byte-compatibly with the Python writer (key order +
86
+ * 2-space indent + trailing newline).
87
+ *
88
+ * `preserveForeign` makes the slot first-write-wins (defense in depth against pointer
89
+ * shadowing): when set and the slot already holds a pointer whose `pi_session_id` differs from
90
+ * the incoming one, the write is SKIPPED with a loud stderr warning naming both session ids
91
+ * (returns `false`) — so any future shadow vector surfaces instead of silently corrupting
92
+ * evidence. A same-session re-capture still refreshes the slot. Default `false` keeps today's
93
+ * overwrite semantics (each default-caller slot has exactly one legitimate writer).
94
+ */
95
+ export function recordSessionPointer(
96
+ cwd: string,
97
+ runId: string,
98
+ klass: SessionClass,
99
+ site: SessionSite,
100
+ pointer: SessionPointer,
101
+ opts: { preserveForeign?: boolean } = {},
102
+ ): boolean {
103
+ if (!runId) return false;
104
+ const root = mainCheckoutRoot(cwd);
105
+ try {
106
+ const record = readSessionPointers(root, runId) ?? emptyRecord(runId);
107
+ const existing = record[klass][site];
108
+ if (
109
+ opts.preserveForeign === true &&
110
+ existing !== null &&
111
+ existing.pi_session_id !== pointer.pi_session_id
112
+ ) {
113
+ console.error(
114
+ `perk: warning: session pointer ${klass}.${site} for run ${runId} already held by ` +
115
+ `${existing.pi_session_id} — skipping foreign overwrite by ${pointer.pi_session_id}`,
116
+ );
117
+ return false;
118
+ }
119
+ // The run id is authoritative — a record read from disk keeps its own; a fresh one is minted
120
+ // with `runId`. (A mismatched on-disk run_id is left as-is; self-keying guarantees a match.)
121
+ record[klass][site] = pointer;
122
+ const dir = runScratchDir(root, runId);
123
+ mkdirSync(dir, { recursive: true });
124
+ writeFileSync(sessionPointersPath(root, runId), serialize(record), "utf8");
125
+ return true;
126
+ } catch (error) {
127
+ console.error(`perk: warning: could not record session pointer (${klass}.${site}): ${error}`);
128
+ return false;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Capture one session pointer into `<class>.<site>` from a session file path — the call-site
134
+ * convenience over `recordSessionPointer`. Derives `pi_session_id` from the basename and stamps
135
+ * `at`. A `null`/empty `sessionFile` or `runId` is a no-op (`false`) — best-effort, never throws.
136
+ * `preserveForeign` threads through to `recordSessionPointer`'s first-write-wins guard.
137
+ */
138
+ export function captureSessionPointer(args: {
139
+ cwd: string;
140
+ runId: string;
141
+ klass: SessionClass;
142
+ site: SessionSite;
143
+ sessionFile: string | null | undefined;
144
+ parentSessionId?: string | null;
145
+ preserveForeign?: boolean;
146
+ }): boolean {
147
+ const { cwd, runId, klass, site, sessionFile } = args;
148
+ if (!sessionFile || !runId) return false;
149
+ return recordSessionPointer(
150
+ cwd,
151
+ runId,
152
+ klass,
153
+ site,
154
+ {
155
+ pi_session_id: basename(sessionFile),
156
+ session_file: sessionFile,
157
+ parent_pi_session_id: args.parentSessionId ?? null,
158
+ at: new Date().toISOString(),
159
+ },
160
+ { preserveForeign: args.preserveForeign },
161
+ );
162
+ }
163
+
164
+ /** Serialize a record byte-compatibly with the Python writer (explicit key order, null slots). */
165
+ function serialize(record: SessionPointers): string {
166
+ const slot = (p: SessionPointer | null): Record<string, unknown> | null =>
167
+ p === null
168
+ ? null
169
+ : {
170
+ pi_session_id: p.pi_session_id,
171
+ session_file: p.session_file,
172
+ parent_pi_session_id: p.parent_pi_session_id,
173
+ at: p.at,
174
+ };
175
+ const payload = {
176
+ run_id: record.run_id,
177
+ planning: { main: slot(record.planning.main), worker: slot(record.planning.worker) },
178
+ implementation: {
179
+ main: slot(record.implementation.main),
180
+ worker: slot(record.implementation.worker),
181
+ },
182
+ };
183
+ return `${JSON.stringify(payload, null, 2)}\n`;
184
+ }
@@ -13,7 +13,6 @@
13
13
  import {
14
14
  type Api,
15
15
  type Context,
16
- complete,
17
16
  type Model,
18
17
  type Static,
19
18
  type Tool,
@@ -21,6 +20,9 @@ import {
21
20
  type TSchema,
22
21
  validateToolCall,
23
22
  } from "@earendil-works/pi-ai";
23
+ // `complete` (the old global API) lives on the /compat entrypoint from pi-ai 0.80; the root
24
+ // keeps the types. Pi's extension loader aliases both the root and /compat to the compat entry.
25
+ import { complete } from "@earendil-works/pi-ai/compat";
24
26
 
25
27
  /** Structurally-minimal slice of `ExtensionContext` needed to reuse the session's model + auth. */
26
28
  export interface ModelAuthContext {
@@ -0,0 +1,178 @@
1
+ // The terminal review door's R7 terminal auto-launch ladder — open hunk in a terminal the human can
2
+ // see, since hunk's TUI is constitutively a human surface (a `pi.exec` child gets pipes, no TTY).
3
+ //
4
+ // A pure `resolveTerminalLaunch` (platform + env + request → an argv + the rung tag, or `null` for
5
+ // "no launcher matched") and a thin fail-soft `launchInTerminal` runner. Never throws, never
6
+ // notifies — the caller owns messaging, and the loud print + clipboard copy are the universal
7
+ // fallback when no rung matches or a rung fails. The `hunk session get` handshake — not a spawn
8
+ // success — remains the ONLY verification the surface is actually up.
9
+ //
10
+ // The `PERK_TERMINAL_LAUNCH` env seam: unset → the platform default ladder; empty → disabled (the
11
+ // test seam); non-empty → a custom launcher receiving the worktree as `$1` and the command as
12
+ // `$2`.
13
+
14
+ import type { ExecHost } from "./coldDoor.ts";
15
+
16
+ /** Which rung of the ladder launched (or would launch) the surface. */
17
+ export type LaunchVia = "custom" | "tmux" | "ghostty" | "iterm2" | "terminal-app";
18
+
19
+ /** A human-readable name for each rung, for the door's launched-in-a-<surface> message. */
20
+ export const LAUNCH_SURFACE: Record<LaunchVia, string> = {
21
+ custom: "terminal window",
22
+ tmux: "tmux pane",
23
+ ghostty: "Ghostty window",
24
+ iterm2: "iTerm2 window",
25
+ "terminal-app": "terminal window",
26
+ };
27
+
28
+ // The macOS AppleScript rungs are `on run argv` bodies: paths + commands ride argv (never
29
+ // interpolated into the script text), so no AppleScript string-quoting hazard exists.
30
+
31
+ /** Single-quote `s` for a POSIX shell line (the standard `'\''` embedded-quote escape). */
32
+ function shQuote(s: string): string {
33
+ return `'${s.replaceAll("'", `'\\''`)}'`;
34
+ }
35
+
36
+ /**
37
+ * Wrap `command` in the human's interactive **login shell** (`$SHELL -i -l -c '<command>'`) for
38
+ * the rungs whose execution context is rc-less — Ghostty's surface `command` is argv-exec'd
39
+ * (quote-aware word split, a relative arg0 joined onto the working directory, never a shell
40
+ * line), and tmux commands run under the server environment. `-i -l` sources the human's rc
41
+ * files, so the launched window resolves binaries exactly like the human's own terminal — which
42
+ * is what the bare command needs: `hunk` AND the `node` its `#!/usr/bin/env node` shebang
43
+ * re-resolves are often on PATH only via rc activation (mise/nvm; both misses were hit live).
44
+ * `$SHELL` when absolute, else `/bin/zsh` on darwin (the macOS default) / `/bin/sh` elsewhere.
45
+ * The shell-line rungs (iTerm2/Terminal.app) type into an interactive login shell already and
46
+ * take the bare command; so does the custom launcher (it owns its own environment).
47
+ */
48
+ function interactiveShellWrap(
49
+ platform: string,
50
+ env: Record<string, string | undefined>,
51
+ command: string,
52
+ ): string {
53
+ const shell =
54
+ env.SHELL !== undefined && env.SHELL.startsWith("/")
55
+ ? env.SHELL
56
+ : platform === "darwin"
57
+ ? "/bin/zsh"
58
+ : "/bin/sh";
59
+ return `${shell} -i -l -c ${shQuote(command)}`;
60
+ }
61
+
62
+ /** Ghostty ≥ 1.3: a native surface configuration (cwd + command as argv items 1/2). */
63
+ const GHOSTTY_SCRIPT = `on run argv
64
+ tell application "Ghostty"
65
+ set cfg to (new surface configuration)
66
+ set (initial working directory of cfg) to (item 1 of argv)
67
+ set (command of cfg) to (item 2 of argv)
68
+ set (wait after command of cfg) to true
69
+ new window with configuration cfg
70
+ activate
71
+ end tell
72
+ end run`;
73
+
74
+ /** iTerm2: a new window whose current session runs the composed shell line (argv item 1). */
75
+ const ITERM_SCRIPT = `on run argv
76
+ tell application "iTerm"
77
+ set w to (create window with default profile)
78
+ tell current session of w to write text (item 1 of argv)
79
+ activate
80
+ end tell
81
+ end run`;
82
+
83
+ /** Terminal.app: the universal macOS fallback — `do script` the composed shell line (argv item 1). */
84
+ const TERMINAL_SCRIPT = `on run argv
85
+ tell application "Terminal"
86
+ do script (item 1 of argv)
87
+ activate
88
+ end tell
89
+ end run`;
90
+
91
+ export interface LaunchRequest {
92
+ /** The review worktree — the terminal's working directory. */
93
+ cwd: string;
94
+ /**
95
+ * The bare surface command to run there, e.g. `hunk diff <sha12>`. The rc-less rungs wrap it
96
+ * in the human's interactive login shell (see `interactiveShellWrap`).
97
+ */
98
+ command: string;
99
+ }
100
+
101
+ /**
102
+ * Resolve the terminal-launch argv + rung tag for `platform` + `env` + `req`, first match wins:
103
+ *
104
+ * 1. `PERK_TERMINAL_LAUNCH === ""` → `null` (the test seam / opt-out);
105
+ * 2. `PERK_TERMINAL_LAUNCH` non-empty → a custom `sh -c` launcher (worktree `$1`, command `$2`);
106
+ * 3. `TMUX` set → a `tmux split-window` pane beside pi (no consent prompts);
107
+ * 4. `darwin`, keyed off `TERM_PROGRAM` → the matching osascript rung (ghostty / iterm2 /
108
+ * terminal-app; anything unrecognized — vscode, WezTerm, absent — falls to terminal-app, the
109
+ * universal macOS fallback);
110
+ * 5. otherwise → `null` (no Linux emulator sniffing — tmux + the custom seam cover it).
111
+ *
112
+ * Rungs that set the working directory natively (ghostty's `initial working directory`, tmux's
113
+ * `-c`) receive the command wrapped in the human's interactive login shell (see
114
+ * `interactiveShellWrap` — their execution contexts are rc-less); the shell-line rungs
115
+ * (iterm2/terminal-app) receive `cd '<cwd>' && <command>` as one arg (the cwd single-quoted —
116
+ * worktree paths can carry spaces; the command is perk-composed, never quoted) typed into an
117
+ * interactive login shell the terminal itself opens.
118
+ */
119
+ export function resolveTerminalLaunch(
120
+ platform: string,
121
+ env: Record<string, string | undefined>,
122
+ req: LaunchRequest,
123
+ ): { argv: string[]; via: LaunchVia } | null {
124
+ const custom = env.PERK_TERMINAL_LAUNCH;
125
+ if (custom !== undefined) {
126
+ if (custom === "") return null;
127
+ return { argv: ["sh", "-c", custom, "sh", req.cwd, req.command], via: "custom" };
128
+ }
129
+
130
+ if (env.TMUX !== undefined && env.TMUX !== "") {
131
+ const wrapped = interactiveShellWrap(platform, env, req.command);
132
+ return { argv: ["tmux", "split-window", "-h", "-c", req.cwd, wrapped], via: "tmux" };
133
+ }
134
+
135
+ if (platform === "darwin") {
136
+ // The shell-line rungs run this through the terminal's shell — quote the cwd (paths can
137
+ // carry spaces/quotes); the command is perk-composed (`hunk diff <sha12>`), never quoted.
138
+ const shellLine = `cd ${shQuote(req.cwd)} && ${req.command}`;
139
+ const term = env.TERM_PROGRAM;
140
+ if (term === "ghostty") {
141
+ const wrapped = interactiveShellWrap(platform, env, req.command);
142
+ return { argv: ["osascript", "-e", GHOSTTY_SCRIPT, req.cwd, wrapped], via: "ghostty" };
143
+ }
144
+ if (term === "iTerm.app") {
145
+ return { argv: ["osascript", "-e", ITERM_SCRIPT, shellLine], via: "iterm2" };
146
+ }
147
+ // Apple_Terminal, vscode, WezTerm, or absent → Terminal.app (the universal macOS fallback).
148
+ return { argv: ["osascript", "-e", TERMINAL_SCRIPT, shellLine], via: "terminal-app" };
149
+ }
150
+
151
+ return null;
152
+ }
153
+
154
+ /**
155
+ * Launch the surface command in a terminal the human can see, best-effort. Resolves the rung from
156
+ * `process.platform` + `process.env`; a `null` resolution (disabled seam, unmatched platform)
157
+ * short-circuits to `{launched: false}`. Otherwise shells the argv with a 15s cap (guards an
158
+ * unanswered macOS Automation/TCC consent dialog blocking `osascript`). Fail-soft on
159
+ * throw/nonzero/killed — a denied or unanswered consent is just another failed rung, and the
160
+ * print+clipboard fallback already covers the human. Never rejects, never notifies.
161
+ */
162
+ export async function launchInTerminal(
163
+ pi: ExecHost,
164
+ ctx: { cwd: string; signal?: AbortSignal },
165
+ req: LaunchRequest,
166
+ ): Promise<{ launched: boolean; via?: LaunchVia }> {
167
+ const resolved = resolveTerminalLaunch(process.platform, process.env, req);
168
+ if (resolved === null) return { launched: false };
169
+ const [cmd, ...rest] = resolved.argv;
170
+ if (cmd === undefined) return { launched: false };
171
+ try {
172
+ const res = await pi.exec(cmd, rest, { cwd: ctx.cwd, signal: ctx.signal, timeout: 15_000 });
173
+ if (res.killed || res.code !== 0) return { launched: false };
174
+ return { launched: true, via: resolved.via };
175
+ } catch {
176
+ return { launched: false };
177
+ }
178
+ }