@mgiles/perk 3.2.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 (202) 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 +365 -380
  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 -311
  49. package/extension/{doors/prReviewBrowser.ts → pi/v1/codeReview/browser.ts} +53 -33
  50. package/extension/{doors/hunkHandoff.ts → pi/v1/codeReview/checkout.ts} +12 -8
  51. package/extension/{doors/reviewWaveTools.ts → pi/v1/codeReview/reviewWave.ts} +146 -114
  52. package/extension/{doors/stackReviewBrowser.ts → pi/v1/codeReview/stack.ts} +62 -29
  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 +154 -267
  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 +123 -61
  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/pi/v1/lifecycleGates.ts +127 -0
  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/{factories/objectivePlan.ts → pi/v1/objectivePlanning.ts} +328 -533
  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 +73 -27
  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 -21
  112. package/extension/substrate/git.ts +88 -6
  113. package/extension/substrate/modelVisible.ts +53 -0
  114. package/extension/substrate/prompts.ts +22 -0
  115. package/extension/substrate/registry.ts +2 -0
  116. package/extension/substrate/resolverLease.ts +5 -4
  117. package/extension/substrate/sessionData.ts +85 -152
  118. package/extension/substrate/toolGating.ts +263 -84
  119. package/extension/substrate/unifiedDiff.ts +1 -1
  120. package/extension/substrate/workflowState.ts +178 -163
  121. package/extension/substrate/worktreeResolverLock.ts +261 -0
  122. package/extension/surfaces/surfaces.ts +79 -27
  123. package/extension/waves/adversarialReviewWave.ts +87 -46
  124. package/extension/waves/blockedReports.ts +59 -0
  125. package/extension/waves/draftReviewWave.ts +42 -42
  126. package/extension/waves/laneIdentity.ts +77 -0
  127. package/extension/waves/objectiveExplorerWave.ts +24 -24
  128. package/extension/waves/prReviewWave.ts +89 -77
  129. package/extension/waves/reportWave.ts +438 -578
  130. package/extension/waves/reviewClassifierWave.ts +22 -22
  131. package/extension/waves/rpcAdapter.ts +100 -15
  132. package/extension/waves/scoutWave.ts +192 -0
  133. package/extension/waves/transport.ts +480 -0
  134. package/extension/worker/sdkAdapter.ts +494 -0
  135. package/extension/worker/stageExecution.ts +679 -0
  136. package/extension/workerMain.ts +18 -19
  137. package/package.json +6 -4
  138. package/prompts/_fixtures/live.yaml +43 -18
  139. package/prompts/contexts/adapters/plannotator-gist.md +6 -0
  140. package/prompts/contexts/adapters/plannotator-objective.md +6 -0
  141. package/prompts/contexts/adapters/plannotator-plan.md +8 -1
  142. package/prompts/contexts/adapters/plannotator-refinement.md +22 -0
  143. package/prompts/contexts/objective-refinement.md +17 -0
  144. package/prompts/contexts/read-only.md +1 -1
  145. package/prompts/stages/conflict-resolution-continuation.md +9 -6
  146. package/prompts/stages/conflict-resolution.md +4 -4
  147. package/prompts/stages/objective-plan/guidance.md +2 -2
  148. package/prompts/stages/objective-plan/seed.md +9 -1
  149. package/prompts/stages/objective-reconcile-ready.md +1 -1
  150. package/prompts/stages/objective-reconcile.md +1 -1
  151. package/prompts/stages/objective-refine/seed.md +18 -0
  152. package/prompts/stages/objective-review-browser.md +4 -4
  153. package/prompts/stages/objective-sync.md +1 -1
  154. package/prompts/stages/plan-review-browser.md +4 -4
  155. package/prompts/stages/pr-review-browser/active.md +3 -4
  156. package/prompts/stages/pr-review-browser/foreign.md +3 -4
  157. package/prompts/stages/pr-review-terminal/active.md +3 -3
  158. package/prompts/stages/pr-review-terminal/foreign.md +3 -3
  159. package/prompts/stages/pr-review.md +3 -3
  160. package/prompts/stages/stack-review-browser/stack.md +5 -6
  161. package/shared/README.md +8 -0
  162. package/shared/bindings.yaml +3 -3
  163. package/shared/contracts.md +2601 -506
  164. package/shared/fixtures/issues-table.json +130 -0
  165. package/shared/registry.yaml +13 -0
  166. package/shared/schemas/outputs/objective-node-engagement.schema.json +318 -0
  167. package/shared/schemas/outputs/objective-stack-status.schema.json +6 -1
  168. package/shared/schemas/outputs/pr-review-context.schema.json +54 -9
  169. package/shared/schemas/outputs/pr-review-stack-context.schema.json +196 -0
  170. package/extension/adapters/planAdapterPlannotator.ts +0 -362
  171. package/extension/doors/auditWaveTools.ts +0 -352
  172. package/extension/doors/ciExecutor.ts +0 -756
  173. package/extension/doors/commitCompact.ts +0 -251
  174. package/extension/doors/dreamWaveTools.ts +0 -489
  175. package/extension/doors/learn.ts +0 -668
  176. package/extension/doors/lifecycleGates.ts +0 -207
  177. package/extension/doors/objectiveStack.ts +0 -1543
  178. package/extension/doors/prReviewDynamic.ts +0 -276
  179. package/extension/doors/ready.ts +0 -279
  180. package/extension/doors/submit.ts +0 -373
  181. package/extension/doors/submitPrReview.ts +0 -505
  182. package/extension/factories/gistAuthor.ts +0 -94
  183. package/extension/factories/gistDraft.ts +0 -265
  184. package/extension/factories/gistSave.ts +0 -251
  185. package/extension/factories/implementHere.ts +0 -116
  186. package/extension/factories/objectiveAuthor.ts +0 -98
  187. package/extension/factories/objectiveDraft.ts +0 -466
  188. package/extension/factories/objectiveSave.ts +0 -366
  189. package/extension/factories/planDraft.ts +0 -140
  190. package/extension/factories/planMode.ts +0 -205
  191. package/extension/factories/planReview.ts +0 -1237
  192. package/extension/factories/planSave.ts +0 -604
  193. package/extension/factories/planTitle.ts +0 -141
  194. package/extension/substrate/structuredOutput.ts +0 -202
  195. package/extension/waves/auditWave.ts +0 -312
  196. package/extension/waves/harvestWave.ts +0 -399
  197. package/extension/waves/learnWave.ts +0 -155
  198. package/extension/waves/memoryAdapter.ts +0 -139
  199. package/extension/waves/prReviewDynamicWave.ts +0 -777
  200. package/extension/worker/readOnlySession.ts +0 -294
  201. package/extension/worker/worker.ts +0 -899
  202. package/prompts/stages/pr-review-dynamic.md +0 -7
