@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,123 @@
1
+ // The first consumer of the structured-output substrate: a best-effort, fail-safe
2
+ // LLM-generated GitHub issue title for a perk plan. When a plan is saved WITHOUT an explicit title,
3
+ // the warm door asks the session model for a structured `{ title, category }` object and forwards
4
+ // the sanitized title to `perk plan save --title`. Every failure mode (no model, unresolved auth,
5
+ // the offline gate, a model error, no tool call, schema-invalid args, an empty sanitized title)
6
+ // returns `null`, so the cold door's deterministic `plan.derive_title` fallback takes over and a
7
+ // save is never blocked. The `category` field exercises multi-field structured output but is
8
+ // intentionally ignored for now.
9
+
10
+ import { type Static, StringEnum, Type } from "@earendil-works/pi-ai";
11
+ import {
12
+ completeStructured,
13
+ type ModelAuthContext,
14
+ resolveModelAuth,
15
+ } from "../substrate/structuredOutput.ts";
16
+
17
+ /** The structured result schema. `StringEnum` (not `Type.Enum`) per pi-ai's Google-compat guidance. */
18
+ const PlanTitleSchema = Type.Object({
19
+ title: Type.String({
20
+ minLength: 1,
21
+ maxLength: 120,
22
+ description:
23
+ "A concise, imperative GitHub issue title for the plan (no trailing period, no markdown).",
24
+ }),
25
+ category: StringEnum(["feature", "fix", "refactor", "docs", "test", "chore"], {
26
+ description: "The dominant kind of change (currently informational only).",
27
+ }),
28
+ });
29
+
30
+ export type PlanTitleResult = Static<typeof PlanTitleSchema>;
31
+
32
+ /** Cap the plan markdown handed to the model (a title needs only the opening, not the whole plan). */
33
+ export const TITLE_INPUT_CHAR_CAP = 12000;
34
+
35
+ /** Max characters in the final issue title (mirrors the schema's `maxLength`). */
36
+ const TITLE_MAX_CHARS = 120;
37
+
38
+ /**
39
+ * Normalize a raw model title into a clean, single-line issue title, or `null` if empty after
40
+ * sanitizing. Pure: trims; strips a leading `"# "`; strips surrounding quotes/backticks; collapses
41
+ * internal whitespace/newlines to single spaces; truncates to ≤120 chars on a word boundary where
42
+ * practical.
43
+ */
44
+ export function sanitizeTitle(raw: string): string | null {
45
+ let s = raw.trim();
46
+ if (!s) return null;
47
+ // Strip a leading ATX heading marker (the model sometimes echoes the plan's `# ` heading).
48
+ s = s.replace(/^#+\s*/, "");
49
+ if (!s) return null;
50
+ // Strip a single layer of surrounding quotes or backticks.
51
+ const pairs: [string, string][] = [
52
+ ['"', '"'],
53
+ ["'", "'"],
54
+ ["`", "`"],
55
+ ];
56
+ for (const [open, close] of pairs) {
57
+ if (s.length >= 2 && s.startsWith(open) && s.endsWith(close)) {
58
+ s = s.slice(1, -1).trim();
59
+ break;
60
+ }
61
+ }
62
+ // Collapse any internal whitespace/newlines to single spaces.
63
+ s = s.replace(/\s+/g, " ").trim();
64
+ if (!s) return null;
65
+ if (s.length > TITLE_MAX_CHARS) {
66
+ const head = s.slice(0, TITLE_MAX_CHARS);
67
+ const lastSpace = head.lastIndexOf(" ");
68
+ // Truncate on a word boundary when one exists past the halfway point; else a hard cut.
69
+ s = (lastSpace > TITLE_MAX_CHARS / 2 ? head.slice(0, lastSpace) : head).trim();
70
+ }
71
+ return s || null;
72
+ }
73
+
74
+ /**
75
+ * The deterministic offline gate. `PERK_NO_LLM` (set by the test harness, never by the production
76
+ * `perk` CLI) disables title generation so tests stay fully offline regardless of ambient API keys.
77
+ * Pure and unit-testable.
78
+ */
79
+ export function llmTitlesEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
80
+ return !env.PERK_NO_LLM;
81
+ }
82
+
83
+ /**
84
+ * Best-effort: generate a GitHub issue title for `planMarkdown` via the session model, or `null` on
85
+ * any failure / when the gate is on. Never throws. Reports a genuine model error
86
+ * (`console.error`) but stays non-fatal — the save proceeds and the cold door derives the title.
87
+ */
88
+ export async function generatePlanTitle(
89
+ ctx: ModelAuthContext,
90
+ planMarkdown: string,
91
+ signal?: AbortSignal,
92
+ ): Promise<string | null> {
93
+ if (!llmTitlesEnabled()) return null;
94
+
95
+ let auth: Awaited<ReturnType<typeof resolveModelAuth>>;
96
+ try {
97
+ auth = await resolveModelAuth(ctx);
98
+ } catch {
99
+ return null;
100
+ }
101
+ if (!auth.ok) return null; // silent: no model / no auth configured (fail-safe).
102
+
103
+ const outcome = await completeStructured({
104
+ model: auth.model,
105
+ schema: PlanTitleSchema,
106
+ toolName: "set_plan_title",
107
+ toolDescription: "Provide the chosen title and category for the plan.",
108
+ system: "You write concise, descriptive GitHub issue titles for software engineering plans.",
109
+ instruction:
110
+ "Read this implementation plan and choose a title and category. The title must be a concise, " +
111
+ "imperative phrase, at most ~70 characters, with no trailing period and no markdown.",
112
+ input: planMarkdown.slice(0, TITLE_INPUT_CHAR_CAP),
113
+ apiKey: auth.apiKey,
114
+ headers: auth.headers,
115
+ signal,
116
+ });
117
+ if (!outcome.ok || !outcome.value) {
118
+ // Report (don't swallow) a genuine model error; the save still proceeds.
119
+ if (!outcome.ok) console.error(`perk: plan-title — ${outcome.error}`);
120
+ return null;
121
+ }
122
+ return sanitizeTitle(outcome.value.title);
123
+ }
@@ -0,0 +1,459 @@
1
+ // perk Pi extension — the session *interior*.
2
+ //
3
+ // The tier-3 session-state mechanics (contracts.md §8.2/§8.3): claim PERK_RUN_ID on
4
+ // `session_start` (verified-linkage), rebuild `perk:workflow-state` on `session_start` AND
5
+ // `session_tree` (per-field LWW), and derive a child run_id on fork.
6
+
7
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
8
+ import { basename, join } from "node:path";
9
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import { registerPlanAdapterPlannotator } from "./adapters/planAdapterPlannotator.ts";
11
+ import { registerPlanAdapterTombell } from "./adapters/planAdapterTombell.ts";
12
+ import { registerTodoAdapterJuicesharp } from "./adapters/todoAdapterJuicesharp.ts";
13
+ import { registerCheckpoints } from "./checkpoints/checkpoints.ts";
14
+ import { registerAddress } from "./doors/address.ts";
15
+ import { registerAskUser } from "./doors/askUser.ts";
16
+ import { registerCiExecutor } from "./doors/ciExecutor.ts";
17
+ import { registerLand } from "./doors/land.ts";
18
+ import { registerLearn } from "./doors/learn.ts";
19
+ import { registerLearnDocs } from "./doors/learnDocs.ts";
20
+ import { registerLifecycleGates } from "./doors/lifecycleGates.ts";
21
+ import { registerPrReview } from "./doors/prReview.ts";
22
+ import { registerReady } from "./doors/ready.ts";
23
+ import { registerSelfcheck } from "./doors/selfcheck.ts";
24
+ import { registerSubmit } from "./doors/submit.ts";
25
+ import { registerObjective } from "./factories/objective.ts";
26
+ import { registerObjectiveAuthor } from "./factories/objectiveAuthor.ts";
27
+ import { registerObjectiveDraft } from "./factories/objectiveDraft.ts";
28
+ import { registerObjectivePlan } from "./factories/objectivePlan.ts";
29
+ import { registerObjectiveSave } from "./factories/objectiveSave.ts";
30
+ import { registerPlanDraft } from "./factories/planDraft.ts";
31
+ import { registerPlanMode } from "./factories/planMode.ts";
32
+ import { registerPlanReview } from "./factories/planReview.ts";
33
+ import { registerPlanSave } from "./factories/planSave.ts";
34
+ import { registerBindingDelivery } from "./substrate/bindingDelivery.ts";
35
+ import {
36
+ ensureRunScratch,
37
+ markHandoffConsumed,
38
+ readHandoff,
39
+ readPlanRef,
40
+ setMarker,
41
+ } from "./substrate/cache.ts";
42
+ import { loadRegistry, type Registry, stageConsumesPlanRef } from "./substrate/registry.ts";
43
+ import { perkVersion, sharedDir } from "./substrate/resources.ts";
44
+ import { mintRunId } from "./substrate/runId.ts";
45
+ import { registerToolGating } from "./substrate/toolGating.ts";
46
+ import {
47
+ appendWorkflowState,
48
+ branchOf,
49
+ decideClaim,
50
+ planRefsEqual,
51
+ rebuildWorkflowState,
52
+ resolveRunStage,
53
+ WORKFLOW_STATE_TYPE,
54
+ type WorkflowState,
55
+ } from "./substrate/workflowState.ts";
56
+ import { isPerkFooterReferenceSelected } from "./surfaces/footerProvider.ts";
57
+ import { report } from "./surfaces/report.ts";
58
+ import { createPerkStatus, installPerkFooter } from "./surfaces/surfaces.ts";
59
+ import { registerBtw } from "./vendor/btw/btw.ts";
60
+ import { registerWhimsical } from "./vendor/whimsical/whimsical.ts";
61
+
62
+ // Cross-plane proof marker (TS writes via cache.ts; the Python helper reads it — gate check 3).
63
+ const T3_MARKER = "t3-extension-cache-write";
64
+
65
+ function writeT3Sentinel(
66
+ cwd: string,
67
+ source: string,
68
+ state: WorkflowState,
69
+ runMode: string | null,
70
+ ): void {
71
+ try {
72
+ const dir = join(cwd, ".pi", "workflow");
73
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
74
+ writeFileSync(
75
+ join(dir, ".perk-t3.json"),
76
+ `${JSON.stringify({
77
+ source,
78
+ // The launch *workflow* mode (read-only/read-write) — drives tool gating.
79
+ run_id: state.run_id ?? null,
80
+ mode: state.mode ?? null,
81
+ // The Pi *run* mode (tui/rpc/json/print) — observability `hasUI` can't express. Distinct
82
+ // from the workflow `mode` above; recorded straight from `ctx.mode`.
83
+ run_mode: runMode,
84
+ predecessor: state.predecessor ?? null,
85
+ pi_session_id: state.pi_session_id ?? null,
86
+ active_plan_ref: state.active_plan_ref ?? null,
87
+ })}\n`,
88
+ "utf8",
89
+ );
90
+ } catch {
91
+ // never throw from a probe
92
+ }
93
+ }
94
+
95
+ export default function (pi: ExtensionAPI) {
96
+ const version = perkVersion();
97
+
98
+ // The read-only tool-gating primitive. Attaches to perk:workflow-state.mode; synced on
99
+ // both session_start AND session_tree below. enter/exit are the surface the gated stages consume.
100
+ const gating = registerToolGating(pi);
101
+
102
+ // Vendored `btw`: a `/btw` human-only side-chat popover backed by an isolated in-memory
103
+ // AgentSession. Takes `gating` for the gate-mirror — its side-session toolset + cache key follow
104
+ // perk's read-only gate (`sideSessionTools`), so the isolated session never bypasses the read-only
105
+ // guarantee. Its `ctx.ui.custom` overlay is the ONE sanctioned charter exception (§6 D6): human-
106
+ // invoked only, `hasUI`-gated, no model tool, not a stage/door — never machine-reachable.
107
+ registerBtw(pi, gating);
108
+
109
+ // Vendored `whimsical`: flavors pi's default working-message label with a random phrase per
110
+ // turn, via the headless-no-op `setWorkingMessage` surfaces seam. Always on, no config toggle.
111
+ registerWhimsical(pi);
112
+
113
+ // perk-owned plan mode: the `/plan` + Ctrl+Alt+P + `--plan` toggle surface over the
114
+ // read-only gate, plus the plan-authoring context injection. perk owns plan mode end-to-end now (the
115
+ // borrowed `@tombell/pi-plan` is retired).
116
+ registerPlanMode(pi, gating);
117
+
118
+ // The first 3rd-party plan adapter: a perk-owned, injection-only bridge that re-enables
119
+ // `@tombell/pi-plan` as a real plan provider. Always registered, but INERT unless
120
+ // `[providers] plan = "tombell-plan"`; it directs the foreign free-form prose `/plan` surface into
121
+ // perk's canonical `plan_save` → `cache.plan-ref` contract. It needs no `gating` (Invariant 1: the
122
+ // read-only gate stays perk's, engaged by the cold-door launch — the shim never arbitrates tools).
123
+ registerPlanAdapterTombell(pi);
124
+
125
+ // The second 3rd-party plan adapter — AUGMENT posture: `@plannotator/pi-extension` contributes
126
+ // its browser plan-review UI while perk's plan surface + gate stay (planMode skips only
127
+ // `--plan`/`Ctrl+Alt+P` under this selection). Always registered, but INERT unless
128
+ // `[providers] plan = "plannotator-plan"`. Injection-only — the `plan_review`
129
+ // tool moved to planReview.ts (below), which dispatches to this adapter's event-bus bridge
130
+ // when plannotator is selected.
131
+ registerPlanAdapterPlannotator(pi);
132
+
133
+ // `plan_review`, perk's UNIVERSAL review door: plannotator-selected → the event-bus
134
+ // bridge; ANY other selection → the first-party in-TUI editor review. It takes `gating` only to
135
+ // COMPOSE the approvalSave seam on an APPROVED review (auto-save → D1a gate exit) — Invariant 1
136
+ // holds: the door composes the gate through the seam, never owns it.
137
+ registerPlanReview(pi, gating);
138
+
139
+ // Objective-author context injection (the objective mirror of plan mode's authoring
140
+ // half). Keyed off (read-only gate AND stage === objective-author); planMode defers to it.
141
+ registerObjectiveAuthor(pi, gating);
142
+ let sharedOk = false;
143
+ try {
144
+ sharedDir();
145
+ sharedOk = true;
146
+ } catch {
147
+ sharedOk = false;
148
+ }
149
+
150
+ let registry: Registry | null = null;
151
+ let registryStages = -1;
152
+ try {
153
+ registry = loadRegistry();
154
+ registryStages = registry.stages.length;
155
+ } catch {
156
+ registry = null;
157
+ registryStages = -1;
158
+ }
159
+ const registryOk = registryStages > 0;
160
+
161
+ // The composed `perk` status handle (charter D2): one slot, ordered objective →
162
+ // checkpoints segments. Created once here (no hidden module state) and threaded into the two
163
+ // segment publishers below; the footer reads it back via get/subscribe.
164
+ const perkStatus = createPerkStatus();
165
+
166
+ // Install the perk-owned footer once per session (charter D2/D7). Once-only: pi's
167
+ // dispose contract for a REPLACED footer factory is unverified, so re-installing on every
168
+ // session_start (reload) could leak the previous handle subscription.
169
+ let footerInstalled = false;
170
+
171
+ pi.on("session_start", async (_event, ctx) => {
172
+ const branchEntries = () => branchOf(ctx);
173
+ const sessionFile = ctx.sessionManager.getSessionFile();
174
+ const currentSessionId = sessionFile ? basename(sessionFile) : null;
175
+
176
+ // Headless-safe linkage failure: loud (notify if UI + stderr), non-fatal, leaves unclaimed.
177
+ const reportError = (message: string) => {
178
+ report(ctx, "workflow-state linkage error", "error", message, { alsoLog: true });
179
+ };
180
+
181
+ const decision = decideClaim({
182
+ state: rebuildWorkflowState(branchEntries()),
183
+ currentSessionId,
184
+ envRunId: process.env.PERK_RUN_ID ?? null,
185
+ cwd: ctx.cwd,
186
+ });
187
+
188
+ let resolved: WorkflowState = decision.action === "claim" ? {} : decision.state;
189
+ let minted = false;
190
+
191
+ if (decision.action === "claim") {
192
+ // Cold claim — establish before consume (strict).
193
+ const handoff = readHandoff(ctx.cwd, decision.runId);
194
+ if (handoff === null || handoff.run_id !== decision.runId) {
195
+ reportError(`handoff missing or mismatched for run ${decision.runId}`);
196
+ } else {
197
+ const data: WorkflowState = {
198
+ run_id: decision.runId,
199
+ pi_session_id: currentSessionId ?? undefined,
200
+ mode: handoff.mode,
201
+ // Record the launched stage so the interior can tell e.g. objective-author from plan
202
+ // (both are read-only) and inject the right authoring context (planMode vs objectiveAuthor).
203
+ stage: handoff.stage,
204
+ };
205
+ const okAppend = appendWorkflowState(pi, ctx, {
206
+ data,
207
+ field: "run_id",
208
+ expected: decision.runId,
209
+ scope: "workflow-state linkage error",
210
+ failure: `read-back failed for run ${decision.runId}`,
211
+ });
212
+ if (!okAppend) {
213
+ // do NOT consume
214
+ } else {
215
+ markHandoffConsumed(ctx.cwd, decision.runId, {
216
+ piSessionId: currentSessionId ?? undefined,
217
+ });
218
+ resolved = data;
219
+ }
220
+ }
221
+ } else if (decision.action === "fork") {
222
+ // Inherited a run_id from a different session file → isolate the child's scratch.
223
+ ensureRunScratch(ctx.cwd, decision.childRunId);
224
+ const data: WorkflowState = {
225
+ run_id: decision.childRunId,
226
+ pi_session_id: currentSessionId ?? undefined,
227
+ predecessor: decision.parentRunId,
228
+ mode: decision.state.mode,
229
+ };
230
+ pi.appendEntry(WORKFLOW_STATE_TYPE, data);
231
+ resolved = data;
232
+ } else if (decision.action === "none") {
233
+ // A warm session with no identity mints its own run_id so
234
+ // per-run state (the session data dir) can key off it. No disk artifacts —
235
+ // dirs are the accessor's job; provenance is recorded separately. A failed cold claim above never
236
+ // falls here (claim stays a loud unclaimed error).
237
+ const runId = mintRunId();
238
+ const data: WorkflowState = { run_id: runId, pi_session_id: currentSessionId ?? undefined };
239
+ const okAppend = appendWorkflowState(pi, ctx, {
240
+ data,
241
+ field: "run_id",
242
+ expected: runId,
243
+ scope: "workflow-state linkage error",
244
+ failure: `read-back failed for minted run ${runId}`,
245
+ });
246
+ if (okAppend) {
247
+ resolved = { ...decision.state, ...data };
248
+ minted = true;
249
+ }
250
+ }
251
+
252
+ // Plan-ref linkage (turn-2b §6, stage-gated): reconcile the cache.plan-ref file into
253
+ // active_plan_ref — but ONLY when the launched stage *consumes* the ref (its registry
254
+ // `requires`/`reads` list `cache.plan-ref`). That is the worktree binding stages
255
+ // (implement/submit/address/land/learn); the root `worktree: none` stages
256
+ // (plan/objective-plan/save) must NOT inherit the root *selector* into a fresh planning
257
+ // session. Idempotent by (provider, pr_id), strict read-back, headless-safe. Runs after the
258
+ // run_id claim so the run is settled first; the two append independent LWW fields.
259
+ // Reload/fork/tree (no launched stage) rely on the LWW rebuild — never re-read the file.
260
+ const linked = rebuildWorkflowState(branchEntries()).active_plan_ref ?? null;
261
+ const runStage = resolveRunStage(decision, ctx.cwd);
262
+ // Registry-missing is permissive when a stage is present, to preserve implement linkage.
263
+ const consumesPlanRef =
264
+ runStage !== null && (registry === null || stageConsumesPlanRef(registry, runStage));
265
+ if (consumesPlanRef) {
266
+ const cachedRef = readPlanRef(ctx.cwd);
267
+ if (cachedRef !== null) {
268
+ if (planRefsEqual(linked, cachedRef)) {
269
+ resolved = { ...resolved, active_plan_ref: linked };
270
+ } else {
271
+ if (
272
+ appendWorkflowState(pi, ctx, {
273
+ data: { active_plan_ref: cachedRef },
274
+ field: "active_plan_ref",
275
+ expected: cachedRef,
276
+ scope: "workflow-state linkage error",
277
+ failure: `plan-ref read-back failed for ${cachedRef.provider}:${cachedRef.pr_id}`,
278
+ equals: planRefsEqual,
279
+ })
280
+ ) {
281
+ resolved = { ...resolved, active_plan_ref: cachedRef };
282
+ }
283
+ }
284
+ } else if (linked !== null) {
285
+ resolved = { ...resolved, active_plan_ref: linked };
286
+ }
287
+ } else if (linked !== null) {
288
+ // Non-consuming stage (or no launched stage): preserve any already-linked ref via LWW,
289
+ // but NEVER read the cache file — the root selector must not leak in.
290
+ resolved = { ...resolved, active_plan_ref: linked };
291
+ }
292
+
293
+ // Reapply the read-only allowlist from the resolved mode. Fail-closed: if the sync throws,
294
+ // leave the gate as-is (a failed sync never opens it).
295
+ try {
296
+ gating.syncFromState(resolved.mode);
297
+ } catch (error) {
298
+ console.error(`perk: tool-gating sync failed on session_start — ${error}`);
299
+ }
300
+
301
+ // Soft version-parity drift signal: pi can lazy-install / load a stale `npm:@mgiles/perk`, so the
302
+ // extension actually running may differ from the `perk` CLI that launched it. The local launch
303
+ // seam injects PERK_CLI_VERSION; compare it against this extension's own `perkVersion()`. Soft +
304
+ // non-fatal (warning), headless-safe via report(). No once-guard — may re-emit on reload, fine
305
+ // for a soft warning. Silent for ad-hoc `pi` (no env) and the self-repo (versions equal).
306
+ const cliVersion = (process.env.PERK_CLI_VERSION ?? "").trim();
307
+ if (cliVersion && version && cliVersion !== version) {
308
+ report(
309
+ ctx,
310
+ "version parity",
311
+ "warning",
312
+ `the loaded @mgiles/perk extension (v${version}) differs from the running perk CLI ` +
313
+ `(v${cliVersion}) — run 'perk doctor --fix' to reinstall the pinned version`,
314
+ { alsoLog: true },
315
+ );
316
+ }
317
+
318
+ // Charter D7: perk identity is standing footer state, not a transition — the
319
+ // `v<version> loaded` toast (and its headless stderr mirror) is retired. D5 is rescinded:
320
+ // perk keeps pi's default working indicator (no setWorkingIndicator call anywhere).
321
+ // Footer-seam install-site vacating: under a foreign `[providers] footer` selection perk does
322
+ // NOT install its own footer, leaving the foreign footer (`pi-powerline-footer` / `pi-bar`) as
323
+ // the sole footer surface. perk's objective/checkpoints progress still reaches it via the
324
+ // composed `perk` setStatus slot. Fail-safe: any config-read error resolves to install.
325
+ if (ctx.hasUI && !footerInstalled && isPerkFooterReferenceSelected(ctx.cwd)) {
326
+ installPerkFooter(ctx, {
327
+ identity: `perk v${version}`,
328
+ status: perkStatus,
329
+ getModelId: () => ctx.model?.id ?? null,
330
+ getContext: () => {
331
+ const usage = ctx.getContextUsage();
332
+ return usage ? { percent: usage.percent, contextWindow: usage.contextWindow } : null;
333
+ },
334
+ });
335
+ footerInstalled = true;
336
+ }
337
+
338
+ if (process.env.PERK_SELFCHECK) {
339
+ try {
340
+ const dir = join(ctx.cwd, ".pi", "workflow");
341
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
342
+ // The gate sentinel (unchanged — those gates parse this line).
343
+ writeFileSync(
344
+ join(dir, ".perk-loaded"),
345
+ `perk ${version} loaded; shared=${sharedOk ? "ok" : "miss"}; ` +
346
+ `registry=${registryOk ? "ok" : "miss"} stages=${registryStages}; hasUI=${ctx.hasUI}\n`,
347
+ );
348
+ writeT3Sentinel(ctx.cwd, minted ? "mint" : decision.source, resolved, ctx.mode ?? null);
349
+ setMarker(ctx.cwd, T3_MARKER); // cross-plane cache write (gate check 3)
350
+ } catch {
351
+ // never throw from a load probe
352
+ }
353
+ }
354
+ });
355
+
356
+ // Non-negotiable: rebuild on branch navigation too, or state goes stale after /tree (§8.3).
357
+ pi.on("session_tree", async (_event, ctx) => {
358
+ const state = rebuildWorkflowState(branchOf(ctx));
359
+ // Non-negotiable: re-sync the gate on tree navigation too (mode is per-field LWW). Fail-closed.
360
+ try {
361
+ gating.syncFromState(state.mode);
362
+ } catch (error) {
363
+ console.error(`perk: tool-gating sync failed on session_tree — ${error}`);
364
+ }
365
+ if (process.env.PERK_SELFCHECK) {
366
+ writeT3Sentinel(ctx.cwd, "tree", state, ctx.mode ?? null);
367
+ }
368
+ });
369
+
370
+ // Warm door: the `plan_save` tool + `/plan-save` command (turn-3). Takes `gating` for D1a:
371
+ // a successful command-path save exits read-only mode (the read-only → read-write boundary).
372
+ registerPlanSave(pi, gating);
373
+
374
+ // The `plan_draft` working-draft file tool. Registered in the factory so it
375
+ // exists before the gate snapshots tools; its name is in READ_ONLY_TOOLS (the structural
376
+ // session-data carve-out), so it survives plan mode.
377
+ registerPlanDraft(pi);
378
+
379
+ // The `objective_draft` working-objective file tool (the plan_draft twin).
380
+ registerObjectiveDraft(pi);
381
+
382
+ // The universal `ask_user_question` tool: lets a model interactively ask the human a
383
+ // clarifying question (free-text or multiple-choice). Registered in the factory so it exists
384
+ // before the gate snapshots tools; its name is in READ_ONLY_TOOLS so it survives plan mode.
385
+ registerAskUser(pi);
386
+
387
+ // Lifecycle gates: the dirty-repo switch/fork guard + the guard-only `/implement` (turn-4b).
388
+ registerLifecycleGates(pi);
389
+
390
+ // Warm door: the `submit` tool + `/submit` command (turn-5a).
391
+ registerSubmit(pi);
392
+
393
+ // The warm `ready` door: the deliberate draft→ready review gate (submit keeps draft).
394
+ registerReady(pi);
395
+
396
+ // Warm doors: `land` (turn-5b) merges + sets pending-learn; `learn` clears it (TS-only).
397
+ registerLand(pi);
398
+ registerLearn(pi);
399
+
400
+ // The warm `/address` review loop: the `resolve_review_threads` tool + `/address`
401
+ // command. Classify-then-act (the verbose feedback fetch + classification runs in an isolated
402
+ // spawned child; the parent fixes actionable items and batch-resolves the threads).
403
+ registerAddress(pi);
404
+
405
+ // The warm `/pr-review` door: automated code review in a FRESH, isolated subagent that
406
+ // POSTS its review to the PR (the deliberate departure from /address's read-only-child rule).
407
+ registerPrReview(pi);
408
+
409
+ // The read-only CI executor: the `run_ci` tool + `/ci` command + `--allow-project-ci`
410
+ // flag. Runs the project's `[ci]` named checks deterministically and reports (never fixes/loops).
411
+ registerCiExecutor(pi);
412
+
413
+ // perk-owned checkpoints: seed from the plan body's `## Steps`, advance on `[DONE:n]`.
414
+ // Inert when no step list is present (perk plans are prose). Own `session_start`/`session_tree`/
415
+ // `turn_end` handlers (coexist with the others; pi.on supports multiple handlers per event).
416
+ // Todo-seam deferral: perk is the reference todo provider (`perk-checkpoints`); these
417
+ // runtime surfaces step aside when a foreign `[providers] todo` is selected (the todo-seam mirror
418
+ // of planMode's plan-seam deferral) — silent on the event handlers, announced on `/checkpoints`.
419
+ registerCheckpoints(pi, perkStatus);
420
+
421
+ // The FIRST 3rd-party todo adapter (the todo-seam mirror of registerPlanAdapterTombell).
422
+ // Injection-only: inert unless `[providers] todo = "juicesharp-todo"` is selected AND the session
423
+ // is an active workflow. It carries perk's implement-progress discipline onto `@juicesharp/rpiv-
424
+ // todo`'s checklist overlay (perk's own checkpoints deferred). No `gating` argument —
425
+ // the shim NEVER arbitrates tools (Invariant 1); no registration-time vacating (no command-name
426
+ // collision on the todo seam, unlike the plan seam); never writes `perk:checkpoint`.
427
+ registerTodoAdapterJuicesharp(pi);
428
+
429
+ // The objective substrate: `/objective` set/clear, budget accounting, threshold
430
+ // compaction, all keyed off the now-live `active_objective`. Inert when no objective is active.
431
+ // (The deterministic objective mechanics live in the Python plane: `perk objective …`.)
432
+ registerObjective(pi, perkStatus);
433
+
434
+ // The warm `objective_save` door: the `objective_save` tool + `/objective-save` command
435
+ // (the objective mirror of plan-save). Takes `gating` for the read-only → read-write boundary.
436
+ registerObjectiveSave(pi, gating);
437
+
438
+ // The objective plan factory's warm transition surface: the `objective_node` bounded
439
+ // tool (delegates to the Python cold door; `status:"done"` requires a completion audit) + the
440
+ // `/objective-plan` command (select the next node and author a bounded plan). The command now
441
+ // enters the read-only gate on invocation (parity with the cold door's `mode: read-only`
442
+ // handoff; exit stays with plan_save / `/plan` off) — hence `gating`.
443
+ registerObjectivePlan(pi, gating);
444
+
445
+ // The learned-docs plan factory's warm surface: the `/learn-docs` command gathers open
446
+ // perk:learn issues into an inbox (via the `perk learn docs --gather` cold door) and injects the
447
+ // factory guidance so the model authors a docs/learned consolidation plan (no model tool).
448
+ registerLearnDocs(pi);
449
+
450
+ // Warm-door skill-binding delivery: Mechanism A's `before_agent_start` injection of
451
+ // the launched stage's user-originated bindings (+ the stale-context strip). Mechanism B (the
452
+ // `command:<id>` suffix) is wired into the `/objective-reconcile` + `/learn-docs` guidance.
453
+ registerBindingDelivery(pi);
454
+
455
+ // `/perk-selfcheck` — the session-wiring verifier (turned from a liveness ping into a real check
456
+ // that the converged ambient index reached `appendSystemPrompt` and the managed `AGENTS.md` block
457
+ // reached `contextFiles`). doctor checks disk; selfcheck checks the prompt.
458
+ registerSelfcheck(pi, { version, sharedOk });
459
+ }