@mgiles/perk 3.1.0 → 3.3.0

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