@@ -0,0 +1,41 @@
1
+ // The plan feature's model-facing prose units: the plan-authoring context content builder and
2
+ // the injection marker/customType constants. Prose-unit MEANING lives here (the feature); the
3
+ // v1 adapter only PLACES these units in Pi context payloads (module-contracts.md's prose
4
+ // split). The tool registration prose (descriptions + promptGuidelines) deliberately stays
5
+ // INLINE at the `pi/v1/plan.ts` registration sites — the prose-review workbench edits
6
+ // registration prose through the TypeScript source adapter, which needs literal in-place
7
+ // arrays (an identifier indirection is an unsupported source shape there). Prompting, NOT
8
+ // enforcement — the read-only tool gate is the enforcement.
9
+ //
10
+ // Carve-out doctrine (mirrors `authoring/gist/`): this home is Pi-free (guard Rule D) — the
11
+ // adapter passes config-derived values (the `[workflow] plan_authoring` addendum) in as plain
12
+ // strings; nothing here reads Pi, the config loader, or the surfaces seam.
13
+
14
+ import { render } from "../../substrate/prompts.ts";
15
+
16
+ /** The plan-authoring context customType (distinct from the gate's `perk:mode-context`). */
17
+ export const PLAN_CONTEXT_TYPE = "perk:plan-context";
18
+
19
+ /** The injected plan-authoring context's identity marker (the strip + dedup key). */
20
+ export const PLAN_MARKER = "[PLAN AUTHORING]";
21
+
22
+ /**
23
+ * The cooperative gather-then-plan contract. This is prompting, NOT enforcement (the gate is the
24
+ * enforcement). It never leaks internal policy text — it tells the model how to materialize a
25
+ * decision-complete plan an executor with zero prior context can follow. Per contracts.md §8.57
26
+ * this mode context is the plan stage's DESIGNATED FLOW CARRIER in every plan-stage session
27
+ * shape (seeded doors carry launch-shape deltas only; the `perk-plan` skill is the detail tier).
28
+ * Durable anchors only, no line numbers.
29
+ */
30
+ export const PLAN_AUTHORING_CONTEXT = render("contexts/plan-authoring.md", {
31
+ marker: PLAN_MARKER,
32
+ });
33
+
34
+ /**
35
+ * Build the full plan-authoring injection, appending the project-config authoring addendum when
36
+ * present. Pure over the addendum — the ADAPTER loads `[workflow] plan_authoring` per event and
37
+ * passes the value in (the narrow-views doctrine).
38
+ */
39
+ export function planAuthoringContextContent(addendum: string | undefined): string {
40
+ return addendum ? `${PLAN_AUTHORING_CONTEXT}\n\n${addendum.trim()}` : PLAN_AUTHORING_CONTEXT;
41
+ }
@@ -0,0 +1,269 @@
1
+ // The plan review feature: the `PlanDraftReviewer` role (one production adapter per review
2
+ // backend — plannotator bridge or first-party editor, both built in pi/v1 — plus a scripted fake
3
+ // in the tests), the reviewer-edits mechanical-apply ladder, and the one-entry `reviewPlanDraft`
4
+ // operation.
5
+ //
6
+ // Ordering is the review door's exact discipline: resolve FIRST (artifact → param ONLY — the
7
+ // review surface never sees a transcript tier; none ⇒ `noPlan`) → review → the abort checkpoint
8
+ // → route the verdict. Provider vocabulary (plannotator's `# Direct Edits` prose) is translated
9
+ // INTO `PlanReviewOutcome` at the adapter — the feature sees typed variants only.
10
+ //
11
+ // Cancellation ownership: `signal?.aborted` is checked at entry AND re-checked immediately
12
+ // after `reviewer.review` resolves, BEFORE any effect (apply ladder, save, gate exit) — abort ⇒
13
+ // the `aborted` arm with nothing saved and the gate untouched. The backend save itself is not
14
+ // abortable mid-flight (today's behavior, stated in save.ts).
15
+ //
16
+ // The gate-safety invariant is FEATURE-OWNED: an `implementHere` verdict under
17
+ // `allowImplementHere: false` (an objective-node planning session — a node-linked plan must
18
+ // save) REFUSES with the distinct `implementHereRefused` arm — nothing saved, gate untouched.
19
+ // The adapter's verdict suppression under a node claim remains the UX layer (the option is
20
+ // never offered); this arm is the defensive backstop.
21
+
22
+ import type { WorkflowSession } from "../../session/workflowSession.ts";
23
+ import { applyUnifiedDiff } from "../../substrate/unifiedDiff.ts";
24
+ import type { ApprovalGate } from "../review/approvalGate.ts";
25
+ import { PLAN_DRAFT_ARTIFACT, resumePlanDraft } from "./draft.ts";
26
+ import {
27
+ type PlanApprovalSaveOutcome,
28
+ type PlanBackend,
29
+ type PlanSaveDeps,
30
+ planApprovalSave,
31
+ } from "./save.ts";
32
+ import { resolvePlanSource } from "./source.ts";
33
+
34
+ /**
35
+ * The reviewer's verdict on the plan bytes. `approvedDirectEdits` carries the reviewer's
36
+ * extracted unified diff (`remainder` is the annotation feedback surviving after the applied
37
+ * section is stripped; `rawFeedback` the FULL original — the fail-open ladder saves verbatim
38
+ * with it). `approvedEditsUnparseable` is the seen-heading/unextractable-diff arm: the section
39
+ * was seen but cannot be honored — verbatim save of the ORIGINAL bytes with the FULL feedback
40
+ * preserved and the loud `directEditsFailed` flag.
41
+ */
42
+ export type PlanReviewOutcome =
43
+ | { status: "approved"; feedback?: string; reviewId?: string }
44
+ | {
45
+ status: "approvedDirectEdits";
46
+ diff: string;
47
+ remainder?: string;
48
+ rawFeedback: string;
49
+ reviewId?: string;
50
+ }
51
+ | { status: "approvedEditsUnparseable"; rawFeedback: string; reviewId?: string }
52
+ | { status: "denied"; feedback?: string; reviewId?: string }
53
+ | { status: "implementHere"; reviewId: string }
54
+ | { status: "dismissed" }
55
+ | { status: "aborted" }
56
+ | { status: "unavailable"; warning: string };
57
+
58
+ /**
59
+ * What one review round produced: the typed outcome, the FINAL reviewed bytes (`plan`), and
60
+ * whether the human edited them. The first-party reviewer adapter writes human edits back to
61
+ * the draft INSIDE the reviewer (before the verdict — a failed write-back is the `unavailable`
62
+ * abort), so `plan` is the final reviewed bytes and `edited` the differ bit; the plannotator
63
+ * reviewer returns the input unchanged (`edited: false` — browser edits arrive as the Direct
64
+ * Edits diff on the outcome, applied feature-side).
65
+ */
66
+ export type PlanDraftReviewResult = { outcome: PlanReviewOutcome; plan: string; edited: boolean };
67
+
68
+ /** The reviewer role: judge the resolved plan bytes. */
69
+ export interface PlanDraftReviewer {
70
+ review(plan: string, signal?: AbortSignal): Promise<PlanDraftReviewResult>;
71
+ }
72
+
73
+ /**
74
+ * The plan-arm mechanical-apply ladder for reviewer edits: strict `applyUnifiedDiff` → draft
75
+ * write-back through the session (reviewed bytes == artifact bytes == saved bytes). Every rung
76
+ * fails open — the caller renders the loud warning and saves verbatim. Never throws.
77
+ */
78
+ export function applyReviewerEdits(
79
+ session: WorkflowSession,
80
+ basePlan: string,
81
+ edits: { diff: string },
82
+ ): { status: "applied"; plan: string } | { status: "failed" } {
83
+ const patched = applyUnifiedDiff(basePlan, edits.diff);
84
+ if (patched === null) return { status: "failed" };
85
+ const written = session.writeArtifact(PLAN_DRAFT_ARTIFACT, patched);
86
+ if (written.status !== "applied" && written.status !== "unchanged") return { status: "failed" };
87
+ return { status: "applied", plan: patched };
88
+ }
89
+
90
+ /** The review-operation dependency bag (the adapter composes production values). */
91
+ export interface ReviewPlanDraftDeps extends PlanSaveDeps {
92
+ reviewer: PlanDraftReviewer;
93
+ backend: PlanBackend;
94
+ gate: ApprovalGate;
95
+ /** The `plan` param fallback (the artifact wins; review mode never sees a transcript). */
96
+ explicit?: string;
97
+ /** False in an objective-node planning session — the no-save exit refuses there. */
98
+ allowImplementHere: boolean;
99
+ }
100
+
101
+ /** The plan-arm save flags every approved arm carries (the adapter's rendering opts). */
102
+ interface ApprovedFlags {
103
+ feedback?: string;
104
+ reviewId?: string;
105
+ paramMismatch: boolean;
106
+ edited: boolean;
107
+ directEditsFailed: boolean;
108
+ }
109
+
110
+ /** The one-entry review outcome — each arm carries exactly what its caller renders. */
111
+ export type ReviewPlanDraftResult =
112
+ | { status: "noPlan" }
113
+ | ({
114
+ status: "approvedSaved";
115
+ save: Extract<PlanApprovalSaveOutcome, { status: "saved" }>;
116
+ } & ApprovedFlags)
117
+ | ({
118
+ status: "approvedSaveFailed";
119
+ save: Extract<PlanApprovalSaveOutcome, { status: "save-failed" }>;
120
+ } & ApprovedFlags)
121
+ | ({ status: "approvedNoPlan" } & ApprovedFlags)
122
+ | {
123
+ status: "implementHere";
124
+ reviewId: string;
125
+ gateExited: boolean;
126
+ plan: string;
127
+ edited: boolean;
128
+ }
129
+ | { status: "implementHereRefused" }
130
+ | { status: "denied"; feedback?: string; reviewId?: string }
131
+ | { status: "dismissed" }
132
+ | { status: "aborted" }
133
+ | { status: "unavailable"; warning: string };
134
+
135
+ /**
136
+ * Review the working plan draft end-to-end: resolve (artifact → param ONLY; none ⇒ `noPlan`) →
137
+ * review → the abort checkpoint → route. Routing: `approvedDirectEdits` ⇒ the apply ladder
138
+ * (success ⇒ save the EDITED bytes with `edited: true` + remainder-only feedback; failure ⇒
139
+ * verbatim save + `directEditsFailed`) · `approvedEditsUnparseable` ⇒ verbatim save + the FULL
140
+ * raw feedback + `directEditsFailed` (fail-open-but-loud) · `approved` ⇒ `planApprovalSave`
141
+ * with the reviewed bytes (`edited` rides into the flags) · `implementHere` ⇒ gate exit WITHOUT
142
+ * save when allowed, else the `implementHereRefused` refusal · everything else passes through.
143
+ * Never throws.
144
+ */
145
+ export async function reviewPlanDraft(
146
+ deps: ReviewPlanDraftDeps,
147
+ signal?: AbortSignal,
148
+ ): Promise<ReviewPlanDraftResult> {
149
+ if (signal?.aborted) return { status: "aborted" };
150
+ const src = resolvePlanSource(
151
+ { draft: resumePlanDraft(deps.session), explicit: deps.explicit },
152
+ "review",
153
+ );
154
+ if (src === null) return { status: "noPlan" };
155
+
156
+ const result = await deps.reviewer.review(src.plan, signal);
157
+ // The abort checkpoint: a turn interrupted while the reviewer ran must produce NO effect —
158
+ // no apply ladder, no save, no gate exit (the aborted arm wins over any verdict).
159
+ if (signal?.aborted) return { status: "aborted" };
160
+ return completePlanReview(deps, result, {
161
+ source: src.source === "plan-draft" ? "plan-draft" : "param",
162
+ paramMismatch: src.paramMismatch,
163
+ });
164
+ }
165
+
166
+ /** Subject policy only: callers authorize effects before entering this completion seam. */
167
+ export async function completePlanReview(
168
+ deps: Omit<ReviewPlanDraftDeps, "reviewer" | "explicit">,
169
+ result: PlanDraftReviewResult,
170
+ source: { source: "plan-draft" | "param"; paramMismatch: boolean },
171
+ ): Promise<ReviewPlanDraftResult> {
172
+ const outcome = result.outcome;
173
+ const approvalSave = (reviewedPlan: string): Promise<PlanApprovalSaveOutcome> =>
174
+ planApprovalSave(deps, { boundSource: { plan: reviewedPlan, ...source } });
175
+ const flags = (
176
+ partial: Pick<ApprovedFlags, "feedback" | "reviewId"> &
177
+ Partial<Pick<ApprovedFlags, "edited" | "directEditsFailed">>,
178
+ ): ApprovedFlags => ({
179
+ ...(partial.feedback !== undefined ? { feedback: partial.feedback } : {}),
180
+ ...(partial.reviewId !== undefined ? { reviewId: partial.reviewId } : {}),
181
+ paramMismatch: source.paramMismatch,
182
+ edited: partial.edited ?? false,
183
+ directEditsFailed: partial.directEditsFailed ?? false,
184
+ });
185
+ const savedResult = (save: PlanApprovalSaveOutcome, f: ApprovedFlags): ReviewPlanDraftResult => {
186
+ switch (save.status) {
187
+ case "saved":
188
+ return { status: "approvedSaved", save, ...f };
189
+ case "save-failed":
190
+ return { status: "approvedSaveFailed", save, ...f };
191
+ case "no-plan":
192
+ return { status: "approvedNoPlan", ...f };
193
+ }
194
+ };
195
+
196
+ switch (outcome.status) {
197
+ case "approvedDirectEdits": {
198
+ // Mechanically apply the reviewer's diff to the exact bytes reviewed, write the patched
199
+ // bytes back to the draft, and save THOSE (only the annotation remainder survives as
200
+ // feedback — the applied diff must never render as "apply these exact changes" guidance).
201
+ // Any rung failing falls open to the verbatim save + the loud directEditsFailed flag.
202
+ const applied = applyReviewerEdits(deps.session, result.plan, { diff: outcome.diff });
203
+ if (applied.status === "applied") {
204
+ return savedResult(
205
+ await approvalSave(applied.plan),
206
+ flags({
207
+ ...(outcome.remainder !== undefined ? { feedback: outcome.remainder } : {}),
208
+ ...(outcome.reviewId !== undefined ? { reviewId: outcome.reviewId } : {}),
209
+ edited: true,
210
+ }),
211
+ );
212
+ }
213
+ return savedResult(
214
+ await approvalSave(result.plan),
215
+ flags({
216
+ feedback: outcome.rawFeedback,
217
+ ...(outcome.reviewId !== undefined ? { reviewId: outcome.reviewId } : {}),
218
+ directEditsFailed: true,
219
+ }),
220
+ );
221
+ }
222
+ case "approvedEditsUnparseable":
223
+ // The seen-heading/unextractable-diff arm: verbatim save of the ORIGINAL bytes, the FULL
224
+ // feedback preserved, plus the loud warning flag (fail-open-but-loud).
225
+ return savedResult(
226
+ await approvalSave(result.plan),
227
+ flags({
228
+ feedback: outcome.rawFeedback,
229
+ ...(outcome.reviewId !== undefined ? { reviewId: outcome.reviewId } : {}),
230
+ directEditsFailed: true,
231
+ }),
232
+ );
233
+ case "approved":
234
+ return savedResult(
235
+ await approvalSave(result.plan),
236
+ flags({
237
+ ...(outcome.feedback !== undefined ? { feedback: outcome.feedback } : {}),
238
+ ...(outcome.reviewId !== undefined ? { reviewId: outcome.reviewId } : {}),
239
+ edited: result.edited,
240
+ }),
241
+ );
242
+ case "implementHere": {
243
+ if (!deps.allowImplementHere) return { status: "implementHereRefused" };
244
+ // The sanctioned no-save exit (contracts §8.23): gate off WITHOUT saving; the draft
245
+ // artifact stays intact so /plan-save can still create the canonical issue later.
246
+ const wasActive = deps.gate.isActive();
247
+ if (wasActive) deps.gate.exit();
248
+ return {
249
+ status: "implementHere",
250
+ reviewId: outcome.reviewId,
251
+ gateExited: wasActive,
252
+ plan: result.plan,
253
+ edited: result.edited,
254
+ };
255
+ }
256
+ case "denied":
257
+ return {
258
+ status: "denied",
259
+ ...(outcome.feedback !== undefined ? { feedback: outcome.feedback } : {}),
260
+ ...(outcome.reviewId !== undefined ? { reviewId: outcome.reviewId } : {}),
261
+ };
262
+ case "dismissed":
263
+ return { status: "dismissed" };
264
+ case "aborted":
265
+ return { status: "aborted" };
266
+ case "unavailable":
267
+ return { status: "unavailable", warning: outcome.warning };
268
+ }
269
+ }
@@ -0,0 +1,256 @@
1
+ // The plan save feature: the narrow exterior `PlanBackend` port (one production adapter — the
2
+ // `perk plan save` cold door in pi/v1 — plus one deterministic fake in the tests: the port
3
+ // admission rule), the `savePlan` operation, and the shared APPROVED-review → save orchestration
4
+ // `planApprovalSave` (the exported name contracts.md §8.23 pins — the `gistApprovalSave`
5
+ // mirror).
6
+ //
7
+ // Identity-less saves stay legal and fully specified: `session.runId === null` ⇒ the backend
8
+ // sees `runId: null` (the adapter omits `--run-id`), pointer capture no-ops in the adapter, and
9
+ // the LINKAGE + CLAIM ops still run — workflow-state appends are branch-backed and
10
+ // identity-independent (today's behavior, preserved). Backend saves are not abortable
11
+ // mid-flight (today's behavior — stated, not changed).
12
+
13
+ import type {
14
+ PlanRef,
15
+ WorkflowChangeResult,
16
+ WorkflowSession,
17
+ } from "../../session/workflowSession.ts";
18
+ import { type ApprovalGate, saveThroughApprovalGate } from "../review/approvalGate.ts";
19
+ import { resumePlanDraft } from "./draft.ts";
20
+ import { type PlanSource, resolvePlanSource } from "./source.ts";
21
+
22
+ /** The atomic objective node→plan commit surfaced by `perk plan save`. */
23
+ export interface ObjectiveNodeLink {
24
+ linked: boolean;
25
+ node: string | null;
26
+ status: string | null;
27
+ error: string | null;
28
+ }
29
+
30
+ /** The backend save facts (`ref.pr_id` is the opaque string issue id — contracts §8.21). */
31
+ export type PlanBackendSaveResult =
32
+ | {
33
+ status: "saved";
34
+ ref: PlanRef;
35
+ existed: boolean | null;
36
+ updated: boolean;
37
+ cached: boolean;
38
+ nodeLink: ObjectiveNodeLink | null;
39
+ }
40
+ | { status: "failed"; message: string; errorType: string };
41
+
42
+ /**
43
+ * The narrow exterior port the save operation writes through. `runId: null` means the caller
44
+ * has no session identity — the backend omits its run linkage (an identity-less save keeps
45
+ * working).
46
+ */
47
+ export interface PlanBackend {
48
+ save(req: {
49
+ plan: string;
50
+ title?: string;
51
+ runId: string | null;
52
+ objectiveId?: string;
53
+ nodeId?: string;
54
+ consumedLearn?: string[];
55
+ }): Promise<PlanBackendSaveResult>;
56
+ }
57
+
58
+ /**
59
+ * The save outcome. The saved arm carries the backend facts PLUS the resolution facts
60
+ * (`source`/`paramMismatch` ride through for the adapter's message assembly) and the two
61
+ * workflow-state results — the session seam's own `WorkflowChangeResult` values passed through
62
+ * VERBATIM (lossless by construction; `null` means not attempted: `linkage` when the save
63
+ * failed before it, `claimClear` when there was no matching claim / no node link). The
64
+ * production adapter's rendering ignores both fields (byte-stable output); they exist so the
65
+ * feature outcome is honest and the direct feature tests can pin every arm — the seam's type,
66
+ * not new API surface.
67
+ */
68
+ export type SavePlanOutcome =
69
+ | {
70
+ status: "saved";
71
+ ref: PlanRef;
72
+ existed: boolean | null;
73
+ updated: boolean;
74
+ cached: boolean;
75
+ nodeLink: ObjectiveNodeLink | null;
76
+ source: PlanSource | null;
77
+ paramMismatch: boolean;
78
+ linkage: WorkflowChangeResult | null;
79
+ claimClear: WorkflowChangeResult | null;
80
+ }
81
+ | { status: "failed"; message: string; errorType: string };
82
+
83
+ /** The dependency bag the save operation runs over (the adapter composes production values). */
84
+ export interface PlanSaveDeps {
85
+ session: WorkflowSession;
86
+ backend: PlanBackend;
87
+ /** Best-effort planning-pointer capture (contracts §8.35; no-ops on absent identity). */
88
+ capturePlanningPointer(): void;
89
+ }
90
+
91
+ /**
92
+ * The single save operation every plan-save surface calls. Ordering preserved exactly: validate
93
+ * the non-blank plan → resolve the title (an explicit title is trimmed and forwarded; else omitted
94
+ * so the cold door derives it) → warm node-claim recovery (BOTH link params absent ⇒ fill both from
95
+ * `session.nodeClaim()`; any explicit value — even one — wins outright, never mixed) →
96
+ * `backend.save` → `capturePlanningPointer()` (best-effort thunk) → link the live session
97
+ * (`apply({kind:"link-plan-ref"})` — append iff the ref differs) → on a node-linked save whose
98
+ * FULL claim identity matches (resolved objective + linked node), clear the claim
99
+ * (`apply({kind:"clear-node-claim"})` — an unrelated claim is never clobbered). Never throws.
100
+ */
101
+ export async function savePlan(
102
+ input: {
103
+ plan: string;
104
+ title?: string;
105
+ objectiveId?: string;
106
+ nodeId?: string;
107
+ consumedLearn?: string[];
108
+ /** The resolved plan source — surfaced in the adapter's message when non-param. */
109
+ source?: PlanSource;
110
+ /** A differing explicit param was ignored in favor of the artifact (visibly flagged). */
111
+ paramMismatch?: boolean;
112
+ },
113
+ deps: PlanSaveDeps,
114
+ ): Promise<SavePlanOutcome> {
115
+ const plan = input.plan.trim();
116
+ if (!plan) {
117
+ return {
118
+ status: "failed",
119
+ message: "no plan markdown to save (propose a plan first)",
120
+ errorType: "invalid_input",
121
+ };
122
+ }
123
+
124
+ // Forward an explicit (trimmed, non-blank) title; when absent the flag is omitted and the cold
125
+ // door derives one from the plan's first `# ` heading (`plan.derive_title`).
126
+ const title = input.title?.trim() || undefined;
127
+
128
+ // Warm node-link recovery. When BOTH link params are absent (an approval-triggered save
129
+ // carries no model params), fill both-or-neither from the rebuilt `objective_node_claim`. Any
130
+ // explicit value (even one) wins outright — a half-specified link is the caller's, never
131
+ // mixed with the claim. Fail-open: a malformed/missing claim never blocks a save
132
+ // (`nodeClaim()` returns null). Mirrors the cold `_link_from_handoff`.
133
+ let objectiveId = input.objectiveId;
134
+ let nodeId = input.nodeId;
135
+ if (objectiveId === undefined && nodeId === undefined) {
136
+ const claim = deps.session.nodeClaim();
137
+ if (claim !== null) {
138
+ objectiveId = claim.objective;
139
+ nodeId = claim.node;
140
+ }
141
+ }
142
+
143
+ const saved = await deps.backend.save({
144
+ plan,
145
+ ...(title !== undefined ? { title } : {}),
146
+ runId: deps.session.runId,
147
+ ...(objectiveId !== undefined ? { objectiveId } : {}),
148
+ ...(nodeId !== undefined ? { nodeId } : {}),
149
+ ...(input.consumedLearn !== undefined ? { consumedLearn: input.consumedLearn } : {}),
150
+ });
151
+ if (saved.status === "failed") return saved;
152
+
153
+ // Capture the planning session pointer (contracts.md §8.35): best-effort + non-fatal (the
154
+ // carrier warns + no-ops on absent identity; a successful save must stand).
155
+ deps.capturePlanningPointer();
156
+
157
+ // Link the live session: the seam appends iff the rebuilt ref differs, with a strict
158
+ // read-back — verbatim into the outcome (the adapter's rendering ignores it; the append
159
+ // helper's report() stays the loudness channel).
160
+ const linkage = deps.session.apply({ kind: "link-plan-ref", ref: saved.ref });
161
+
162
+ // A successful node-linked save clears the matching claim (best-effort — failure only risks
163
+ // a stale claim silently linking a later, unrelated save; surfaced via the seam's loud
164
+ // report()). The FULL claim identity must match — the resolved objective AND the linked node
165
+ // — so a save linked to objective B node 1.1 never clears objective A's standing 1.1 claim.
166
+ let claimClear: WorkflowChangeResult | null = null;
167
+ if (saved.nodeLink?.linked === true) {
168
+ const linkedNode = saved.nodeLink.node ?? nodeId ?? null;
169
+ const claim = deps.session.nodeClaim();
170
+ if (
171
+ linkedNode !== null &&
172
+ claim !== null &&
173
+ claim.node === linkedNode &&
174
+ claim.objective === objectiveId
175
+ ) {
176
+ claimClear = deps.session.apply({
177
+ kind: "clear-node-claim",
178
+ claim: { objective: claim.objective, node: linkedNode },
179
+ });
180
+ }
181
+ }
182
+
183
+ return {
184
+ status: "saved",
185
+ ref: saved.ref,
186
+ existed: saved.existed,
187
+ updated: saved.updated,
188
+ cached: saved.cached,
189
+ nodeLink: saved.nodeLink,
190
+ source: input.source ?? null,
191
+ paramMismatch: input.paramMismatch ?? false,
192
+ linkage,
193
+ claimClear,
194
+ };
195
+ }
196
+
197
+ /** The approval→save orchestration outcome (the plan `GistApprovalSaveOutcome` mirror). */
198
+ export type PlanApprovalSaveOutcome =
199
+ | { status: "no-plan" }
200
+ | { status: "saved"; result: Extract<SavePlanOutcome, { status: "saved" }>; gateExited: boolean }
201
+ | {
202
+ status: "save-failed";
203
+ result: Extract<SavePlanOutcome, { status: "failed" }>;
204
+ gateExited: false;
205
+ };
206
+
207
+ /** The approval→save dependency bag: the save deps + the gate + the save-mode transcript tier. */
208
+ export interface PlanApprovalSaveDeps extends PlanSaveDeps {
209
+ gate: ApprovalGate;
210
+ /** The transcript-scrape thunk (save-mode last resort); omit where the tier cannot apply. */
211
+ transcript?: () => string | null;
212
+ }
213
+
214
+ /**
215
+ * The shared APPROVED-review → save orchestration (an APPROVED `plan_review` outcome and the
216
+ * manual `/plan-save` failsafe both run THIS — contracts §8.23 pins the name). Flow:
217
+ * explicit `boundSource` for review completion, otherwise artifact-first resolution
218
+ * (`resolvePlanSource` — `reviewedPlan` is the explicit fallback, the transcript scrape last) → `savePlan` (warm node-claim recovery happens inside) through
219
+ * `saveThroughApprovalGate` (the D1a invariant: snapshot before the save; exit only after a
220
+ * successful save while read-only; a failed save leaves the gate ON). No resolvable plan
221
+ * source → `no-plan` (nothing saved, the gate untouched); callers render their own fallback.
222
+ */
223
+ export async function planApprovalSave(
224
+ deps: PlanApprovalSaveDeps,
225
+ opts: {
226
+ reviewedPlan?: string;
227
+ title?: string;
228
+ /** Explicit reviewed bytes; never re-resolve a partially written patch from the artifact. */
229
+ boundSource?: { plan: string; source: "plan-draft" | "param"; paramMismatch: boolean };
230
+ } = {},
231
+ ): Promise<PlanApprovalSaveOutcome> {
232
+ const src =
233
+ opts.boundSource ??
234
+ resolvePlanSource(
235
+ {
236
+ draft: resumePlanDraft(deps.session),
237
+ ...(opts.reviewedPlan !== undefined ? { explicit: opts.reviewedPlan } : {}),
238
+ ...(deps.transcript !== undefined ? { transcript: deps.transcript } : {}),
239
+ },
240
+ "save",
241
+ );
242
+ if (src === null) return { status: "no-plan" };
243
+ const { outcome: result, gateExited } = await saveThroughApprovalGate(deps.gate, () =>
244
+ savePlan(
245
+ {
246
+ plan: src.plan,
247
+ source: src.source,
248
+ paramMismatch: src.paramMismatch,
249
+ ...(opts.title !== undefined ? { title: opts.title } : {}),
250
+ },
251
+ deps,
252
+ ),
253
+ );
254
+ if (result.status === "failed") return { status: "save-failed", result, gateExited: false };
255
+ return { status: "saved", result, gateExited };
256
+ }
@@ -0,0 +1,82 @@
1
+ // The plan-source feature: where saved/reviewed plan bytes come from. The FILE-FIRST resolution
2
+ // law both save surfaces and the review door share: the validated `plan-draft.md` artifact wins;
3
+ // the explicit `plan` param is the fallback; the transcript scrape is the universal last resort
4
+ // FOR SAVES ONLY — the review surface never sees a transcript tier (an approval auto-saves the
5
+ // reviewed bytes, and scraped conversation bytes must never be those).
6
+ //
7
+ // Pi-free (guard Rule D): the tiers arrive as plain values/thunks — the ADAPTER reads the draft
8
+ // through the session seam and binds the transcript scrape over its branch.
9
+
10
+ /** Where the saved plan bytes came from (the file-first resolution order). */
11
+ export type PlanSource = "plan-draft" | "param" | "transcript";
12
+
13
+ function isRecord(value: unknown): value is Record<string, unknown> {
14
+ return typeof value === "object" && value !== null;
15
+ }
16
+
17
+ /** The joined text of a message's content blocks (fail-open: malformed blocks contribute ""). */
18
+ function textOf(content: unknown): string {
19
+ if (typeof content === "string") return content;
20
+ if (!Array.isArray(content)) return "";
21
+ return content
22
+ .map((block) =>
23
+ isRecord(block) && block.type === "text" && typeof block.text === "string" ? block.text : "",
24
+ )
25
+ .filter(Boolean)
26
+ .join("\n");
27
+ }
28
+
29
+ /**
30
+ * Best-effort, deterministic: the whole text of the latest assistant message, or null. This is
31
+ * the universal fail-open transcript FALLBACK behind the validated plan-draft artifact (see
32
+ * `resolvePlanSource`) — never a review source. Inherently fragile (it cannot tell a clean plan
33
+ * from conversation): keep the working draft current with `plan_draft` so the validated
34
+ * artifact wins. (There is no tag/marker convention to extract — the borrowed plan-mode package
35
+ * emits no structured plan, only free-form prose.) Fail-open by construction: entries are
36
+ * UNTRUSTED session history, so every field is proven before dereference — a null/sparse entry,
37
+ * a primitive, or a malformed content block is skipped, never thrown on.
38
+ */
39
+ export function extractPlanMarkdown(entries: readonly unknown[]): string | null {
40
+ for (let i = entries.length - 1; i >= 0; i--) {
41
+ const entry = entries[i];
42
+ if (!isRecord(entry) || entry.type !== "message") continue;
43
+ const message = entry.message;
44
+ if (!isRecord(message) || message.role !== "assistant") continue;
45
+ const text = textOf(message.content).trim();
46
+ if (!text) continue;
47
+ return text;
48
+ }
49
+ return null;
50
+ }
51
+
52
+ /**
53
+ * The shared plan-source resolver every plan surface uses. Resolution order: (1) a non-blank
54
+ * validated draft (the caller has already collapsed absent/invalid reads to `null`); (2) a
55
+ * non-blank explicit param; (3) — save mode only — the transcript scrape thunk; else null.
56
+ *
57
+ * `paramMismatch` is true iff the artifact won AND a non-blank explicit param was passed whose
58
+ * trimmed bytes differ from the artifact's — surfaced by the save rendering, never silently
59
+ * dropped and never a hard-fail. Review mode NEVER sees a transcript tier (the review-surface
60
+ * law): a caller that passes a transcript thunk in review mode still resolves null.
61
+ */
62
+ export function resolvePlanSource(
63
+ tiers: { draft: string | null; explicit?: string; transcript?: () => string | null },
64
+ mode: "save" | "review",
65
+ ): { plan: string; source: PlanSource; paramMismatch: boolean } | null {
66
+ if (tiers.draft !== null && tiers.draft.trim().length > 0) {
67
+ const param = tiers.explicit?.trim() ?? "";
68
+ return {
69
+ plan: tiers.draft,
70
+ source: "plan-draft",
71
+ paramMismatch: param.length > 0 && param !== tiers.draft.trim(),
72
+ };
73
+ }
74
+ if (tiers.explicit !== undefined && tiers.explicit.trim().length > 0) {
75
+ return { plan: tiers.explicit, source: "param", paramMismatch: false };
76
+ }
77
+ if (mode === "save" && tiers.transcript !== undefined) {
78
+ const scraped = tiers.transcript();
79
+ if (scraped !== null) return { plan: scraped, source: "transcript", paramMismatch: false };
80
+ }
81
+ return null;
82
+ }