@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
package/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # perk
2
+
3
+ A Pi-native, plan-oriented engineering workflow — a Python `perk` CLI (the session
4
+ *exterior*) plus a TypeScript Pi extension (the session *interior*).
5
+
6
+ > Start at [`docs/user-docs/`](docs/user-docs/index.md).
7
+
8
+ > Originally based on prior art `erk`, by the team at [dagster](https://github.com/dagster-io/dagster)
9
+
10
+ ## What perk is
11
+
12
+ perk implements a plan-oriented engineering workflow (explore read-only → save a plan →
13
+ implement on a branch → submit → land → learn) to [Pi](https://github.com/earendil-works),
14
+ split across **two planes**:
15
+
16
+ - the **exterior** — a Python `perk` CLI that scaffolds repos, positions worktrees, mints
17
+ run ids, and launches primed `pi` sessions (everything that happens *outside* a session);
18
+ - the **interior** — a TypeScript Pi extension that drives stage transitions and state
19
+ *inside* a running session.
20
+
21
+ A language-neutral [`shared/`](shared/) contract (the stage registry + cross-plane specs)
22
+ is the single source both planes read, so the two stay in lockstep without a codegen step.
23
+
24
+ perk is built to **bootstrap its own development**: each phase leaves perk capable of
25
+ driving the next, and perk's own repo is the first thing it scaffolds.
26
+
27
+ ## Quickstart
28
+
29
+ perk targets any git repo. From the repo you want to wire:
30
+
31
+ ```bash
32
+ uv tool install perk # (or run from source — see Develop)
33
+ perk init # scaffold/converge Pi wiring (idempotent; safe to re-run)
34
+ perk doctor # report health; perk doctor --fix repairs drift
35
+ ```
36
+
37
+ `perk init` requires a git repo + `git`, `gh`, `node ≥ 22`, `ast-grep`, and `pi` on PATH. GitHub auth
38
+ is verified but never required (it is reported, never fatal).
39
+
40
+ For the guided first run, follow
41
+ [Get started with perk](docs/user-docs/tutorials/get-started.md).
42
+
43
+ ## Documentation
44
+
45
+ The operator-facing docs live under [`docs/user-docs/`](docs/user-docs/index.md), organized as
46
+ the four [Divio](https://docs.divio.com/documentation-system/) quadrants:
47
+
48
+ - **[Tutorials](docs/user-docs/tutorials/index.md)** — learning-oriented lessons; start with
49
+ [Get started with perk](docs/user-docs/tutorials/get-started.md).
50
+ - **[How-to guides](docs/user-docs/how-to/index.md)** — goal-oriented recipes (resume a plan,
51
+ address review feedback, switch to Linear, attach a skill, …).
52
+ - **[Reference](docs/user-docs/reference/index.md)** — the CLI surface, in-session commands &
53
+ tools, the objective roadmap model, configuration, and providers & backends.
54
+ - **[Explanation](docs/user-docs/explanation/index.md)** — how perk thinks; headless/remote
55
+ maturity.
56
+
57
+ perk's internal research and planning record lives under [`docs/`](docs/index.md) and is for
58
+ perk's own developers.
59
+
60
+ ## Layout
61
+
62
+ - `perk/` — the Python CLI (the session exterior).
63
+ - `extension/` — the TypeScript Pi extension (the session interior).
64
+ - `shared/` — cross-plane contracts (the stage registry + specs), bundled into both build
65
+ artifacts.
66
+ - `docs/` — research inputs, design notes, and durable learnings.
67
+
68
+ ## Develop
69
+
70
+ Two pinned toolchains:
71
+
72
+ - **Python** — [uv](https://docs.astral.sh/uv/) (3.13, pinned in `.python-version`),
73
+ [ruff](https://docs.astral.sh/ruff/) (lint/format), [ty](https://docs.astral.sh/ty/) (types).
74
+ - **TypeScript** — npm (Node ≥ 22, `.npmrc`), [Biome](https://biomejs.dev/) (lint/format),
75
+ `tsc` (types).
76
+
77
+ With [`just`](https://github.com/casey/just):
78
+
79
+ ```bash
80
+ just setup # uv sync + npm install + git hooks + install-cli (the `perk` CLI on PATH)
81
+ just install-cli # just the `perk` CLI on PATH (editable: tracks this clone)
82
+ just fmt # ruff format + biome format
83
+ just lint # ruff check + biome check
84
+ just typecheck # ty + tsc
85
+ just test # pytest + node:test (the regression gate)
86
+ just ci # setup + lint + typecheck + test
87
+ ```
88
+
89
+ After `just setup` (or `just install-cli`), call `perk` directly — no `uv run`. The install is
90
+ **editable**, so a `git pull` reflects Python changes live; re-run `just install-cli` after a
91
+ dependency change. It lands in uv's tool bin (`~/.local/bin`) — if `perk` isn't found, that dir
92
+ is not on your `PATH`; run `uv tool update-shell` (then restart your shell). Remove it with
93
+ `uv tool uninstall perk`.
94
+
95
+ Releasing perk → see [docs/releasing.md](docs/releasing.md) (version SSOT, dual-plane runbook,
96
+ the `validate-release-versions` tag gate).
97
+
98
+ `just setup` also runs `just hooks` (`prek install`), wiring a [prek](https://prek.j178.dev)
99
+ pre-commit hook that runs `ruff check` on staged Python (config in `prek.toml`; the ruff
100
+ env is built by prek from the remote ruff-pre-commit repo, so it never depends on a
101
+ system/`.venv` ruff). Re-run `just hooks` after a fresh clone.
102
+
103
+ Without `just`: `uv run …` for Python (`uv run perk init`, `uv run pytest`,
104
+ `uv run ruff check perk tests`, `uv run ty check`) and `npm run …` for TypeScript
105
+ (`npm run lint`, `npm run typecheck`).
@@ -0,0 +1,269 @@
1
+ // The SECOND 3rd-party plan adapter — and the first with the AUGMENT posture. A perk-owned shim
2
+ // that enables `@plannotator/pi-extension` as a REAL, selectable plan provider: unlike the tombell
3
+ // adapter (REPLACE posture — perk's plan surface fully vacates), plannotator AUGMENTS perk's plan
4
+ // flow. perk's `/plan` mode, authoring injection, and read-only gate STAY (planMode skips only the
5
+ // `--plan` flag + `Ctrl+Alt+P` shortcut — the two real registration collisions).
6
+ //
7
+ // INJECTION + BRIDGE ONLY: the `plan_review` TOOL lives in `extension/factories/planReview.ts`
8
+ // (perk's backend-neutral review door); this module is the injection-only adapter shape. It owns
9
+ // (1) the plannotator review-step authoring context (injected while the gate is active AND
10
+ // plannotator is selected — TWO content flavors, one customType: the plan bridge context, or the
11
+ // objective flavor when the stage is `objective-author`) and (2) the pure event-bus bridge
12
+ // (`createPlannotatorBridge`) that planReview.ts dispatches to when plannotator is the
13
+ // selected plan provider. The bridge speaks plannotator's published `plannotator:request` event
14
+ // API (in-process `pi.events` bus).
15
+ //
16
+ // INERT BY DEFAULT. The shim is ALWAYS registered in index.ts but the injection fires only when
17
+ // the resolved `[providers] plan` selection is `plannotator-plan` (read fresh per-event, same
18
+ // shape as planMode/planAdapterTombell). On any other selection the context handler only strips
19
+ // its own stale marker — zero behavior change on the default path.
20
+ //
21
+ // INVARIANTS HELD: never calls `setActiveTools`, never registers a `tool_call` handler, never
22
+ // restamps `cache.plan-ref.provider` (stays `"github"`). The adapter is INJECTION-ONLY again
23
+ // (Invariant 1: composes, never owns) — the review tool, the `approvalSave` composition, and the
24
+ // gate exit all live behind planReview.ts's seams; the injection's gate-active check reads the
25
+ // persisted `perk:workflow-state.mode`, the gate's own state twin.
26
+ //
27
+ // EVENT ENVELOPE (pinned against `@plannotator/pi-extension@0.20.0`, `plannotator-events.ts`):
28
+ // request — pi.events.emit("plannotator:request", { requestId, action: "plan-review",
29
+ // payload: { planContent, origin? }, respond }) // respond = in-payload callback
30
+ // handshake — respond({ status: "handled", result: { status: "pending", reviewId } })
31
+ // | respond({ status: "unavailable", error? }) | respond({ status: "error", error })
32
+ // decision — pi.events.on("plannotator:review-result", { reviewId, approved, feedback?, ... })
33
+
34
+ import { randomUUID } from "node:crypto";
35
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
36
+ import { OBJECTIVE_AUTHOR_STAGE } from "../factories/objectiveAuthor.ts";
37
+ import { resolvedPlanProviderId } from "../factories/planMode.ts";
38
+ // Type-only (erased at runtime — no cycle): the outcome vocabulary lives with the review door.
39
+ import type { ReviewOutcome } from "../factories/planReview.ts";
40
+ import { PLANNOTATOR_PLAN_PROVIDER_ID } from "../substrate/providers.ts";
41
+ import { branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
42
+
43
+ /** The plannotator plan-adapter bridge customType (distinct from planMode's `perk:plan-context`). */
44
+ export const PLAN_ADAPTER_PLANNOTATOR_CONTEXT_TYPE = "perk:plan-adapter-plannotator";
45
+ const PLAN_ADAPTER_PLANNOTATOR_MARKER = "[PLAN ADAPTER: PLANNOTATOR]";
46
+ const OBJECTIVE_ADAPTER_PLANNOTATOR_MARKER = "[OBJECTIVE ADAPTER: PLANNOTATOR]";
47
+
48
+ /**
49
+ * The handshake timeout for plannotator's immediate `respond` callback (mirrors plannotator's own
50
+ * `PLANNOTATOR_TIMEOUT_MS = 5_000`). Overridable for tests via PERK_PLANNOTATOR_HANDSHAKE_MS.
51
+ */
52
+ export const PLANNOTATOR_HANDSHAKE_TIMEOUT_MS = 5_000;
53
+
54
+ function handshakeTimeoutMs(): number {
55
+ const raw = Number(process.env.PERK_PLANNOTATOR_HANDSHAKE_MS ?? "");
56
+ return Number.isFinite(raw) && raw > 0 ? raw : PLANNOTATOR_HANDSHAKE_TIMEOUT_MS;
57
+ }
58
+
59
+ /**
60
+ * The augment-posture bridge prompt: perk's plan-authoring discipline plus the plannotator review
61
+ * step. Prompting, NOT enforcement (perk's own gate is the read-only authority). Durable anchors
62
+ * only — mirrors PLAN_AUTHORING_CONTEXT, which is also injected (perk's plan mode stays).
63
+ */
64
+ export const PLAN_ADAPTER_PLANNOTATOR_CONTEXT = `${PLAN_ADAPTER_PLANNOTATOR_MARKER}
65
+ A Plannotator browser review surface is configured for plan authoring in this repo. Author the plan
66
+ read-only exactly as the plan-authoring contract describes; then add one review step:
67
+
68
+ - Keep the working draft current with plan_draft — the validated plan-draft artifact is what gets
69
+ reviewed AND auto-saved; the plan param is only a fallback for sessions that never wrote a draft.
70
+ - When the plan is decision-complete, call the plan_review tool. The Plannotator browser UI opens
71
+ for the human reviewer.
72
+ - If the review is DENIED: revise per the returned annotations/feedback, rewrite the working draft
73
+ with plan_draft, then call plan_review again.
74
+ - If the review is APPROVED: the plan is auto-saved to GitHub and the session leaves read-only.
75
+ Do NOT re-dump the plan as a final message and do NOT tell the user to run /plan-save — relay
76
+ the save outcome (and any reviewer feedback) instead.
77
+ - If plan_review reports it was skipped or no review surface is available: fall back to presenting
78
+ the complete plan to the user; the human runs /plan-save (the manual failsafe).`;
79
+
80
+ /**
81
+ * The objective flavor of the bridge prompt, injected in an
82
+ * `objective-author` session instead of the plan flavor. An APPROVED review auto-saves the
83
+ * objective via the `objectiveApprovalSave` seam; `/objective-save` is the manual failsafe on
84
+ * the skipped/unavailable arms.
85
+ */
86
+ export const OBJECTIVE_ADAPTER_PLANNOTATOR_CONTEXT = `${OBJECTIVE_ADAPTER_PLANNOTATOR_MARKER}
87
+ A Plannotator browser review surface is configured for objective authoring in this repo. Author
88
+ the objective read-only exactly as the objective-authoring contract describes; then add one
89
+ review step:
90
+
91
+ - Keep the working objective current with objective_draft — pass the FULL prose and the FULL
92
+ structured roadmap each call (it rewrites the whole draft); never hand-write roadmap YAML.
93
+ - When the objective + roadmap are decision-complete, call the plan_review tool. The Plannotator
94
+ browser UI shows the RENDERED objective (the prose + a roadmap table) derived from the draft
95
+ artifact — never raw JSON.
96
+ - If the review is DENIED: revise per the returned annotations/feedback, rewrite the working
97
+ draft with objective_draft, then call plan_review again.
98
+ - If the review is APPROVED: the objective is auto-saved to GitHub and the session leaves
99
+ read-only — do NOT re-dump the objective as a final message and do NOT tell the user to run
100
+ /objective-save; relay the save outcome (and any reviewer feedback) instead.
101
+ - If plan_review reports it was skipped or no review surface is available: present the complete
102
+ objective + structured roadmap to the user; the human runs /objective-save (the manual
103
+ failsafe).`;
104
+
105
+ /** Whether the foreign `plannotator-plan` provider is the selected plan provider for `cwd`. */
106
+ export function isPlannotatorPlanSelected(cwd: string): boolean {
107
+ return resolvedPlanProviderId(cwd) === PLANNOTATOR_PLAN_PROVIDER_ID;
108
+ }
109
+
110
+ // ------------------------------------------------------------------ the event-bus bridge core
111
+
112
+ /** The minimal `pi.events` surface the bridge needs (mirrors pi's EventBus). */
113
+ export interface PlannotatorBus {
114
+ emit(channel: string, data: unknown): void;
115
+ on(channel: string, handler: (data: unknown) => void): void;
116
+ }
117
+
118
+ /** Plannotator's immediate `respond` handshake payload (pinned envelope, see header). */
119
+ interface HandshakeResponse {
120
+ status?: string;
121
+ error?: string;
122
+ result?: { status?: string; reviewId?: string };
123
+ }
124
+
125
+ /** The human decision arriving on `plannotator:review-result`. */
126
+ interface ReviewDecision {
127
+ approved: boolean;
128
+ feedback?: string;
129
+ }
130
+
131
+ /**
132
+ * Create the plannotator bridge over an event bus: ONE persistent `plannotator:review-result`
133
+ * listener registered up front, resolving pending reviews from a Map keyed by reviewId (no
134
+ * dependence on an undocumented `pi.events.off`). Pure over the bus → unit-testable offline with a
135
+ * fake plannotator listener.
136
+ */
137
+ export function createPlannotatorBridge(bus: PlannotatorBus): {
138
+ review(plan: string, signal?: AbortSignal): Promise<ReviewOutcome>;
139
+ } {
140
+ const pending = new Map<string, (decision: ReviewDecision) => void>();
141
+
142
+ bus.on("plannotator:review-result", (data) => {
143
+ const d = data as { reviewId?: unknown; approved?: unknown; feedback?: unknown };
144
+ if (typeof d?.reviewId !== "string") return;
145
+ const resolve = pending.get(d.reviewId);
146
+ if (resolve === undefined) return;
147
+ pending.delete(d.reviewId);
148
+ resolve({
149
+ approved: d.approved === true,
150
+ feedback: typeof d.feedback === "string" && d.feedback.trim() ? d.feedback : undefined,
151
+ });
152
+ });
153
+
154
+ async function review(plan: string, signal?: AbortSignal): Promise<ReviewOutcome> {
155
+ if (signal?.aborted) return { status: "aborted" };
156
+
157
+ // 1. Emit the request and await the immediate `respond` handshake (bounded — fail-open).
158
+ const requestId = randomUUID();
159
+ let respondResolve: (response: HandshakeResponse) => void = () => {};
160
+ const handshake = new Promise<HandshakeResponse | "timeout">((resolve) => {
161
+ respondResolve = resolve;
162
+ });
163
+ const timer = setTimeout(() => respondResolve("timeout" as never), handshakeTimeoutMs());
164
+ bus.emit("plannotator:request", {
165
+ requestId,
166
+ action: "plan-review",
167
+ payload: { planContent: plan, origin: "perk" },
168
+ respond: (response: unknown) => respondResolve(response as HandshakeResponse),
169
+ });
170
+ const response = await handshake;
171
+ clearTimeout(timer);
172
+
173
+ if (response === "timeout") {
174
+ return {
175
+ status: "unavailable",
176
+ warning: "plannotator did not respond to the review request (handshake timeout)",
177
+ };
178
+ }
179
+ if (response?.status !== "handled") {
180
+ const detail = response?.error ? `: ${response.error}` : "";
181
+ return {
182
+ status: "unavailable",
183
+ warning: `plannotator reported ${response?.status ?? "an invalid response"}${detail}`,
184
+ };
185
+ }
186
+ const reviewId = response.result?.reviewId;
187
+ if (response.result?.status !== "pending" || typeof reviewId !== "string") {
188
+ return {
189
+ status: "unavailable",
190
+ warning: "plannotator handshake returned no pending reviewId",
191
+ };
192
+ }
193
+
194
+ // 2. Await the human decision (no timeout — the reviewer takes as long as they take), but
195
+ // honor a turn abort so an interrupted session never leaks a wedged promise.
196
+ return await new Promise<ReviewOutcome>((resolve) => {
197
+ const onAbort = (): void => {
198
+ pending.delete(reviewId);
199
+ resolve({ status: "aborted" });
200
+ };
201
+ pending.set(reviewId, (decision) => {
202
+ signal?.removeEventListener("abort", onAbort);
203
+ resolve({ status: "completed", reviewId, ...decision });
204
+ });
205
+ signal?.addEventListener("abort", onAbort, { once: true });
206
+ });
207
+ }
208
+
209
+ return { review };
210
+ }
211
+
212
+ // ----------------------------------------------------------------------------- registration
213
+
214
+ /**
215
+ * Register the plannotator plan adapter: the augment-posture authoring-context injection, inert
216
+ * unless `[providers] plan = "plannotator-plan"`. INJECTION-ONLY (Invariant 1: composes, never
217
+ * owns) — the `plan_review` tool lives in planReview.ts (the backend-neutral review door), which
218
+ * dispatches to this module's bridge when plannotator is selected; the adapter itself never
219
+ * arbitrates tools and needs no gating.
220
+ */
221
+ export function registerPlanAdapterPlannotator(pi: ExtensionAPI): void {
222
+ // Inject the bridge context while the read-only gate is active AND plannotator is selected.
223
+ // Two content flavors, one customType: an objective-author session (also read-only) gets the
224
+ // objective flavor (the review surface renders the objective draft); any other
225
+ // gated stage gets the plan flavor. The gate-active check reads the persisted
226
+ // `perk:workflow-state.mode` (the gate's state twin) — never the gate itself.
227
+ pi.on("before_agent_start", async (_event, ctx) => {
228
+ if (!isPlannotatorPlanSelected(ctx.cwd)) return;
229
+ const state = rebuildWorkflowState(branchOf(ctx));
230
+ if (state.mode !== "read-only") return;
231
+ const content =
232
+ state.stage === OBJECTIVE_AUTHOR_STAGE
233
+ ? OBJECTIVE_ADAPTER_PLANNOTATOR_CONTEXT
234
+ : PLAN_ADAPTER_PLANNOTATOR_CONTEXT;
235
+ return {
236
+ message: {
237
+ customType: PLAN_ADAPTER_PLANNOTATOR_CONTEXT_TYPE,
238
+ content,
239
+ display: false,
240
+ },
241
+ };
242
+ });
243
+
244
+ // Strip the stale bridge markers (BOTH flavors) from context when plannotator-plan is no
245
+ // longer selected (same hygiene as the tombell shim), so they never linger across a deselect.
246
+ const hasMarker = (text: string): boolean =>
247
+ text.includes(PLAN_ADAPTER_PLANNOTATOR_MARKER) ||
248
+ text.includes(OBJECTIVE_ADAPTER_PLANNOTATOR_MARKER);
249
+ pi.on("context", async (event, ctx) => {
250
+ if (isPlannotatorPlanSelected(ctx.cwd)) return;
251
+ return {
252
+ messages: event.messages.filter((m) => {
253
+ const msg = m as { customType?: string; role?: string; content?: unknown };
254
+ if (msg.customType === PLAN_ADAPTER_PLANNOTATOR_CONTEXT_TYPE) return false;
255
+ if (msg.role !== "user") return true;
256
+ const content = msg.content;
257
+ if (typeof content === "string") return !hasMarker(content);
258
+ if (Array.isArray(content)) {
259
+ return !content.some(
260
+ (c) =>
261
+ (c as { type?: string; text?: string }).type === "text" &&
262
+ hasMarker((c as { text?: string }).text ?? ""),
263
+ );
264
+ }
265
+ return true;
266
+ }),
267
+ };
268
+ });
269
+ }
@@ -0,0 +1,147 @@
1
+ // The FIRST 3rd-party plan adapter. A perk-owned, injection-only bridge that re-enables
2
+ // `@tombell/pi-plan` as a REAL, selectable plan provider: it bridges that package's free-form prose
3
+ // `/plan` surface to perk's canonical produced contract (`plan_save` → `cache.plan-ref`).
4
+ //
5
+ // INERT BY DEFAULT. This shim is ALWAYS registered in index.ts but does nothing unless the resolved
6
+ // `[providers] plan` selection is `tombell-plan` (read fresh per-event, same shape as planMode). On
7
+ // any non-tombell selection it injects nothing and only strips its own stale marker — zero behavior
8
+ // change on the default path.
9
+ //
10
+ // WHAT IT DOES (and does NOT do):
11
+ // - It injects a hidden (`display:false`) `perk:plan-adapter-tombell` context that tells the model
12
+ // the foreign `/plan` surface authors a FREE-FORM PROSE plan, and directs it through perk's
13
+ // review-first discipline: keep the draft current with `plan_draft`, then call
14
+ // `plan_review` — which (for any non-plannotator selection, tombell included) runs the
15
+ // first-party in-TUI editor review, and whose APPROVED outcome auto-saves via the
16
+ // `approvalSave` seam. The injection is CONDITIONED: it fires only when perk's read-only gate
17
+ // is active (per the persisted `perk:workflow-state.mode`) OR tombell's own persisted
18
+ // `plan-mode-state` entry says plan mode is enabled — never in an objective-author session
19
+ // (objectiveAuthor.ts owns that authoring context).
20
+ // - The present + `/plan-save` flow is the explicit FAIL-OPEN fallback, not the primary path:
21
+ // it applies when the review reports skipped/unavailable, or when `@tombell/pi-plan`'s own
22
+ // interactive `/plan` `setActiveTools` restriction hides `plan_draft`/`plan_review` from the
23
+ // tool set. `/plan-save` prefers the validated draft artifact and falls back to the
24
+ // `extractPlanMarkdown` transcript scrape (planSave.ts) — no new save machinery; the shim
25
+ // only directs flow.
26
+ // - It does NOT own, replace, or duplicate the read-only gate (Invariant 1) and NEVER calls
27
+ // `setActiveTools` / registers a `tool_call` handler. The read-only tier during foreign planning
28
+ // comes from (a) perk's gate, already engaged by the cold-door launch (session_start →
29
+ // syncFromState(handoff.mode=read-only)), and (b) the foreign package's own self-enforcement for
30
+ // ad-hoc interactive `pi --plan`. The shim is purely a prompting bridge.
31
+ // - It does NOT restamp `cache.plan-ref.provider` — a tombell-authored prose plan lands with
32
+ // `provider="github"` exactly like a perk-authored plan (the authoring-provider id lives only in
33
+ // the `[providers] plan` selection; `provider` is the issue storage backend). All downstream
34
+ // stages bind only to the provider-agnostic plan-ref and are unchanged.
35
+
36
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
37
+ import { OBJECTIVE_AUTHOR_STAGE } from "../factories/objectiveAuthor.ts";
38
+ import { resolvedPlanProviderId } from "../factories/planMode.ts";
39
+ import { TOMBELL_PLAN_PROVIDER_ID } from "../substrate/providers.ts";
40
+ import { type BranchEntry, branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
41
+
42
+ /** The tombell plan-adapter bridge customType (distinct from planMode's `perk:plan-context`). */
43
+ export const PLAN_ADAPTER_TOMBELL_CONTEXT_TYPE = "perk:plan-adapter-tombell";
44
+ const PLAN_ADAPTER_TOMBELL_MARKER = "[PLAN ADAPTER: TOMBELL]";
45
+
46
+ /**
47
+ * The bridge prompt: directs the foreign free-form prose `/plan` surface into perk's review-first
48
+ * discipline (mirrors PLAN_AUTHORING_CONTEXT's review-first ending), with the present +
49
+ * `/plan-save` flow as the explicit fail-open fallback. Prompting, NOT enforcement (perk's gate,
50
+ * engaged by the cold-door launch, is the read-only authority). Durable anchors only, no line
51
+ * numbers.
52
+ */
53
+ export const PLAN_ADAPTER_TOMBELL_CONTEXT = `${PLAN_ADAPTER_TOMBELL_MARKER}
54
+ You are authoring a plan through the @tombell/pi-plan \`/plan\` surface — a read-only exploration mode
55
+ that produces a FREE-FORM PROSE plan (it emits no structured plan and no save tool of its own).
56
+
57
+ Gather before you plan, then write the plan so an executor with zero prior context can implement it
58
+ without guessing: anchor every change durably — function/class names, behavioral descriptions,
59
+ structural locations — never line numbers, and resolve every open choice before you save.
60
+
61
+ perk persists the plan to the provider-agnostic plan reference (cache.plan-ref); the objective/node
62
+ linkage and any consumed-learn numbers are recovered automatically from the launch handoff — never
63
+ try to write the plan reference yourself.
64
+
65
+ - Keep the working draft current with the plan_draft tool — the validated plan-draft artifact is
66
+ what gets reviewed AND auto-saved.
67
+ - When the plan is decision-complete, call the plan_review tool — the human reviews the draft in
68
+ perk's in-TUI editor review.
69
+ - If the review is DENIED: revise per the feedback, rewrite the draft with plan_draft, then call
70
+ plan_review again.
71
+ - If the review is APPROVED: the plan is auto-saved and the session leaves read-only. Relay the
72
+ save outcome — do NOT re-dump the plan as a final message and do NOT tell the user to run
73
+ /plan-save.
74
+ - If plan_review reports it was skipped or unavailable, OR the plan_draft/plan_review tools are not
75
+ in your tool set (this plan surface restricts tools): write the COMPLETE final plan as your last
76
+ message and present it to the user — the human runs the /plan-save command when satisfied (it
77
+ prefers the validated draft artifact and falls back to scraping your latest message, so that
78
+ final message must be the clean, complete plan and nothing else).`;
79
+
80
+ /** Whether the foreign `tombell-plan` provider is the selected plan provider for `cwd`. */
81
+ export function isTombellPlanSelected(cwd: string): boolean {
82
+ return resolvedPlanProviderId(cwd) === TOMBELL_PLAN_PROVIDER_ID;
83
+ }
84
+
85
+ /**
86
+ * Whether @tombell/pi-plan's own plan mode is enabled, per the latest `plan-mode-state` custom
87
+ * entry on the branch (the package's persisted state twin — it writes one per toggle via
88
+ * `pi.appendEntry`, latest wins, mirroring its own `session_start` rebuild). Defensive: any
89
+ * missing / malformed entry ⇒ false.
90
+ */
91
+ export function isTombellPlanModeEnabled(branch: readonly BranchEntry[]): boolean {
92
+ for (let i = branch.length - 1; i >= 0; i--) {
93
+ const entry = branch[i];
94
+ if (entry?.type !== "custom" || entry.customType !== "plan-mode-state") continue;
95
+ return entry.data?.enabled === true;
96
+ }
97
+ return false;
98
+ }
99
+
100
+ /**
101
+ * Register the tombell plan adapter: an injection-only bridge, inert unless `[providers] plan =
102
+ * "tombell-plan"`. It NEVER touches tool gating / setActiveTools (Invariant 1) and never throws.
103
+ */
104
+ export function registerPlanAdapterTombell(pi: ExtensionAPI): void {
105
+ // Inject the bridge context while the foreign tombell-plan provider is selected AND a plan
106
+ // authoring mode is on — perk's read-only gate (per the persisted `perk:workflow-state.mode`,
107
+ // the gate's state twin — never the gate object) OR tombell's own persisted `plan-mode-state`
108
+ // entry (the ad-hoc interactive `/plan` arm). Objective-author sessions are excepted
109
+ // (objectiveAuthor owns that session; mirrors the plannotator adapter's recipe).
110
+ pi.on("before_agent_start", async (_event, ctx) => {
111
+ if (!isTombellPlanSelected(ctx.cwd)) return;
112
+ const branch = branchOf(ctx);
113
+ const state = rebuildWorkflowState(branch);
114
+ if (state.stage === OBJECTIVE_AUTHOR_STAGE) return;
115
+ if (state.mode !== "read-only" && !isTombellPlanModeEnabled(branch)) return;
116
+ return {
117
+ message: {
118
+ customType: PLAN_ADAPTER_TOMBELL_CONTEXT_TYPE,
119
+ content: PLAN_ADAPTER_TOMBELL_CONTEXT,
120
+ display: false,
121
+ },
122
+ };
123
+ });
124
+
125
+ // Strip the stale bridge marker from context when tombell-plan is no longer selected (same
126
+ // hygiene planMode/objectiveAuthor/toolGating apply), so it never lingers across a deselect.
127
+ pi.on("context", async (event, ctx) => {
128
+ if (isTombellPlanSelected(ctx.cwd)) return;
129
+ return {
130
+ messages: event.messages.filter((m) => {
131
+ const msg = m as { customType?: string; role?: string; content?: unknown };
132
+ if (msg.customType === PLAN_ADAPTER_TOMBELL_CONTEXT_TYPE) return false;
133
+ if (msg.role !== "user") return true;
134
+ const content = msg.content;
135
+ if (typeof content === "string") return !content.includes(PLAN_ADAPTER_TOMBELL_MARKER);
136
+ if (Array.isArray(content)) {
137
+ return !content.some(
138
+ (c) =>
139
+ (c as { type?: string; text?: string }).type === "text" &&
140
+ ((c as { text?: string }).text ?? "").includes(PLAN_ADAPTER_TOMBELL_MARKER),
141
+ );
142
+ }
143
+ return true;
144
+ }),
145
+ };
146
+ });
147
+ }
@@ -0,0 +1,105 @@
1
+ // The FIRST 3rd-party todo adapter. A perk-owned, injection-only bridge that re-enables
2
+ // `@juicesharp/rpiv-todo` as a REAL, selectable todo provider: it carries perk's implement-progress
3
+ // discipline onto that package's checklist-overlay surface (the todo-seam mirror of
4
+ // planAdapterTombell, which bridges the plan seam).
5
+ //
6
+ // INERT BY DEFAULT. This shim is ALWAYS registered in index.ts but does nothing unless the resolved
7
+ // `[providers] todo` selection is `juicesharp-todo` (read fresh per-event via the same
8
+ // `resolvedTodoProviderId` the reference checkpoints provider uses) AND the session is an active
9
+ // workflow (`active_plan_ref != null`, the same gate the reference checkpoints provider seeds on).
10
+ // On any non-juicesharp selection it injects nothing and only strips its own stale marker — zero
11
+ // behavior change on the default path.
12
+ //
13
+ // WHAT IT DOES (and does NOT do):
14
+ // - It injects a hidden (`display:false`) `perk:todo-adapter-juicesharp` context that tells the
15
+ // model the foreign `@juicesharp/rpiv-todo` checklist overlay is the sole progress surface here
16
+ // (perk's own checkpoints stepped aside) and directs it to carry perk's
17
+ // implement-progress DISCIPLINE onto that overlay: seed it from the plan body's `## Steps` and
18
+ // mark each item complete in order — the same gather-then-advance flow perk's checkpoints embody.
19
+ // - It does NOT own, replace, or duplicate the read-only gate (Invariant 1) and NEVER calls
20
+ // `setActiveTools` / registers a `tool_call` handler (the todo seam composes no shared primitive).
21
+ // - It does NOT write perk's `perk:checkpoint` entry and does NOT revive perk's deferred marker
22
+ // scanner (Correction 2): that entry is a transient TS-only progress overlay nothing downstream
23
+ // consumes, and perk's render + scanner are already deferred. Re-populating it would
24
+ // be dead duplication — the foreign overlay is the sole, uncontested progress surface.
25
+ // - It adds NO registration-time vacating (Correction 1): unlike the plan seam (where perk and the
26
+ // foreign package both register `/plan`, forcing Pi to suffix the duplicate), the todo seam has
27
+ // no command-name collision — perk registers `/checkpoints`; the foreign overlay registers its
28
+ // own differently-named command(s) — so the runtime deferral is already sufficient.
29
+
30
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
31
+ import { resolvedTodoProviderId } from "../checkpoints/checkpoints.ts";
32
+ import { JUICESHARP_TODO_PROVIDER_ID } from "../substrate/providers.ts";
33
+ import { branchOf, rebuildWorkflowState } from "../substrate/workflowState.ts";
34
+
35
+ /** The juicesharp todo-adapter bridge customType (distinct from checkpoints' `perk:checkpoint`). */
36
+ export const TODO_ADAPTER_JUICESHARP_CONTEXT_TYPE = "perk:todo-adapter-juicesharp";
37
+ const TODO_ADAPTER_JUICESHARP_MARKER = "[TODO ADAPTER: JUICESHARP]";
38
+
39
+ /**
40
+ * The bridge prompt: carries perk's implement-progress discipline onto the foreign checklist
41
+ * overlay. Prompting, NOT enforcement (perk's checkpoint scanner is deferred under this selection).
42
+ * Durable anchors only, no line numbers.
43
+ */
44
+ export const TODO_ADAPTER_JUICESHARP_CONTEXT = `${TODO_ADAPTER_JUICESHARP_MARKER}
45
+ This implement session tracks progress through the \`@juicesharp/rpiv-todo\` checklist overlay — the
46
+ selected todo provider (\`[providers] todo = "juicesharp-todo"\`). perk's own checkpoint surface has
47
+ stepped aside (Node 3.1), so the foreign overlay is the sole progress surface here.
48
+
49
+ Carry perk's implement-progress discipline onto that overlay: seed it from the plan body's
50
+ \`## Steps\` numbered list — one checklist item per step, in order — then mark each item complete as
51
+ you finish the corresponding step, the same gather-then-advance flow perk's checkpoints embody. Use
52
+ the overlay's own controls to add and complete items; you do not need perk's \`[WIP:n]\`/\`[DONE:n]\`
53
+ markers here (perk's checkpoint scanner is deferred under this selection). If the plan has no
54
+ \`## Steps\` list, there is nothing to seed — let the overlay behave as its defaults suggest.`;
55
+
56
+ /** Whether the foreign `juicesharp-todo` provider is the selected todo provider for `cwd`. */
57
+ export function isJuicesharpTodoSelected(cwd: string): boolean {
58
+ return resolvedTodoProviderId(cwd) === JUICESHARP_TODO_PROVIDER_ID;
59
+ }
60
+
61
+ /**
62
+ * Register the juicesharp todo adapter: an injection-only bridge, inert unless `[providers] todo =
63
+ * "juicesharp-todo"` AND the session is an active workflow. It NEVER touches tool gating /
64
+ * setActiveTools (Invariant 1) and never throws.
65
+ */
66
+ export function registerTodoAdapterJuicesharp(pi: ExtensionAPI): void {
67
+ // Inject the bridge context while the foreign juicesharp-todo provider is selected AND a workflow
68
+ // is active (active_plan_ref != null) — the same active-workflow scope the reference checkpoints
69
+ // provider seeds on, so the bridge never reaches planning/objective sessions (display:false).
70
+ pi.on("before_agent_start", async (_event, ctx) => {
71
+ if (!isJuicesharpTodoSelected(ctx.cwd)) return;
72
+ const branch = branchOf(ctx);
73
+ if (rebuildWorkflowState(branch).active_plan_ref == null) return;
74
+ return {
75
+ message: {
76
+ customType: TODO_ADAPTER_JUICESHARP_CONTEXT_TYPE,
77
+ content: TODO_ADAPTER_JUICESHARP_CONTEXT,
78
+ display: false,
79
+ },
80
+ };
81
+ });
82
+
83
+ // Strip the stale bridge marker from context when juicesharp-todo is no longer selected (same
84
+ // hygiene the sibling adapters apply), so it never lingers across a deselect.
85
+ pi.on("context", async (event, ctx) => {
86
+ if (isJuicesharpTodoSelected(ctx.cwd)) return;
87
+ return {
88
+ messages: event.messages.filter((m) => {
89
+ const msg = m as { customType?: string; role?: string; content?: unknown };
90
+ if (msg.customType === TODO_ADAPTER_JUICESHARP_CONTEXT_TYPE) return false;
91
+ if (msg.role !== "user") return true;
92
+ const content = msg.content;
93
+ if (typeof content === "string") return !content.includes(TODO_ADAPTER_JUICESHARP_MARKER);
94
+ if (Array.isArray(content)) {
95
+ return !content.some(
96
+ (c) =>
97
+ (c as { type?: string; text?: string }).type === "text" &&
98
+ ((c as { text?: string }).text ?? "").includes(TODO_ADAPTER_JUICESHARP_MARKER),
99
+ );
100
+ }
101
+ return true;
102
+ }),
103
+ };
104
+ });
105
+ }