@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,589 @@
1
+ // The warm `/plan-save` door (turn-3 §5/§6). The in-session twin of the Python cold
2
+ // door (`perk plan save`): a deterministic, terminating tool + command that WRAP the existing
3
+ // storage — they do NOT reimplement the GitHub write. `savePlan()` delegates to `perk plan save
4
+ // --json` via the shared cold-door client (`runColdDoor` — the plan markdown rides the run-scratch
5
+ // stdin channel), then appends `active_plan_ref` so the live session is linked immediately
6
+ // (strict read-back, idempotent, headless-safe). Failures are loud-but-non-fatal — never throw.
7
+ //
8
+ // FILE-FIRST PLAN SOURCE. Both surfaces resolve their plan through
9
+ // `resolvePlanSource`: the validated `plan-draft.md` artifact (`readSessionArtifact`,
10
+ // digest-validated, fail-open) wins; the explicit `plan` param (tool only, now optional) is the
11
+ // fallback for sessions that never wrote a draft; `extractPlanMarkdown` (the transcript scrape)
12
+ // is the universal last resort. A differing ignored param is surfaced, never silent.
13
+ //
14
+ // APPROVAL→SAVE ORCHESTRATION. The exported `approvalSave` seam is the shared
15
+ // APPROVED-review → save flow: artifact-first resolution → `savePlan` (warm node-link recovery
16
+ // inside, from the `objective_node_claim` carrier) → gate exit on a successful save (D1a). The
17
+ // `/plan-save` command is the MANUAL FAILSAFE invocation of the same seam; the review backends
18
+ // (plannotator / first-party / tombell) wire their APPROVED outcome into it.
19
+ //
20
+ // SEAM-SHARED SUBSTRATE. `savePlan`/the `plan_save` tool/`/plan-save`/the read-only gate
21
+ // are the produced-contract landing for the PLAN seam (`adapter-architecture.md` Invariant 1) — the
22
+ // adapter bridges a foreign plan surface *to* `plan_save`/`cache.plan-ref`/the gate, so
23
+ // they must stay always-registered. They do NOT defer when a foreign `[providers] plan` is selected
24
+ // — only perk's own authoring surface (`extension/factories/planMode.ts`: `/plan`, `Ctrl+Alt+P`, `--plan`,
25
+ // the `perk:plan-context` injection) steps aside. Deferring this substrate would break the adapter.
26
+
27
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
28
+ import type { PlanRef } from "../substrate/cache.ts";
29
+ import {
30
+ booleanField,
31
+ type ColdJson,
32
+ nullableStringField,
33
+ objectField,
34
+ runColdDoor,
35
+ stringField,
36
+ } from "../substrate/coldDoor.ts";
37
+ import { failFor, ok, type Result } from "../substrate/result.ts";
38
+ import { readSessionArtifact, type SessionDataCtx } from "../substrate/sessionData.ts";
39
+ import type { ToolGating } from "../substrate/toolGating.ts";
40
+ import { idArrayParam, paramsOf, stringParam } from "../substrate/toolParams.ts";
41
+ import {
42
+ appendWorkflowState,
43
+ type BranchEntry,
44
+ branchOf,
45
+ planRefsEqual,
46
+ rebuildWorkflowState,
47
+ } from "../substrate/workflowState.ts";
48
+ import { report, type Severity } from "../surfaces/report.ts";
49
+ import { nodeClaimsEqual, readNodeClaim } from "./objectivePlan.ts";
50
+ import { PLAN_DRAFT_ARTIFACT } from "./planDraft.ts";
51
+ import { generatePlanTitle } from "./planTitle.ts";
52
+
53
+ /** The ok-arm fields (turn-3 D6) — the `details` surface doubles as branch-safe persisted state. */
54
+ export interface PlanSaveOk {
55
+ /** `issue.id` is the opaque string issue id (GitHub "42", Linear "ENG-123") — §8.21. */
56
+ issue: { id: string; url: string };
57
+ plan_ref: PlanRef;
58
+ cached: boolean;
59
+ existed: boolean | null;
60
+ updated: boolean;
61
+ objective_node: ObjectiveNodeLink | null;
62
+ plan_source: PlanSource | null;
63
+ }
64
+
65
+ /** The atomic objective node→plan commit surfaced by `perk plan save`. */
66
+ export interface ObjectiveNodeLink {
67
+ linked: boolean;
68
+ node: string | null;
69
+ status: string | null;
70
+ error: string | null;
71
+ }
72
+
73
+ /** A tool result patch (AgentToolResult has no `isError`; failure is signaled via details.ok). */
74
+ export type SaveResult = Result<PlanSaveOk>;
75
+ export type PlanSaveDetails = SaveResult["details"];
76
+
77
+ /**
78
+ * The decoded `perk plan save --json` payload slice the warm door consumes. Decode policy
79
+ * (`docs/learned/workflow/cold-door-client.md`: strict iff appended to workflow-state): only
80
+ * `plan_ref` is strict. The rendered `issue.id`/`url` are DERIVED from the strict ref — the cold
81
+ * door constructs the ref from the issue (`pr_id == issue.id`, `url == issue.url`), so they are
82
+ * byte-identical by construction; `existed` and `objective_node` are advisory.
83
+ */
84
+ interface PlanSavePayload {
85
+ issue: { id: string; url: string; existed: boolean | undefined };
86
+ plan_ref: PlanRef;
87
+ cached?: boolean;
88
+ updated?: boolean;
89
+ objective_node: ObjectiveNodeLink | null;
90
+ }
91
+
92
+ /**
93
+ * Fully strict `plan_ref` decode — a half-formed ref appended to workflow-state would poison
94
+ * `planRefsEqual` and every downstream consumer, so any miss → null → bad_output.
95
+ */
96
+ function decodePlanRef(payload: ColdJson): PlanRef | null {
97
+ const ref = objectField(payload, "plan_ref");
98
+ if (ref === undefined) return null;
99
+ const provider = stringField(ref, "provider");
100
+ const prId = stringField(ref, "pr_id");
101
+ const url = stringField(ref, "url");
102
+ const labels = ref.labels;
103
+ const objectiveId = nullableStringField(ref, "objective_id");
104
+ if (
105
+ provider === undefined ||
106
+ prId === undefined ||
107
+ url === undefined ||
108
+ !Array.isArray(labels) ||
109
+ !labels.every((l) => typeof l === "string") ||
110
+ objectiveId === undefined
111
+ ) {
112
+ return null;
113
+ }
114
+ // `base` stays Python-owned for all behavior; carrying it keeps the workflow-state
115
+ // `active_plan_ref` copy byte-consistent with the cold door's `--json` plan_ref. Parity-only +
116
+ // lenient: a present null/string is carried, an absent/mistyped value is simply omitted (never a
117
+ // decode failure — legacy plan-refs lack the field).
118
+ const base = nullableStringField(ref, "base");
119
+ return { provider, pr_id: prId, url, labels, objective_id: objectiveId, base };
120
+ }
121
+
122
+ /** Validate the optional `objective_node` sub-object; malformed → null (advisory, never fatal). */
123
+ function decodeObjectiveNode(payload: ColdJson): ObjectiveNodeLink | null {
124
+ const node = objectField(payload, "objective_node");
125
+ if (node === undefined) return null;
126
+ const linked = booleanField(node, "linked");
127
+ const name = nullableStringField(node, "node");
128
+ const status = nullableStringField(node, "status");
129
+ const error = nullableStringField(node, "error");
130
+ if (linked === undefined || name === undefined || status === undefined || error === undefined) {
131
+ return null;
132
+ }
133
+ return { linked, node: name, status, error };
134
+ }
135
+
136
+ /**
137
+ * Narrow the `perk plan save --json` success payload. Strict ONLY on `plan_ref` (malformed →
138
+ * bad_output — it is appended to workflow-state, where a half-formed ref would poison
139
+ * `planRefsEqual`). The rendered issue id/url are derived from the strict ref instead of decoded
140
+ * independently — the cold door builds the ref FROM the issue, so they are byte-identical by
141
+ * construction; this makes any `issue` sub-object shape change (e.g. a `number`→`id`
142
+ * rename under CLI↔extension version skew) skew-harmless. `existed` and
143
+ * `objective_node` are advisory — the plan genuinely saved, so the success report must survive
144
+ * them. With `plan_ref` the only strict field, `bad_output` is reachable only for a payload whose
145
+ * persistence would corrupt workflow-state.
146
+ */
147
+ function decodePlanSave(payload: ColdJson): PlanSavePayload | null {
148
+ const ref = decodePlanRef(payload);
149
+ if (ref === null) return null;
150
+ const issue = objectField(payload, "issue");
151
+ const existed = issue === undefined ? undefined : booleanField(issue, "existed");
152
+ return {
153
+ issue: { id: ref.pr_id, url: ref.url, existed },
154
+ plan_ref: ref,
155
+ cached: booleanField(payload, "cached"),
156
+ updated: booleanField(payload, "updated"),
157
+ objective_node: decodeObjectiveNode(payload),
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Whether perk-owned plan mode is active — read from perk's OWN signal, the `read-only` `mode` of
163
+ * `perk:workflow-state` (the structural tool gate). This retires the soft coupling to
164
+ * `@tombell/pi-plan`'s `plan-mode-state` entry: perk now owns plan mode (`/plan` toggles
165
+ * `gating.enter`/`exit`, which append `mode`), so the gate's own field is the source of truth.
166
+ */
167
+ export function isPlanModeActive(branch: readonly BranchEntry[]): boolean {
168
+ return rebuildWorkflowState(branch).mode === "read-only";
169
+ }
170
+
171
+ function textOf(content: unknown): string {
172
+ if (typeof content === "string") return content;
173
+ if (!Array.isArray(content)) return "";
174
+ return content
175
+ .map((block) => {
176
+ const b = block as { type?: string; text?: string };
177
+ return b.type === "text" && typeof b.text === "string" ? b.text : "";
178
+ })
179
+ .filter(Boolean)
180
+ .join("\n");
181
+ }
182
+
183
+ /**
184
+ * Best-effort, deterministic: the whole text of the latest assistant message, or null. This is
185
+ * the universal fail-open transcript FALLBACK behind the validated plan-draft
186
+ * artifact (see `resolvePlanSource`) — no longer the `/plan-save` command's primary plan source.
187
+ * Inherently fragile (it cannot tell a clean plan from conversation): keep the working draft
188
+ * current with `plan_draft` so the validated artifact wins. (There is no tag/marker convention to
189
+ * extract — the borrowed plan-mode package emits no structured plan, only free-form prose.)
190
+ */
191
+ export function extractPlanMarkdown(entries: readonly unknown[]): string | null {
192
+ for (let i = entries.length - 1; i >= 0; i--) {
193
+ const entry = entries[i] as { type?: string; message?: { role?: string; content?: unknown } };
194
+ if (entry.type !== "message" || entry.message?.role !== "assistant") continue;
195
+ const text = textOf(entry.message.content).trim();
196
+ if (!text) continue;
197
+ return text;
198
+ }
199
+ return null;
200
+ }
201
+
202
+ /** Where the saved plan bytes came from (the file-first resolution order). */
203
+ export type PlanSource = "plan-draft" | "param" | "transcript";
204
+
205
+ /**
206
+ * The shared plan-source resolver both save surfaces use — and the
207
+ * approval→save orchestration. Resolution order: (1) the validated `plan-draft.md`
208
+ * artifact (`readSessionArtifact`: digest-validated, fail-open — null on no run_id / no pointer
209
+ * / fork run_id mismatch / missing file / digest mismatch); (2) a non-blank explicit param;
210
+ * (3) the transcript scrape (`extractPlanMarkdown`); else null.
211
+ *
212
+ * `paramMismatch` is true iff the artifact won AND a non-blank explicit param was passed whose
213
+ * trimmed bytes differ from the artifact's — surfaced by `savePlan`, never silently dropped and
214
+ * never a hard-fail.
215
+ */
216
+ export function resolvePlanSource(
217
+ ctx: SessionDataCtx,
218
+ explicit?: string,
219
+ ): { plan: string; source: PlanSource; paramMismatch: boolean } | null {
220
+ const artifact = readSessionArtifact(ctx, PLAN_DRAFT_ARTIFACT);
221
+ if (artifact !== null && artifact.content.trim().length > 0) {
222
+ const param = explicit?.trim() ?? "";
223
+ return {
224
+ plan: artifact.content,
225
+ source: "plan-draft",
226
+ paramMismatch: param.length > 0 && param !== artifact.content.trim(),
227
+ };
228
+ }
229
+ if (explicit !== undefined && explicit.trim().length > 0) {
230
+ return { plan: explicit, source: "param", paramMismatch: false };
231
+ }
232
+ const scraped = extractPlanMarkdown(branchOf(ctx));
233
+ if (scraped !== null) return { plan: scraped, source: "transcript", paramMismatch: false };
234
+ return null;
235
+ }
236
+
237
+ /**
238
+ * The single save implementation both surfaces call. Delegates the GitHub write to the Python
239
+ * cold door, then links the live session. Returns a soft result (never throws); failures set
240
+ * `details.ok = false` and append no linkage.
241
+ */
242
+ export async function savePlan(
243
+ pi: ExtensionAPI,
244
+ ctx: ExtensionContext,
245
+ opts: {
246
+ plan: string;
247
+ title?: string;
248
+ objectiveId?: string;
249
+ nodeId?: string;
250
+ consumedLearn?: string[];
251
+ /** The resolved plan source — surfaced in the message + details when non-param. */
252
+ source?: PlanSource;
253
+ /** A differing explicit param was ignored in favor of the artifact (visibly flagged). */
254
+ paramMismatch?: boolean;
255
+ },
256
+ ): Promise<SaveResult> {
257
+ const fail = failFor(ctx, "plan-save");
258
+
259
+ const plan = opts.plan.trim();
260
+ if (!plan) return fail("no plan markdown to save (propose a plan first)", "invalid_input");
261
+
262
+ // Forward an explicit title (previously accepted but DROPPED), else best-effort generate
263
+ // one via the session model. On any failure the cold door's `derive_title` fallback takes over.
264
+ const explicit = opts.title?.trim();
265
+ const title =
266
+ explicit && explicit.length > 0
267
+ ? explicit
268
+ : ((await generatePlanTitle(ctx, plan, ctx.signal)) ?? undefined);
269
+
270
+ const branch = (): BranchEntry[] => branchOf(ctx);
271
+ // No read-only fail-fast here (D1a): the `plan_save` TOOL is structurally unreachable while
272
+ // read-only (the read-only allowlist excludes it), so reaching savePlan via the tool means the gate is
273
+ // already off; the `/plan-save` COMMAND is allowed to run while read-only and the command handler
274
+ // exits the gate on a successful save (the read-only → read-write boundary in one gesture).
275
+ const runId = rebuildWorkflowState(branch()).run_id ?? "";
276
+
277
+ // Warm node-link recovery. When BOTH link params are absent (an
278
+ // approval-triggered save carries no model params), fill both-or-neither from the rebuilt
279
+ // `objective_node_claim`. Any explicit value (even one) wins outright — a half-specified link
280
+ // is the caller's, never mixed with the claim. Fail-open: a malformed/missing claim never
281
+ // blocks a save (readNodeClaim returns null). Mirrors the cold `_link_from_handoff`.
282
+ let objectiveId = opts.objectiveId;
283
+ let nodeId = opts.nodeId;
284
+ if (objectiveId === undefined && nodeId === undefined) {
285
+ const claim = readNodeClaim(ctx);
286
+ if (claim !== null) {
287
+ objectiveId = claim.objective;
288
+ nodeId = claim.node;
289
+ }
290
+ }
291
+
292
+ const args = ["plan", "save", "--json"];
293
+ if (runId) args.push("--run-id", runId);
294
+ // The resolved title (explicit or LLM-generated). When absent, the cold door derives it.
295
+ if (title) args.push("--title", title);
296
+ // The plan→objective link. The objective plan-factory passes the active objective
297
+ // number; non-objective plans omit it (unchanged behavior).
298
+ if (objectiveId) args.push("--objective-id", objectiveId);
299
+ // The objective plan factory passes the node id alongside the objective id; the cold
300
+ // door commits the node→plan backlink + `in_progress` advance atomically. Non-factory plans
301
+ // omit it (unchanged behavior).
302
+ if (nodeId) args.push("--node-id", nodeId);
303
+ // The learn-docs factory passes the consumed perk:learn issue numbers; docs plans land
304
+ // them (close + label perk:consolidated). Non-factory plans omit it (unchanged behavior).
305
+ if (opts.consumedLearn && opts.consumedLearn.length > 0) {
306
+ args.push("--consumed-learn", opts.consumedLearn.join(","));
307
+ }
308
+ const r = await runColdDoor<PlanSavePayload>(pi, ctx, args, {
309
+ label: "perk plan save",
310
+ decode: decodePlanSave,
311
+ stdin: { flag: "--plan-file", content: plan, filename: "plan.md" },
312
+ });
313
+ if (!r.ok) return fail(r.message, r.errorType);
314
+
315
+ // Link the live session (turn-3 D4): append iff the rebuilt ref differs, with a strict read-back.
316
+ const ref = r.data.plan_ref;
317
+ if (!planRefsEqual(rebuildWorkflowState(branch()).active_plan_ref ?? null, ref)) {
318
+ appendWorkflowState(pi, ctx, {
319
+ data: { active_plan_ref: ref },
320
+ field: "active_plan_ref",
321
+ expected: ref,
322
+ scope: "plan-save",
323
+ failure: `plan-ref read-back failed for ${ref.provider}:${ref.pr_id}`,
324
+ equals: planRefsEqual,
325
+ });
326
+ }
327
+
328
+ const verb = r.data.issue.existed ? "Updated" : "Saved";
329
+ const nodeLink = r.data.objective_node;
330
+ // A successful node-linked save clears the matching claim (best-effort —
331
+ // failure only risks a stale claim silently linking a later, unrelated save; surfaced via
332
+ // appendWorkflowState's report()). An unrelated claim is never clobbered.
333
+ if (nodeLink?.linked === true) {
334
+ const linkedNode = nodeLink.node ?? nodeId ?? null;
335
+ const claim = readNodeClaim(ctx);
336
+ if (linkedNode !== null && claim !== null && claim.node === linkedNode) {
337
+ appendWorkflowState(pi, ctx, {
338
+ data: { objective_node_claim: null },
339
+ field: "objective_node_claim",
340
+ expected: null,
341
+ scope: "plan-save",
342
+ failure: `objective_node_claim clear read-back failed for node ${linkedNode}`,
343
+ equals: nodeClaimsEqual,
344
+ });
345
+ }
346
+ }
347
+ // Render all THREE node-link outcomes (the silent-partial-failure fix). A failed advance
348
+ // (`linked: false`) is a non-fatal sub-step — the plan genuinely saved — but it must be VISIBLE
349
+ // (the §8.4 "warn + retriable" intent), not swallowed. Both surfaces render content[0].text, so
350
+ // this one site fixes the tool path (the model relays it) and the command path (the user sees the
351
+ // notify) at once.
352
+ let linkSuffix = "";
353
+ if (nodeLink?.linked === true) {
354
+ linkSuffix = ` · linked objective node ${nodeLink.node} → in_progress`;
355
+ } else if (nodeLink && nodeLink.linked === false) {
356
+ linkSuffix = ` · ⚠ objective node ${nodeLink.node} NOT advanced — re-run /plan-save to retry${
357
+ nodeLink.error ? ` (${nodeLink.error})` : ""
358
+ }`;
359
+ }
360
+ // Surface NON-param sources in the message (param-path success messages stay
361
+ // byte-stable); a differing ignored param is visibly flagged, never silent.
362
+ let sourceSuffix = "";
363
+ if (opts.source === "plan-draft" || opts.source === "transcript") {
364
+ sourceSuffix =
365
+ opts.source === "plan-draft"
366
+ ? " · plan source: plan-draft artifact"
367
+ : " · plan source: transcript";
368
+ if (opts.paramMismatch) {
369
+ sourceSuffix += " (⚠ differing plan param ignored — the validated artifact was saved)";
370
+ }
371
+ }
372
+ return ok(
373
+ `${verb} plan #${ref.pr_id} → ${ref.url}${sourceSuffix}${linkSuffix}`,
374
+ {
375
+ issue: { id: r.data.issue.id, url: r.data.issue.url },
376
+ plan_ref: ref,
377
+ cached: r.data.cached ?? false,
378
+ existed: r.data.issue.existed ?? null,
379
+ updated: r.data.updated ?? false,
380
+ objective_node: nodeLink,
381
+ plan_source: opts.source ?? null,
382
+ },
383
+ { terminate: true },
384
+ );
385
+ }
386
+
387
+ /** The approval→save orchestration outcome. */
388
+ export type ApprovalSaveOutcome =
389
+ | { status: "no-plan" }
390
+ | { status: "saved" | "save-failed"; result: SaveResult; gateExited: boolean };
391
+
392
+ /**
393
+ * The shared approval→save orchestration seam: an APPROVED review outcome
394
+ * (the `plan_review` door, planReview.ts — the plannotator bridge AND the first-party
395
+ * in-TUI editor review; tombell's re-aim is pending) and the manual `/plan-save`
396
+ * failsafe both run THIS. Flow: artifact-first plan resolution (`resolvePlanSource` — the reviewed plan text is
397
+ * the explicit fallback, the transcript scrape last) → `savePlan` (warm node-link recovery happens
398
+ * inside) → gate exit on a successful save while read-only (the D1a pattern: snapshot
399
+ * `gating.isActive()` before the save; a failed save leaves the gate ON). No resolvable plan
400
+ * source → `no-plan` (nothing saved, the gate untouched); callers render their own fallback.
401
+ * The returned `SaveResult` keeps `terminate: true` for tool-path callers (plannotator's
402
+ * approved arm propagates it).
403
+ */
404
+ export async function approvalSave(
405
+ pi: ExtensionAPI,
406
+ ctx: ExtensionContext,
407
+ gating: ToolGating,
408
+ opts: { reviewedPlan?: string; title?: string } = {},
409
+ ): Promise<ApprovalSaveOutcome> {
410
+ const src = resolvePlanSource(ctx, opts.reviewedPlan);
411
+ if (src === null) return { status: "no-plan" };
412
+ // D1a: snapshot the gate BEFORE the save; on success, exit it so save marks the read-only →
413
+ // read-write boundary in one gesture. A failed save leaves the gate on.
414
+ const wasReadOnly = gating.isActive();
415
+ const result = await savePlan(pi, ctx, {
416
+ plan: src.plan,
417
+ source: src.source,
418
+ paramMismatch: src.paramMismatch,
419
+ title: opts.title,
420
+ });
421
+ let gateExited = false;
422
+ if (result.details.ok && wasReadOnly) {
423
+ gating.exit(ctx);
424
+ gateExited = true;
425
+ }
426
+ return { status: result.details.ok ? "saved" : "save-failed", result, gateExited };
427
+ }
428
+
429
+ /** The decoded `plan_save` tool params (snake_case, as the schema declares them). */
430
+ interface PlanSaveParams {
431
+ plan?: string;
432
+ title?: string;
433
+ objective_id?: string;
434
+ node_id?: string;
435
+ consumed_learn?: string[];
436
+ }
437
+
438
+ /**
439
+ * Decode unknown `plan_save` tool-call params (the tool-boundary seam). `plan` is
440
+ * optional (the validated plan-draft artifact is preferred) — absent decodes to
441
+ * `undefined`, but present-but-mistyped → null (strict-fail); the optional fields likewise.
442
+ */
443
+ export function decodePlanSaveParams(params: unknown): PlanSaveParams | null {
444
+ const p = paramsOf(params);
445
+ if (p === null) return null;
446
+ const plan = stringParam(p, "plan");
447
+ if (plan === null) return null;
448
+ const title = stringParam(p, "title");
449
+ const objectiveId = stringParam(p, "objective_id");
450
+ const nodeId = stringParam(p, "node_id");
451
+ // Opaque string ids (§8.21); numbers are coerced — the learn-docs guidance renders bare
452
+ // numeric ids on GitHub, so the model may echo them un-quoted.
453
+ const consumedLearn = idArrayParam(p, "consumed_learn");
454
+ if (title === null || objectiveId === null || nodeId === null || consumedLearn === null) {
455
+ return null;
456
+ }
457
+ return {
458
+ plan: plan ?? undefined,
459
+ title,
460
+ objective_id: objectiveId,
461
+ node_id: nodeId,
462
+ consumed_learn: consumedLearn,
463
+ };
464
+ }
465
+
466
+ const TOOL_GUIDELINES = [
467
+ "Use plan_save only after the plan is decision-complete and the user has agreed; it creates the canonical GitHub plan and ends the turn.",
468
+ "Keep the working draft current with plan_draft — the validated plan-draft artifact is what plan_save saves; the `plan` parameter is only a fallback when no draft exists. Never reference line numbers — use durable anchors (function names, behavioral descriptions, structural locations).",
469
+ "Pass consumed_learn (the gathered perk:learn issue ids) only from the learned-docs factory — it links the issues the docs plan consolidates so /land closes + labels them.",
470
+ "When saving an objective-factory plan, pass BOTH objective_id and node_id — this links the node to the plan and advances it planning → in_progress (no separate backlink call).",
471
+ ];
472
+
473
+ /** Register the warm door: the `plan_save` tool (canonical) + the `/plan-save` command twin. */
474
+ export function registerPlanSave(pi: ExtensionAPI, gating: ToolGating): void {
475
+ pi.registerTool({
476
+ name: "plan_save",
477
+ label: "Save plan",
478
+ description:
479
+ "Persist the current plan to GitHub as the canonical perk plan and link this session to it. " +
480
+ "Terminating: ends the turn on save. Call only when the plan is decision-complete.",
481
+ promptSnippet: "Save the decision-complete plan to GitHub (terminates the turn)",
482
+ promptGuidelines: TOOL_GUIDELINES,
483
+ executionMode: "sequential",
484
+ parameters: {
485
+ type: "object",
486
+ additionalProperties: false,
487
+ properties: {
488
+ plan: {
489
+ type: "string",
490
+ description:
491
+ "Optional — the validated plan-draft.md artifact is preferred when present; this " +
492
+ "param is the fallback for sessions that never wrote a draft (no line-number " +
493
+ "references).",
494
+ },
495
+ title: {
496
+ type: "string",
497
+ description: "Optional issue title (defaults to the plan's first heading).",
498
+ },
499
+ objective_id: {
500
+ type: "string",
501
+ description:
502
+ "Optional objective issue number to link this plan to (the objective plan factory " +
503
+ "passes the active objective; omit for a standalone plan).",
504
+ },
505
+ node_id: {
506
+ type: "string",
507
+ description:
508
+ "Objective node id to commit on save — the objective plan factory passes it with " +
509
+ "`objective_id` (links the node and advances it to `in_progress`); omit for a " +
510
+ "standalone plan.",
511
+ },
512
+ consumed_learn: {
513
+ type: "array",
514
+ items: { type: ["string", "number"] },
515
+ description:
516
+ "Optional perk:learn issue ids this docs plan consumes (the learned-docs factory " +
517
+ "passes the gathered ids; omit for a standalone plan). /land closes + labels them.",
518
+ },
519
+ },
520
+ },
521
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
522
+ const decoded = decodePlanSaveParams(params);
523
+ if (decoded === null) {
524
+ // The `label` arg matters: this handler-level closure renders "plan_save failed: …" while
525
+ // savePlan's internal failFor(ctx, "plan-save") stays as-is.
526
+ return failFor(
527
+ ctx,
528
+ "plan-save",
529
+ "plan_save",
530
+ )("plan_save needs { plan: string, … } per the tool schema", "bad_input");
531
+ }
532
+ const src = resolvePlanSource(ctx, decoded.plan);
533
+ if (src === null) {
534
+ return failFor(
535
+ ctx,
536
+ "plan-save",
537
+ "plan_save",
538
+ )(
539
+ "no plan to save — write the working draft with plan_draft, or pass the plan parameter",
540
+ "invalid_input",
541
+ );
542
+ }
543
+ return savePlan(pi, ctx, {
544
+ plan: src.plan,
545
+ source: src.source,
546
+ paramMismatch: src.paramMismatch,
547
+ title: decoded.title,
548
+ objectiveId: decoded.objective_id,
549
+ nodeId: decoded.node_id,
550
+ consumedLearn: decoded.consumed_learn,
551
+ });
552
+ },
553
+ });
554
+
555
+ pi.registerCommand("plan-save", {
556
+ description:
557
+ "Save the latest proposed plan to GitHub — the manual failsafe for the approval→save flow " +
558
+ "(the read-only → read-write boundary).",
559
+ handler: async (args, ctx) => {
560
+ const title = args.trim() || undefined;
561
+ // The manual-failsafe invocation of the shared approval→save seam. Artifact-first
562
+ // (no explicit param on the command path ⇒ paramMismatch is always false); the D1a gate exit
563
+ // lives in the seam. (The tool path never exits the gate — it is structurally unreachable
564
+ // while read-only.)
565
+ const outcome = await approvalSave(pi, ctx, gating, { title });
566
+ if (outcome.status === "no-plan") {
567
+ report(
568
+ ctx,
569
+ "plan-save",
570
+ "warning",
571
+ "no plan to save; write a draft with plan_draft, propose a plan, or call the plan_save tool.",
572
+ { alsoLog: true },
573
+ );
574
+ return;
575
+ }
576
+ // Severity reflects a failed objective-node advance: not-ok → error; saved-but-link-failed →
577
+ // warning; otherwise info. A failed node-link never blocks the gate exit above (the plan was
578
+ // saved) — but it MUST surface (the silent-partial-failure fix), in headless runs too.
579
+ const result = outcome.result;
580
+ const message = result.content[0]?.text ?? "plan-save done";
581
+ const severity: Severity = !result.details.ok
582
+ ? "error"
583
+ : result.details.objective_node?.linked === false
584
+ ? "warning"
585
+ : "info";
586
+ report(ctx, "plan-save", severity, message);
587
+ },
588
+ });
589
+ }