@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
@@ -0,0 +1,971 @@
1
+ // The feature-facing WorkflowSession seam AND its one deep engine (module-contracts.md's
2
+ // `session/` home): run identity + verified session-artifact operations + the named
3
+ // workflow-state reads (`nodeClaim`/`activeObjective`/`activeSessionPlanRef`/`reviewPosts`) and
4
+ // the closed change union, sized strictly to the callers that exist. No `stage`/`mode`/pointer-map
5
+ // snapshot — no feature caller consumes them through the seam yet (stage routing and the gate
6
+ // stay adapter-side; startup's lifecycle facts live in `session/lifecycle.ts`); the seam grows
7
+ // only from proven callers.
8
+ //
9
+ // ONE ENGINE, TWO NARROW PORTS. `openWorkflowSession(deps)` implements every WorkflowChange
10
+ // invariant, the full artifact discipline (name policy → identity refusal → unchanged
11
+ // short-circuit → store → read-back digest → pointer construction → merged-map strict append;
12
+ // the classified read tiers), and all error/problem text exactly once. Backings supply only
13
+ // mechanics: a `SessionStateStore` (session/lifecycle.ts — the same workflow-state store port
14
+ // the identity lifecycle uses) and an `ArtifactContentStore` (content I/O only, zero error
15
+ // prose). The production binding is `branchWorkflowSession.ts` (branch/file); the deterministic
16
+ // in-memory binding lives in `testing/memoryWorkflowSession.ts` (dev-only, outside the
17
+ // production corpus). Both are exercised by the shared interface suite in
18
+ // `workflowSession.test.ts`.
19
+ //
20
+ // Identity is OPTIONAL: `runId` is `string | null` and a session always opens — the plan-save
21
+ // surfaces prove the shape (workflow-state appends are branch-backed and identity-independent:
22
+ // an identity-less save still links `active_plan_ref`). The ARTIFACT ops classify no-identity as
23
+ // `rejected` (write) / `absent` (ordinary read) / `invalid` (strict read); the state ops work without
24
+ // identity. Identity is TRUST-NARROWED: a rebuilt `run_id` that is unsafe as a path component
25
+ // (`isSafeRunId`) degrades to no-identity before any path derivation — a hostile persisted id
26
+ // can never steer artifact reads outside the run root or reach a receipt.
27
+ //
28
+ // Results carry the session-owned `SessionArtifactReceipt` — validated or re-derived values
29
+ // ONLY (never the persisted pointer, whose rebuilt fields are unvalidated branch data). The
30
+ // full `SessionArtifactPointer` wire shape is still constructed here for the
31
+ // `session_artifacts` strict append (contracts §8.3's persistence format) — derived at the
32
+ // storage boundary, internal to the engine, never exposed through results.
33
+ //
34
+ // `apply(change)` is a CLOSED union admitted from proven callers (never a feature dispatcher);
35
+ // the first two variants come from the plan-save surfaces, the second two from the objective
36
+ // flows (`transitionObjectiveNode`'s planning arm records the claim; `saveObjective`'s
37
+ // post-save linkage sets `active_objective`). No snapshot payloads on the applied/unchanged
38
+ // arms — nothing consumes them (narrow until proven).
39
+
40
+ import { isSafeRunId, type PlanRef } from "../substrate/cache.ts";
41
+ import { digestSessionData, type SessionDataRead } from "../substrate/sessionData.ts";
42
+ import {
43
+ nodeClaimsEqual,
44
+ planRefsEqual,
45
+ type SessionArtifactPointer,
46
+ } from "../substrate/workflowState.ts";
47
+ import type { SessionStateStore } from "./lifecycle.ts";
48
+
49
+ /** Session-owned vocabulary: features import the plan-ref shape through the session seam. */
50
+ export type { PlanRef };
51
+ /** One digest convention, exposed through the session boundary for bound review consumers. */
52
+ export { digestSessionData };
53
+
54
+ /** A human-readable problem description (the backing has already warned where its tier is loud). */
55
+ export type SessionProblem = string;
56
+
57
+ /** The refinement stage id (the session keys its subject mapping + stage-only entry on it). */
58
+ const REFINE_STAGE_ID = "objective-refine";
59
+
60
+ /**
61
+ * The refinement grounding-context artifact name — session-owned vocabulary because the review
62
+ * binding fences the refinement subject on this artifact's digest (features import it here).
63
+ */
64
+ export const REFINEMENT_CONTEXT_ARTIFACT = "objective-refinement-context.json";
65
+
66
+ /**
67
+ * Session-owned artifact receipt: validated or re-derived values ONLY. `runId` is the
68
+ * safe-narrowed active run id (matches the persisted pointer's `run_id` by construction);
69
+ * `path` is re-derived from the content store (fs: repo-relative; memory: the name) — NEVER a
70
+ * persisted pointer field; `digest` is proven — computed from the bytes read back (applied) or
71
+ * from the stored bytes during the unchanged probe.
72
+ */
73
+ export interface SessionArtifactReceipt {
74
+ runId: string;
75
+ path: string;
76
+ digest: string;
77
+ }
78
+
79
+ /**
80
+ * The artifact content port: content I/O only, mechanical results, zero error prose —
81
+ * classification, policy, and all problem text are engine-owned. Every operation receives the
82
+ * ENGINE-VALIDATED run id (the artifact namespace), so storage, pointer, and receipt share one
83
+ * identity by construction — a backing never derives its own.
84
+ */
85
+ export interface ArtifactContentStore {
86
+ /** Persist bytes under the run; false = refusal (the port has already warned where its tier is loud). */
87
+ store(runId: string, name: string, content: string): boolean;
88
+ /** The run's current bytes; null = missing/unreadable. */
89
+ load(runId: string, name: string): string | null;
90
+ /** Strict reads distinguish genuine absence from I/O refusal; no fallback to load(). */
91
+ loadStrict(runId: string, name: string): SessionDataRead;
92
+ /** The receipt/warning display path, re-derived from the given identity — NEVER a persisted pointer field. */
93
+ displayPath(runId: string, name: string): string;
94
+ }
95
+
96
+ /**
97
+ * The classified artifact read. `absent` is the silent, branchable tier (no identity, no
98
+ * pointer, or a cross-run fork pointer — designed isolation); `invalid` is the loud tier (a
99
+ * pointer whose file is missing or digest-mismatched — rewind/tamper). Opt-in strict reads also
100
+ * classify missing identity, malformed provenance, orphan bytes and I/O failure as `invalid`;
101
+ * they return problems to the authorizing caller rather than emitting ordinary read warnings.
102
+ */
103
+ export type ReadArtifactResult =
104
+ | { status: "found"; content: string }
105
+ | { status: "absent" }
106
+ | { status: "invalid"; problem: SessionProblem };
107
+
108
+ /**
109
+ * The classified artifact write — the verified state op: `applied` proves the file AND the
110
+ * strict-appended `session_artifacts` pointer both landed and read back; `unchanged` is the
111
+ * byte-identical short-circuit (the recorded pointer already proves these bytes); `rejected`
112
+ * refused before any effect (including the no-identity refusal — artifacts need a run_id);
113
+ * `unverified` means an effect may have landed but the read-back proof failed — never
114
+ * consumable.
115
+ */
116
+ export type WriteArtifactResult =
117
+ | { status: "applied"; receipt: SessionArtifactReceipt }
118
+ | { status: "unchanged"; receipt: SessionArtifactReceipt }
119
+ | { status: "unverified"; problem: SessionProblem }
120
+ | { status: "rejected"; problem: SessionProblem };
121
+
122
+ /**
123
+ * The last automated `/pr-review` outcome (`last_pr_review`, contracts §8.3): exactly the
124
+ * record the `post_pr_review` post surface constructs on a real success. After a recorded wave,
125
+ * `angles` is the authoritative attempted manifest and `covered_angles` its schema-valid
126
+ * subset; standalone posts use the caller's angles for both.
127
+ */
128
+ export interface PrReviewRecord {
129
+ pr: number;
130
+ verdict: "clean" | "actionable";
131
+ angles: readonly string[];
132
+ covered_angles: readonly string[];
133
+ comment_count: number | null;
134
+ mode: string | null;
135
+ at: string;
136
+ }
137
+
138
+ /**
139
+ * The last curated review-door outcome (`last_review`, contracts §8.3): exactly the record the
140
+ * `submit_pr_review` post surface constructs on a real success. `event` stays `string` in the
141
+ * record — the decode boundary already constrains it; the stored field is render-only.
142
+ */
143
+ export interface ReviewSubmissionRecord {
144
+ pr: number;
145
+ event: string;
146
+ comment_count: number | null;
147
+ mode: string | null;
148
+ at: string;
149
+ }
150
+
151
+ /** The four known review-classification count keys the finalizer records (§8.3). */
152
+ export interface ReviewBatchCounts {
153
+ actionable?: number;
154
+ informational?: number;
155
+ praise?: number;
156
+ question?: number;
157
+ }
158
+
159
+ /**
160
+ * The last finalized review batch (`last_review_batch`, contracts §8.3): exactly the record the
161
+ * address finalizer constructs after publication and corroborated full thread resolution.
162
+ */
163
+ export interface ReviewBatchRecord {
164
+ pr: number | null;
165
+ counts: ReviewBatchCounts | null;
166
+ resolved_thread_ids: string[];
167
+ at: string;
168
+ }
169
+
170
+ /** One `review_posts` ledger row: a REAL curated submission that reached GitHub (§8.3). */
171
+ export interface ReviewPostRow {
172
+ pr: number;
173
+ event: string;
174
+ at: string;
175
+ }
176
+
177
+ /**
178
+ * Tolerant re-narrow of a rebuilt `review_posts` list (best-effort tier: a malformed row is
179
+ * dropped, never a refusal — the ledger only ever grows from the seam's own writes).
180
+ */
181
+ export function reviewPostsOf(raw: unknown): ReviewPostRow[] {
182
+ if (!Array.isArray(raw)) return [];
183
+ const rows: ReviewPostRow[] = [];
184
+ for (const item of raw) {
185
+ if (typeof item !== "object" || item === null || Array.isArray(item)) continue;
186
+ const row = item as Record<string, unknown>;
187
+ if (typeof row.pr !== "number" || !Number.isInteger(row.pr)) continue;
188
+ if (typeof row.event !== "string" || typeof row.at !== "string") continue;
189
+ rows.push({ pr: row.pr, event: row.event, at: row.at });
190
+ }
191
+ return rows;
192
+ }
193
+
194
+ /** Ledger equality for the read-back verification (order-sensitive — posting order matters). */
195
+ export function reviewPostsEqual(rebuilt: unknown, expected: unknown): boolean {
196
+ const a = reviewPostsOf(rebuilt);
197
+ const b = reviewPostsOf(expected);
198
+ if (a.length !== b.length) return false;
199
+ return a.every(
200
+ (row, i) => row.pr === b[i]?.pr && row.event === b[i]?.event && row.at === b[i]?.at,
201
+ );
202
+ }
203
+
204
+ /**
205
+ * The closed workflow-state change union — admitted variant-by-variant from proven callers
206
+ * (the proven callers: the plan-save surfaces). Reads stay NAMED (`nodeClaim()`); only changes
207
+ * ride the union.
208
+ */
209
+ export type WorkflowChange =
210
+ /** Link the live session to a saved plan: append `active_plan_ref` iff it differs. */
211
+ | { kind: "link-plan-ref"; ref: PlanRef }
212
+ /**
213
+ * Clear `objective_node_claim` iff the live claim matches BOTH fields (never clobbers an
214
+ * unrelated claim — a save linked to objective B node 1.1 must not clear objective A's 1.1).
215
+ */
216
+ | { kind: "clear-node-claim"; claim: { objective: string; node: string } }
217
+ /**
218
+ * Record `objective_node_claim` iff the live claim differs (`nodeClaimsEqual`) — an
219
+ * idempotent re-claim short-circuits `unchanged` (the re-append "refresh" carries no
220
+ * semantic payload: the claim has no timestamp and rebuilds identically).
221
+ */
222
+ | { kind: "record-node-claim"; claim: { objective: string; node: string } }
223
+ /** Link the live session to a saved objective: append `active_objective` iff it differs. */
224
+ | { kind: "link-objective"; objective: string }
225
+ /**
226
+ * Enter the refinement stage from a warm session: append `stage: "objective-refine"` iff the
227
+ * live stage differs — a STAGE-ONLY change (never a claim, plan-ref, objective or mode write;
228
+ * the warm `/objective-refine` entry sets the stage the cold handoff would have carried). An
229
+ * idempotent re-entry short-circuits `unchanged`.
230
+ */
231
+ | { kind: "enter-refinement-stage" }
232
+ /**
233
+ * Record the last automated `/pr-review` outcome: ONE `last_pr_review` append (LWW), strict
234
+ * read-back. No pre-read, no dedupe (same runtime invariant as `record-review`:
235
+ * `applied`/`unverified`/`rejected` only).
236
+ */
237
+ | { kind: "record-pr-review"; record: PrReviewRecord }
238
+ /**
239
+ * Record the last curated review-door outcome: ONE `last_review` append (LWW), strict
240
+ * read-back. No pre-read, no dedupe — the `already_posted` resume guard is feature-op policy
241
+ * upstream, so at runtime this yields `applied`/`unverified`/`rejected` only (a runtime
242
+ * invariant, not a type claim).
243
+ */
244
+ | { kind: "record-review"; record: ReviewSubmissionRecord }
245
+ /**
246
+ * Append one `review_posts` ledger row: read-rebuild-append of the whole ordered list, with
247
+ * the order-sensitive `reviewPostsEqual` read-back. The pre-read is FAIL-CLOSED and
248
+ * STRICT-DECODED (an unrebuildable or malformed persisted ledger refuses the append — see the
249
+ * engine's arm); no dedupe (same invariant as `record-review`:
250
+ * `applied`/`unverified`/`rejected` only at runtime).
251
+ */
252
+ | { kind: "append-review-post"; row: ReviewPostRow }
253
+ /**
254
+ * Record the last finalized review batch: ONE `last_review_batch` append (LWW), strict
255
+ * read-back. No pre-read, no dedupe — the corroborated-success-first ordering is feature-op
256
+ * policy upstream (`applied`/`unverified`/`rejected` only at runtime).
257
+ */
258
+ | { kind: "record-review-batch"; record: ReviewBatchRecord };
259
+
260
+ /**
261
+ * The classified change outcome: `applied` proves the append landed and read back; `unchanged`
262
+ * is the idempotent short-circuit (link: the rebuilt ref already equals; clear: no matching
263
+ * claim); `unverified` means the append may have landed but the read-back proof failed (the
264
+ * branch backing has already warned loudly); `rejected` refused before any effect.
265
+ */
266
+ export type WorkflowChangeResult =
267
+ | { status: "applied" }
268
+ | { status: "unchanged" }
269
+ | { status: "unverified"; problem: SessionProblem }
270
+ | { status: "rejected"; problem: SessionProblem };
271
+
272
+ /**
273
+ * The feature-facing session: optional identity + verified artifact ops + the named
274
+ * workflow-state reads and the closed change union. A session ALWAYS opens; `runId: null` is
275
+ * the identity-less arm (artifact ops classify it; state ops still work).
276
+ */
277
+ export interface WorkflowSession {
278
+ readonly runId: string | null;
279
+ /** Strict live identity only; completion must not revalidate source or routing inputs. */
280
+ currentRunIdentity():
281
+ | { ok: true; runId: string }
282
+ | { ok: false; reason: "no-identity" | "invalid-state" };
283
+ /** Strict single-snapshot routing read; malformed relevant claims refuse, never disappear. */
284
+ draftReviewContext():
285
+ | {
286
+ ok: true;
287
+ runId: string;
288
+ subject: "plan" | "objective" | "gist" | "refinement";
289
+ warmNodeClaim: { objective: string; node: string } | null;
290
+ }
291
+ | { ok: false; reason: "no-identity" | "invalid-state" };
292
+ readArtifact(name: string, options?: { provenance: "strict" }): ReadArtifactResult;
293
+ /** Strict writes refuse broken prior provenance; callers still own exclusion and failure residue. */
294
+ writeArtifact(
295
+ name: string,
296
+ content: string,
297
+ options?: { provenance: "strict" },
298
+ ): WriteArtifactResult;
299
+ /** Snapshot read of the rebuilt `objective_node_claim` (malformed ⇒ null). */
300
+ nodeClaim(): { objective: string; node: string } | null;
301
+ /** Snapshot read of the rebuilt `active_objective` (malformed/throwing ⇒ null). */
302
+ activeObjective(): string | null;
303
+ /**
304
+ * Shape-validated, fail-open read of the LIVE SESSION's rebuilt `active_plan_ref` — session
305
+ * linkage ONLY, deliberately with no checkout `cache.plan-ref` fallback (the checkout selector
306
+ * can name a future plan unrelated to this session; the checkout-first read is
307
+ * `substrate/workflowState.ts::activePlanRef`, a different authority). Absent, malformed, or
308
+ * unreadable linkage reads null. For continuation rendering, never permission, verified
309
+ * linkage, artifact validation, or review routing.
310
+ */
311
+ activeSessionPlanRef(): PlanRef | null;
312
+ /** Fail-open read of the rebuilt `review_posts` ledger (malformed rows dropped, never a refusal). */
313
+ reviewPosts(): ReviewPostRow[];
314
+ apply(change: WorkflowChange): WorkflowChangeResult;
315
+ }
316
+
317
+ // ------------------------------------------------------------------------------- the engine
318
+
319
+ /**
320
+ * Validate a session-artifact name at the seam: non-empty, no path separators (the artifact
321
+ * name keys the pointer map and joins under the run's data dir — a separator would escape it).
322
+ * Returns the problem string, or `null` when the name is safe. Name policy is refusal policy —
323
+ * it lives with the engine, not the backings.
324
+ */
325
+ export function sessionArtifactNameProblem(name: string): string | null {
326
+ if (name.trim() === "") return "session artifact name is empty";
327
+ if (name.includes("/") || name.includes("\\")) {
328
+ return `session artifact name ${JSON.stringify(name)} carries a path separator`;
329
+ }
330
+ return null;
331
+ }
332
+
333
+ /**
334
+ * The ONE persisted-pointer decode: accept a rebuilt `session_artifacts` value only when it is
335
+ * SHAPE-SOUND, and narrow the return to exactly the two fields the engine dereferences —
336
+ * branch data is unvalidated (`rebuildWorkflowState` trusts entry data), so a malformed session
337
+ * entry can put `null` — or anything else — where a pointer belongs, and the OTHER persisted
338
+ * fields (`path`, `name`, `at`) stay untrusted `unknown` even on a sound value (`path` is always
339
+ * re-derived, `name` is the map key, `at` has no consumer). Anything unsound reads as "no
340
+ * pointer" and never throws. Tests asserting persisted-pointer facts narrow through this —
341
+ * never a cast.
342
+ */
343
+ export function soundPointer(candidate: unknown): { run_id: string; digest: string } | null {
344
+ if (typeof candidate !== "object" || candidate === null) return null;
345
+ const pointer = candidate as Record<string, unknown>;
346
+ if (typeof pointer.run_id !== "string" || typeof pointer.digest !== "string") return null;
347
+ return { run_id: pointer.run_id, digest: pointer.digest };
348
+ }
349
+
350
+ /** Per-name pointer identity: same run_id + same digest (each side narrowed via the decode). */
351
+ function artifactMapsEqual(
352
+ rebuilt: Record<string, unknown> | null | undefined,
353
+ expected: Record<string, unknown> | null | undefined,
354
+ ): boolean {
355
+ const a = rebuilt ?? {};
356
+ const b = expected ?? {};
357
+ const names = Object.keys(b);
358
+ if (Object.keys(a).length !== names.length) return false;
359
+ return names.every((name) => {
360
+ const ra = soundPointer(a[name]);
361
+ const rb = soundPointer(b[name]);
362
+ // Unsound on both sides (junk siblings carried forward by the merged-map spread) compares
363
+ // equal — the append is verified on the pointers it can vouch for, never on junk shape.
364
+ if (ra === null || rb === null) return ra === rb;
365
+ return ra.run_id === rb.run_id && ra.digest === rb.digest;
366
+ });
367
+ }
368
+
369
+ /**
370
+ * Strict `review_posts` decode for the append pre-read — the ONE row schema is `reviewPostsOf`,
371
+ * consumed strictly: a present-but-malformed persisted ledger (non-array, or any row the
372
+ * tolerant decode would DROP) refuses with a problem naming the malformed variant instead of
373
+ * silently narrowing (a tolerant pre-read would let the whole-list LWW re-append ERASE
374
+ * malformed-but-possibly-real rows, violating the ledger invariant that a confirmed post is
375
+ * never erased by a write). Extra row fields are narrowed out, never a refusal.
376
+ */
377
+ function strictReviewPosts(raw: unknown): { rows: ReviewPostRow[] } | { malformed: string } {
378
+ if (!Array.isArray(raw)) return { malformed: `not a list (${JSON.stringify(raw)})` };
379
+ const rows = reviewPostsOf(raw);
380
+ if (rows.length !== raw.length) {
381
+ return {
382
+ malformed: `${raw.length - rows.length} malformed row(s) in ${JSON.stringify(raw)}`,
383
+ };
384
+ }
385
+ return { rows };
386
+ }
387
+
388
+ /** The two ports the engine runs over — the backings supply ONLY these. */
389
+ export interface WorkflowSessionDeps {
390
+ state: SessionStateStore;
391
+ artifacts: ArtifactContentStore;
392
+ }
393
+
394
+ /**
395
+ * The safe-narrowed active run id: the rebuilt `run_id`, non-empty AND safe as a path component
396
+ * (`isSafeRunId` — an unsafe persisted id degrades to no-identity BEFORE any path derivation);
397
+ * a throwing rebuild degrades to null (no resolvable identity, never a stamp).
398
+ */
399
+ function activeRunId(state: SessionStateStore): string | null {
400
+ try {
401
+ const runId = state.rebuild().run_id;
402
+ if (typeof runId === "string" && isSafeRunId(runId)) return runId;
403
+ } catch {
404
+ // a throwing rebuild means no resolvable identity — degrade to null
405
+ }
406
+ return null;
407
+ }
408
+
409
+ /** The rebuilt `objective_node_claim`, read fail-open (malformed/throwing rebuild → null). */
410
+ function readClaim(state: SessionStateStore): { objective: string; node: string } | null {
411
+ try {
412
+ const claim = state.rebuild().objective_node_claim ?? null;
413
+ if (
414
+ claim !== null &&
415
+ typeof claim.objective === "string" &&
416
+ claim.objective !== "" &&
417
+ typeof claim.node === "string" &&
418
+ claim.node !== ""
419
+ ) {
420
+ return claim;
421
+ }
422
+ return null;
423
+ } catch {
424
+ return null;
425
+ }
426
+ }
427
+
428
+ /** The rebuilt `active_objective`, read fail-open (malformed/throwing rebuild ⇒ null). */
429
+ function readActiveObjective(state: SessionStateStore): string | null {
430
+ try {
431
+ const value = state.rebuild().active_objective ?? null;
432
+ return typeof value === "string" && value !== "" ? value : null;
433
+ } catch {
434
+ return null;
435
+ }
436
+ }
437
+
438
+ /** Non-blank string guard for the session plan-ref decode (bytes are preserved, never trimmed). */
439
+ const nonblank = (value: unknown): value is string =>
440
+ typeof value === "string" && value.trim() !== "";
441
+
442
+ /**
443
+ * The ONE session-only plan-ref decode: the rebuilt `active_plan_ref` inspected as `unknown`
444
+ * (branch data is unvalidated) and accepted only in its persisted shape — non-blank string
445
+ * `provider`/`pr_id`/`url`, an all-string `labels` list, a REQUIRED `objective_id` that is null
446
+ * or a string, and `base` absent, null, or a string. The return is a reconstructed `PlanRef`
447
+ * carrying exactly those fields (extra persisted keys never escape; `base` omission is preserved
448
+ * versus an explicit null; no trimming, provider constraint, or URL parse). Fail-open: absent,
449
+ * malformed, or throwing rebuilds read null — the only consumer renders optional continuation
450
+ * guidance, so unreadability must degrade to "no plan named", never a thrown handler. One fresh
451
+ * rebuild per call; no checkout, artifact, append, or memoization.
452
+ */
453
+ function readActiveSessionPlanRef(state: SessionStateStore): PlanRef | null {
454
+ try {
455
+ const ref: unknown = state.rebuild().active_plan_ref;
456
+ if (typeof ref !== "object" || ref === null) return null;
457
+ const { provider, pr_id, url, labels, objective_id, base } = ref as Record<string, unknown>;
458
+ if (!nonblank(provider) || !nonblank(pr_id) || !nonblank(url)) return null;
459
+ if (!Array.isArray(labels) || !labels.every((l): l is string => typeof l === "string")) {
460
+ return null;
461
+ }
462
+ if (objective_id !== null && typeof objective_id !== "string") return null;
463
+ if (base !== undefined && base !== null && typeof base !== "string") return null;
464
+ return {
465
+ provider,
466
+ pr_id,
467
+ url,
468
+ labels: [...labels],
469
+ objective_id,
470
+ ...(base !== undefined ? { base } : {}),
471
+ };
472
+ } catch {
473
+ return null;
474
+ }
475
+ }
476
+
477
+ /** The rebuilt `review_posts` ledger, read fail-open (malformed rows drop; a throwing rebuild ⇒ []). */
478
+ function readReviewPosts(state: SessionStateStore): ReviewPostRow[] {
479
+ try {
480
+ return reviewPostsOf(state.rebuild().review_posts);
481
+ } catch {
482
+ return [];
483
+ }
484
+ }
485
+
486
+ function strictArtifactMap(raw: unknown): Record<string, SessionArtifactPointer> {
487
+ if (raw === undefined || raw === null) return {};
488
+ if (
489
+ typeof raw !== "object" ||
490
+ Array.isArray(raw) ||
491
+ (Object.getPrototypeOf(raw) !== Object.prototype && Object.getPrototypeOf(raw) !== null)
492
+ ) {
493
+ throw new Error("session_artifacts map is malformed");
494
+ }
495
+ const entries: [string, SessionArtifactPointer][] = [];
496
+ for (const [key, value] of Object.entries(raw)) {
497
+ if (
498
+ !isSafeRunId(key) ||
499
+ key.trim() === "" ||
500
+ typeof value !== "object" ||
501
+ value === null ||
502
+ Array.isArray(value)
503
+ )
504
+ throw new Error("session_artifacts pointer is malformed");
505
+ const p = value as Record<string, unknown>;
506
+ if (
507
+ typeof p.run_id !== "string" ||
508
+ !isSafeRunId(p.run_id) ||
509
+ typeof p.digest !== "string" ||
510
+ !/^sha256:[0-9a-f]{64}$/.test(p.digest) ||
511
+ p.name !== key ||
512
+ typeof p.path !== "string" ||
513
+ p.path.length === 0 ||
514
+ typeof p.at !== "string" ||
515
+ p.at.length === 0
516
+ ) {
517
+ throw new Error("session_artifacts pointer is malformed");
518
+ }
519
+ entries.push([key, { run_id: p.run_id, name: key, path: p.path, digest: p.digest, at: p.at }]);
520
+ }
521
+ return Object.fromEntries(entries);
522
+ }
523
+
524
+ function strictArtifactRead(deps: WorkflowSessionDeps, name: string): ReadArtifactResult {
525
+ const invalid = (problem: string): ReadArtifactResult => ({ status: "invalid", problem });
526
+ if (!isSafeRunId(name) || sessionArtifactNameProblem(name) !== null) {
527
+ return invalid("unsafe session artifact name");
528
+ }
529
+ try {
530
+ // One snapshot: a throwing/malformed identity read is never evidence of absence.
531
+ const snapshot = deps.state.rebuild();
532
+ if (typeof snapshot !== "object" || snapshot === null || Array.isArray(snapshot)) {
533
+ return invalid("session artifact state is malformed");
534
+ }
535
+ const runId = snapshot.run_id;
536
+ if (typeof runId !== "string" || !isSafeRunId(runId)) {
537
+ return invalid("session has no safe run_id — strict artifact reads need identity");
538
+ }
539
+ // Validate siblings too: the next whole-map append must not launder malformed provenance.
540
+ const map = strictArtifactMap(snapshot.session_artifacts);
541
+ const pointer = Object.hasOwn(map, name) ? map[name] : null;
542
+ // Never dereference inherited pointers. Inspect ONLY the active run's namespace, including
543
+ // when there is no pointer: bytes without current-run provenance are an orphan, not absent.
544
+ const loaded = deps.artifacts.loadStrict(runId, name);
545
+ if (loaded.status === "io-error") return invalid(`session artifact ${name} I/O failure`);
546
+ if (pointer == null || pointer.run_id !== runId) {
547
+ return loaded.status === "absent"
548
+ ? { status: "absent" }
549
+ : invalid(`session artifact ${name} has no current-run provenance (orphan)`);
550
+ }
551
+ if (loaded.status === "absent")
552
+ return invalid(`session artifact ${name} has a pointer but no file`);
553
+ if (digestSessionData(loaded.content) !== pointer.digest) {
554
+ return invalid(`session artifact ${name} digest mismatch (rewound or modified)`);
555
+ }
556
+ return { status: "found", content: loaded.content };
557
+ } catch {
558
+ return invalid(`session artifact ${name} strict read failed`);
559
+ }
560
+ }
561
+
562
+ /**
563
+ * Open a session over the two ports — the ONE deep implementation of the seam. ALWAYS opens;
564
+ * `runId: null` is the identity-less arm (artifact writes reject, reads read absent; the state
565
+ * ops are store-backed and identity-independent). `runId` is captured at open and re-derived
566
+ * per artifact call (a fork entry appended mid-session re-keys the artifact ops — today's
567
+ * behavior). Loudness: the strict-append port reports its own read-back failures (the
568
+ * classified-append seam's report() channel inside the production binding); the engine warns on
569
+ * stderr for the loud read tier (rewind/tamper) and the unreadable-after-write arm.
570
+ */
571
+ export function openWorkflowSession(deps: WorkflowSessionDeps): WorkflowSession {
572
+ const { state, artifacts } = deps;
573
+
574
+ return {
575
+ runId: activeRunId(state),
576
+ currentRunIdentity() {
577
+ try {
578
+ const snapshot = state.rebuild();
579
+ if (typeof snapshot !== "object" || snapshot === null || Array.isArray(snapshot))
580
+ return { ok: false, reason: "invalid-state" };
581
+ const runId = snapshot.run_id;
582
+ return typeof runId === "string" && isSafeRunId(runId)
583
+ ? { ok: true, runId }
584
+ : { ok: false, reason: "no-identity" };
585
+ } catch {
586
+ return { ok: false, reason: "invalid-state" };
587
+ }
588
+ },
589
+ draftReviewContext() {
590
+ try {
591
+ const snapshot = state.rebuild();
592
+ const runId = snapshot.run_id;
593
+ if (typeof runId !== "string" || !isSafeRunId(runId))
594
+ return { ok: false, reason: "no-identity" };
595
+ const stage = snapshot.stage;
596
+ if (stage != null && (typeof stage !== "string" || !stage.trim()))
597
+ return { ok: false, reason: "invalid-state" };
598
+ const subject =
599
+ stage === "objective-author" || stage === "objective-save"
600
+ ? "objective"
601
+ : stage === "gist-author"
602
+ ? "gist"
603
+ : stage === REFINE_STAGE_ID
604
+ ? "refinement"
605
+ : "plan";
606
+ const raw: unknown = subject === "plan" ? snapshot.objective_node_claim : null;
607
+ let warmNodeClaim: { objective: string; node: string } | null = null;
608
+ if (raw != null) {
609
+ if (typeof raw !== "object" || Array.isArray(raw))
610
+ return { ok: false, reason: "invalid-state" };
611
+ const claim = raw as Record<string, unknown>;
612
+ if (
613
+ Object.keys(claim).length !== 2 ||
614
+ typeof claim.objective !== "string" ||
615
+ !claim.objective.trim() ||
616
+ typeof claim.node !== "string" ||
617
+ !claim.node.trim()
618
+ )
619
+ return { ok: false, reason: "invalid-state" };
620
+ warmNodeClaim = { objective: claim.objective, node: claim.node };
621
+ }
622
+ return { ok: true, runId, subject, warmNodeClaim };
623
+ } catch {
624
+ return { ok: false, reason: "invalid-state" };
625
+ }
626
+ },
627
+ readArtifact(name: string, options): ReadArtifactResult {
628
+ if (options?.provenance === "strict") return strictArtifactRead(deps, name);
629
+ const runId = activeRunId(state);
630
+ if (runId === null) return { status: "absent" }; // no identity — silent, branchable
631
+ let pointer: { run_id: string; digest: string } | null;
632
+ try {
633
+ pointer = soundPointer(state.rebuild().session_artifacts?.[name]);
634
+ } catch {
635
+ return { status: "absent" };
636
+ }
637
+ if (pointer === null) return { status: "absent" }; // no pointer — or a malformed one (no provenance)
638
+ if (pointer.run_id !== runId) return { status: "absent" }; // fork isolation — by design, silent
639
+
640
+ const content = artifacts.load(runId, name);
641
+ if (content === null) {
642
+ console.error(
643
+ `perk: warning: session artifact ${name} has a pointer but no file at ` +
644
+ artifacts.displayPath(runId, name),
645
+ );
646
+ return { status: "invalid", problem: `session artifact ${name} has a pointer but no file` };
647
+ }
648
+ if (digestSessionData(content) !== pointer.digest) {
649
+ console.error(
650
+ `perk: warning: session artifact ${artifacts.displayPath(runId, name)} digest mismatch ` +
651
+ "(rewound or modified) — refusing",
652
+ );
653
+ return {
654
+ status: "invalid",
655
+ problem: `session artifact ${name} digest mismatch (rewound or modified)`,
656
+ };
657
+ }
658
+ return { status: "found", content };
659
+ },
660
+ writeArtifact(name: string, content: string, options): WriteArtifactResult {
661
+ const strict = options?.provenance === "strict";
662
+ if (strict) {
663
+ const prior = strictArtifactRead(deps, name);
664
+ if (prior.status === "invalid") return { status: "rejected", problem: prior.problem };
665
+ }
666
+ let effectAttempted = false;
667
+ try {
668
+ const nameProblem = sessionArtifactNameProblem(name);
669
+ if (nameProblem !== null) return { status: "rejected", problem: nameProblem };
670
+
671
+ const runId = activeRunId(state);
672
+ if (runId === null) {
673
+ return {
674
+ status: "rejected",
675
+ problem: "session has no run_id — session artifacts need identity",
676
+ };
677
+ }
678
+
679
+ // The unchanged short-circuit: a byte-identical rewrite is a no-op (no store, no fresh
680
+ // pointer entry) — the recorded pointer already proves exactly these bytes. QUIET by
681
+ // design: a stale/broken/malformed pointer simply fails the probe and the write proceeds
682
+ // (the probe must never emit the read tier's rewind warnings). The receipt is fully
683
+ // re-derived — junk persisted fields are unobservable.
684
+ let current: { run_id: string; digest: string } | null;
685
+ try {
686
+ const snapshot = state.rebuild();
687
+ if (strict && snapshot.run_id !== runId) throw new Error("session identity changed");
688
+ current = strict
689
+ ? (strictArtifactMap(snapshot.session_artifacts)[name] ?? null)
690
+ : soundPointer(snapshot.session_artifacts?.[name]);
691
+ } catch (error) {
692
+ if (strict) throw error;
693
+ current = null;
694
+ }
695
+ if (current !== null && current.run_id === runId) {
696
+ const checked = strict ? strictArtifactRead(deps, name) : null;
697
+ if (checked !== null && checked.status !== "found") {
698
+ return {
699
+ status: "rejected",
700
+ problem: `session artifact ${name} provenance changed before write`,
701
+ };
702
+ }
703
+ const stored =
704
+ checked?.status === "found" ? checked.content : artifacts.load(runId, name);
705
+ if (
706
+ stored !== null &&
707
+ digestSessionData(stored) === current.digest &&
708
+ current.digest === digestSessionData(content)
709
+ ) {
710
+ return {
711
+ status: "unchanged",
712
+ receipt: {
713
+ runId,
714
+ path: artifacts.displayPath(runId, name),
715
+ digest: digestSessionData(stored),
716
+ },
717
+ };
718
+ }
719
+ }
720
+
721
+ effectAttempted = true;
722
+ if (!artifacts.store(runId, name, content)) {
723
+ // the port already warned; never point at an unwritten file
724
+ return {
725
+ status: "rejected",
726
+ problem: `could not write session data ${name} (see warnings)`,
727
+ };
728
+ }
729
+
730
+ // Digest the bytes as read back from the store — catches encoding/disk surprises.
731
+ const strictReadBack = strict ? artifacts.loadStrict(runId, name) : null;
732
+ const readBack =
733
+ strictReadBack === null
734
+ ? artifacts.load(runId, name)
735
+ : strictReadBack.status === "found"
736
+ ? strictReadBack.content
737
+ : null;
738
+ if (readBack === null) {
739
+ const problem = `session artifact ${artifacts.displayPath(runId, name)} unreadable after write`;
740
+ console.error(`perk: warning: ${problem}`);
741
+ return { status: "unverified", problem };
742
+ }
743
+
744
+ if (strict && readBack !== content) {
745
+ return {
746
+ status: "unverified",
747
+ problem: `session artifact ${name} write read-back differs`,
748
+ };
749
+ }
750
+
751
+ // The persisted wire shape (contracts §8.3) — constructed at the storage boundary,
752
+ // internal to the engine, never exposed through results.
753
+ const pointer: SessionArtifactPointer = {
754
+ run_id: runId,
755
+ name,
756
+ path: artifacts.displayPath(runId, name),
757
+ digest: digestSessionData(readBack),
758
+ at: new Date().toISOString(),
759
+ };
760
+
761
+ // Per-field LWW: each append must carry the WHOLE merged map so sibling artifacts survive
762
+ // (junk siblings carry forward unchanged — existing LWW behavior).
763
+ const latest = state.rebuild();
764
+ if (strict && latest.run_id !== runId) {
765
+ return { status: "unverified", problem: "session identity changed after artifact write" };
766
+ }
767
+ const merged: Record<string, unknown> = {
768
+ ...(strict
769
+ ? strictArtifactMap(latest.session_artifacts)
770
+ : (latest.session_artifacts ?? {})),
771
+ [name]: pointer,
772
+ };
773
+ const appended = state.appendVerified({
774
+ data: { session_artifacts: merged },
775
+ field: "session_artifacts",
776
+ expected: merged,
777
+ scope: "session-data",
778
+ failure: `session_artifacts pointer read-back failed for ${name}`,
779
+ equals: artifactMapsEqual,
780
+ });
781
+ if (appended.status !== "applied") {
782
+ // already reported through the strict-append port
783
+ return {
784
+ status: "unverified",
785
+ problem: `session_artifacts pointer read-back failed for ${name}`,
786
+ };
787
+ }
788
+ if (strict) {
789
+ const verified = strictArtifactRead(deps, name);
790
+ if (verified.status !== "found" || verified.content !== content) {
791
+ return {
792
+ status: "unverified",
793
+ problem: `session artifact ${name} strict write verification failed`,
794
+ };
795
+ }
796
+ }
797
+ return {
798
+ status: "applied",
799
+ receipt: { runId, path: pointer.path, digest: pointer.digest },
800
+ };
801
+ } catch (error) {
802
+ if (!strict) throw error;
803
+ return {
804
+ status: effectAttempted ? "unverified" : "rejected",
805
+ problem: `session artifact ${name} strict write failed`,
806
+ };
807
+ }
808
+ },
809
+ nodeClaim() {
810
+ return readClaim(state);
811
+ },
812
+ activeObjective() {
813
+ return readActiveObjective(state);
814
+ },
815
+ activeSessionPlanRef() {
816
+ return readActiveSessionPlanRef(state);
817
+ },
818
+ reviewPosts() {
819
+ return readReviewPosts(state);
820
+ },
821
+ apply(change: WorkflowChange): WorkflowChangeResult {
822
+ switch (change.kind) {
823
+ case "link-plan-ref": {
824
+ const ref = change.ref;
825
+ // The pre-read dedupe is deliberately NOT try/caught — a throwing rebuild propagates.
826
+ if (planRefsEqual(state.rebuild().active_plan_ref ?? null, ref)) {
827
+ return { status: "unchanged" };
828
+ }
829
+ // The classified strict-append distinguishes a PROVEN refusal-before-effect (the
830
+ // append threw and the rebuilt field never changed — `rejected`) from a read-back
831
+ // miss (`unverified`: an append may have landed unproven); its report() path stays
832
+ // the loudness channel. `ClassifiedAppend` IS the seam's change vocabulary.
833
+ return state.appendVerified({
834
+ data: { active_plan_ref: ref },
835
+ field: "active_plan_ref",
836
+ expected: ref,
837
+ scope: "plan-save",
838
+ failure: `plan-ref read-back failed for ${ref.provider}:${ref.pr_id}`,
839
+ equals: planRefsEqual,
840
+ });
841
+ }
842
+ case "clear-node-claim": {
843
+ const claim = change.claim;
844
+ // Never clobber an unrelated claim: clear only when the LIVE claim matches BOTH
845
+ // fields (same-node/different-objective stays untouched).
846
+ if (!nodeClaimsEqual(readClaim(state), claim)) return { status: "unchanged" };
847
+ return state.appendVerified({
848
+ data: { objective_node_claim: null },
849
+ field: "objective_node_claim",
850
+ expected: null,
851
+ scope: "plan-save",
852
+ failure: `objective_node_claim clear read-back failed for node ${claim.node}`,
853
+ equals: nodeClaimsEqual,
854
+ });
855
+ }
856
+ case "record-node-claim": {
857
+ const claim = change.claim;
858
+ // The idempotent re-claim short-circuit: an equal live claim rebuilds identically, so
859
+ // a re-append would carry no semantic payload (the claim has no timestamp).
860
+ if (nodeClaimsEqual(readClaim(state), claim)) return { status: "unchanged" };
861
+ return state.appendVerified({
862
+ data: { objective_node_claim: claim },
863
+ field: "objective_node_claim",
864
+ expected: claim,
865
+ scope: "objective-plan",
866
+ failure: `objective_node_claim read-back failed for #${claim.objective} node ${claim.node}`,
867
+ equals: nodeClaimsEqual,
868
+ });
869
+ }
870
+ case "link-objective": {
871
+ const objective = change.objective;
872
+ if (readActiveObjective(state) === objective) return { status: "unchanged" };
873
+ return state.appendVerified({
874
+ data: { active_objective: objective },
875
+ field: "active_objective",
876
+ expected: objective,
877
+ scope: "objective-save",
878
+ failure: `active_objective read-back failed for #${objective}`,
879
+ });
880
+ }
881
+ case "enter-refinement-stage": {
882
+ if (state.rebuild().stage === REFINE_STAGE_ID) return { status: "unchanged" };
883
+ return state.appendVerified({
884
+ data: { stage: REFINE_STAGE_ID },
885
+ field: "stage",
886
+ expected: REFINE_STAGE_ID,
887
+ scope: "objective-refine",
888
+ failure: `stage read-back failed for ${REFINE_STAGE_ID}`,
889
+ });
890
+ }
891
+ case "record-pr-review": {
892
+ // No pre-read/dedupe by design (the single-use wave state is feature-op policy
893
+ // upstream): at runtime this yields applied/unverified/rejected only.
894
+ return state.appendVerified({
895
+ data: { last_pr_review: change.record },
896
+ field: "last_pr_review",
897
+ expected: change.record,
898
+ scope: "pr-review",
899
+ failure: "last_pr_review read-back failed",
900
+ });
901
+ }
902
+ case "record-review": {
903
+ // No pre-read/dedupe by design (the resume guard is feature-op policy upstream): at
904
+ // runtime this yields applied/unverified/rejected only.
905
+ return state.appendVerified({
906
+ data: { last_review: change.record },
907
+ field: "last_review",
908
+ expected: change.record,
909
+ scope: "review",
910
+ failure: "last_review read-back failed",
911
+ });
912
+ }
913
+ case "record-review-batch": {
914
+ // No pre-read/dedupe by design (the corroborated-success ordering is feature-op
915
+ // policy upstream): at runtime this yields applied/unverified/rejected only.
916
+ return state.appendVerified({
917
+ data: { last_review_batch: change.record },
918
+ field: "last_review_batch",
919
+ expected: change.record,
920
+ scope: "address",
921
+ failure: "last_review_batch read-back failed",
922
+ });
923
+ }
924
+ case "append-review-post": {
925
+ // Read-rebuild-append: each write carries the whole ordered list (the resume reader
926
+ // sees every confirmed post); order-sensitive read-back. The pre-read is FAIL-CLOSED
927
+ // and STRICT — deliberately NOT the fail-open `reviewPosts()` read: appending over an
928
+ // unrebuildable OR malformed ledger would LWW-overwrite possibly-real earlier rows,
929
+ // and the resume guard would then permit duplicate GitHub reviews. Refusing before
930
+ // any effect keeps the asymmetric trust rule intact (a row may be MISSING spuriously,
931
+ // never PRESENT spuriously — and never erased by a write). An ABSENT field is the
932
+ // normal first append (the empty prior ledger).
933
+ let prior: ReviewPostRow[];
934
+ try {
935
+ const raw = state.rebuild().review_posts;
936
+ if (raw === undefined || raw === null) {
937
+ prior = [];
938
+ } else {
939
+ const decoded = strictReviewPosts(raw);
940
+ if ("malformed" in decoded) {
941
+ return {
942
+ status: "rejected",
943
+ problem:
944
+ "review_posts ledger is malformed — refusing to append over an unknown " +
945
+ `ledger: ${decoded.malformed}`,
946
+ };
947
+ }
948
+ prior = decoded.rows;
949
+ }
950
+ } catch (error) {
951
+ return {
952
+ status: "rejected",
953
+ problem:
954
+ "review_posts ledger rebuild failed — refusing to append over an unknown " +
955
+ `ledger: ${String(error)}`,
956
+ };
957
+ }
958
+ const posts: ReviewPostRow[] = [...prior, change.row];
959
+ return state.appendVerified({
960
+ data: { review_posts: posts },
961
+ field: "review_posts",
962
+ expected: posts,
963
+ scope: "review",
964
+ failure: "review_posts read-back failed",
965
+ equals: reviewPostsEqual,
966
+ });
967
+ }
968
+ }
969
+ },
970
+ };
971
+ }