@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,644 @@
1
+ // The session identity lifecycle (contracts.md §8.2/§8.3) as named, Pi-free session
2
+ // operations: decide what `session_start` should do (claim / fork / adopt / mint / keep) and
3
+ // perform the workflow-state establishment for the decided arm — the ONE combined claim entry
4
+ // with establish-before-consume, the derived fork/adopt identities, the warm mint, and the
5
+ // deliberate keep-arm non-write (reload-generation reconstruction IS the LWW rebuild; no
6
+ // version backfill) — then the two-phase startup facts around the gate: `sessionStartToolScope`
7
+ // (pure; the mode/stage slice the gate syncs from BEFORE any fallible read) and
8
+ // `resolveSessionStartFacts` (post-gate; the lazy launched-stage linkage reconciliation plus the
9
+ // implementation-capture and feedback-receiver inputs), with `sessionTreeFacts` as the
10
+ // navigation twin over the already-rebuilt selected-branch state.
11
+ //
12
+ // Pi-free by construction (importDirectionGuard Rule D): effects arrive through narrow
13
+ // injection points — `SessionStateStore` (the workflow-state slice: rebuild + plain append +
14
+ // the strict verified append), `SessionIdentityPorts` (handoff read/consume, run-scratch
15
+ // isolation, the run-id mint, the §8.3 version stamp), and `SessionStartFactReads` (the handoff
16
+ // + checkout plan-ref reads the post-gate facts may touch). `index.ts` binds the production
17
+ // values, orders the Pi effects, and renders the outcome's per-arm problems/warnings with today's
18
+ // exact report scopes; the strict appends keep reporting through
19
+ // `appendWorkflowStateClassified`'s own loudness channel (the report slice rides
20
+ // `SessionArtifactCtx`, re-exported via `substrate/sessionData.ts` — this module never imports
21
+ // `surfaces/`, `pi/`, or the feedback receiver).
22
+ //
23
+ // ONE handoff authority: every handoff/run-id read — the claim arm's, `decideClaim`'s
24
+ // env-child probe, `resolveRunStage`'s stage lookup, and `deriveForkRunId`'s sibling scan —
25
+ // flows through the injected reads (`SessionIdentityReads`, the read slice of
26
+ // `SessionIdentityPorts`), so the lifecycle is genuinely independent of the cache backing and
27
+ // the fakes never touch disk. The decision logic is byte-identical to its
28
+ // `substrate/workflowState.ts` ancestry.
29
+
30
+ import type { Handoff, PlanRef } from "../substrate/cache.ts";
31
+ import { type Registry, stageConsumesPlanRef } from "../substrate/registry.ts";
32
+ import type { SessionArtifactCtx } from "../substrate/sessionData.ts";
33
+ import {
34
+ type AppendWorkflowStateOpts,
35
+ appendWorkflowStateClassified,
36
+ branchOf,
37
+ type ClassifiedAppend,
38
+ type EntrySink,
39
+ planRefsEqual,
40
+ rebuildWorkflowState,
41
+ WORKFLOW_STATE_TYPE,
42
+ type WorkflowState,
43
+ } from "../substrate/workflowState.ts";
44
+
45
+ /**
46
+ * The exterior reads the lifecycle's decision tier needs — the read slice of
47
+ * `SessionIdentityPorts` (production bound to `substrate/cache.ts` by `index.ts`; the test
48
+ * suites bind fakes).
49
+ */
50
+ export interface SessionIdentityReads {
51
+ /** The cold-launch handoff blob for `runId`, or null (missing/unreadable). */
52
+ readHandoff(runId: string): Handoff | null;
53
+ /** The existing run ids under `scratch/runs/` (the fork/adopt sibling-derivation scan). */
54
+ listRunIds(): string[];
55
+ }
56
+
57
+ /**
58
+ * Derive a fork-child run_id: `<parent>.<n>` where `n` is the max existing sibling + 1
59
+ * (over the `scratch/runs/` scan), else 1.
60
+ */
61
+ export function deriveForkRunId(parentRunId: string, runIds: Iterable<string>): string {
62
+ const prefix = `${parentRunId}.`;
63
+ let max = 0;
64
+ for (const id of runIds) {
65
+ if (!id.startsWith(prefix)) continue;
66
+ const segment = id.slice(prefix.length).split(".")[0] ?? "";
67
+ const n = Number.parseInt(segment, 10);
68
+ if (Number.isInteger(n) && n > max) max = n;
69
+ }
70
+ return `${parentRunId}.${max + 1}`;
71
+ }
72
+
73
+ export type ClaimDecision =
74
+ | { action: "keep"; source: "session"; state: WorkflowState }
75
+ | {
76
+ action: "fork";
77
+ source: "fork";
78
+ childRunId: string;
79
+ parentRunId: string;
80
+ state: WorkflowState;
81
+ }
82
+ | { action: "claim"; source: "env"; runId: string }
83
+ | {
84
+ action: "adopt";
85
+ source: "env-child";
86
+ childRunId: string;
87
+ parentRunId: string;
88
+ /** Inherited from the parent's handoff so read-only gating survives into the child. */
89
+ mode?: string;
90
+ }
91
+ | { action: "none"; source: "none"; state: WorkflowState };
92
+
93
+ /**
94
+ * The registry stage id the launched run is acting on, read from its handoff blob, or null.
95
+ * Only `claim` (cold) and `keep` (reload) sessions have a settled run whose handoff records a
96
+ * `stage` — a kept session re-reads its run's handoff on every reload, so a consuming stage's
97
+ * checkout binding is re-read there too; `fork`, `adopt`, and `none` carry no launched stage (an
98
+ * adopted env-child must never impersonate the launched stage; LWW restores fork/none state
99
+ * instead). The stage gates whether `session_start` reconciles `cache.plan-ref` into
100
+ * `active_plan_ref`.
101
+ */
102
+ export function resolveRunStage(
103
+ decision: ClaimDecision,
104
+ reads: Pick<SessionIdentityReads, "readHandoff">,
105
+ ): string | null {
106
+ const runId =
107
+ decision.action === "claim"
108
+ ? decision.runId
109
+ : decision.action === "keep"
110
+ ? decision.state.run_id
111
+ : null;
112
+ if (runId === undefined || runId === null) return null;
113
+ const stage = reads.readHandoff(runId)?.stage;
114
+ return typeof stage === "string" && stage !== "" ? stage : null;
115
+ }
116
+
117
+ /**
118
+ * Decide what `session_start` should do, from the rebuilt state + the current session handle
119
+ * + the launch env. Reload vs fork is distinguished by the `run_id ↔ pi_session_id` mapping
120
+ * (NOT `event.reason`, which is "startup" for a headless `pi --fork`): if the branch already
121
+ * carries a `run_id` whose recorded `pi_session_id` differs from the current session, the id
122
+ * was inherited across a fork → derive a child; if it matches (or is absent), it's a reload.
123
+ * An env-inherited run id whose handoff was already CONSUMED by a different session is a
124
+ * spawned child, not the launched session → `adopt` (derive a sibling id, inherit `mode`).
125
+ */
126
+ export function decideClaim(args: {
127
+ state: WorkflowState;
128
+ currentSessionId: string | null;
129
+ envRunId: string | null;
130
+ reads: SessionIdentityReads;
131
+ }): ClaimDecision {
132
+ const { state, currentSessionId, envRunId, reads } = args;
133
+ if (state.run_id !== undefined) {
134
+ if (state.pi_session_id === undefined || state.pi_session_id === currentSessionId) {
135
+ return { action: "keep", source: "session", state };
136
+ }
137
+ const childRunId = deriveForkRunId(state.run_id, reads.listRunIds());
138
+ return { action: "fork", source: "fork", childRunId, parentRunId: state.run_id, state };
139
+ }
140
+ if (envRunId !== null && envRunId !== "") {
141
+ // Env-child detection (contracts §8.2): subagent children are spawned as separate `pi`
142
+ // processes with the parent's env, so they arrive here carrying the parent's PERK_RUN_ID.
143
+ // A handoff already consumed by a DIFFERENT (or unrecorded) session belongs to someone else:
144
+ // adopt a derived `<run_id>.<n>` child identity instead of re-claiming — never re-consume the
145
+ // handoff, never capture pointers, never impersonate the launched stage. The parent's `mode`
146
+ // is inherited so read-only gating survives into exploration children. Everything else —
147
+ // absent/corrupt/mismatched handoff (the loud unclaimed error), unconsumed (the normal cold
148
+ // claim), or consumed by THIS session (idempotent re-claim after lost branch state) — stays
149
+ // the claim arm.
150
+ const handoff = reads.readHandoff(envRunId);
151
+ if (
152
+ handoff !== null &&
153
+ handoff.run_id === envRunId &&
154
+ handoff.consumed === true &&
155
+ handoff.pi_session_id !== currentSessionId
156
+ ) {
157
+ return {
158
+ action: "adopt",
159
+ source: "env-child",
160
+ childRunId: deriveForkRunId(envRunId, reads.listRunIds()),
161
+ parentRunId: envRunId,
162
+ mode: handoff.mode,
163
+ };
164
+ }
165
+ return { action: "claim", source: "env", runId: envRunId };
166
+ }
167
+ return { action: "none", source: "none", state };
168
+ }
169
+
170
+ // --------------------------------------------------------------- the identity establishment
171
+
172
+ /**
173
+ * The narrow workflow-state store port: the LWW rebuild, the plain (honest-tier, no read-back)
174
+ * append the fork/adopt arms use, and the strict verified append. ONE port serves both the
175
+ * identity lifecycle (which verifies `run_id`) and the session engine
176
+ * (`session/workflowSession.ts`, which verifies each change's own field) — hence the generic
177
+ * verified append.
178
+ */
179
+ export interface SessionStateStore {
180
+ rebuild(): WorkflowState;
181
+ append(data: WorkflowState): void;
182
+ appendVerified<K extends keyof WorkflowState>(opts: AppendWorkflowStateOpts<K>): ClassifiedAppend;
183
+ }
184
+
185
+ /**
186
+ * The production `SessionStateStore` over the live branch: `EntrySink` appends,
187
+ * `rebuildWorkflowState` over the branch, and `appendWorkflowStateClassified` for the strict
188
+ * tier (its report() path stays the loudness channel for read-back failures).
189
+ */
190
+ export function branchSessionStateStore(
191
+ sink: EntrySink,
192
+ source: SessionArtifactCtx,
193
+ ): SessionStateStore {
194
+ return {
195
+ rebuild: () => rebuildWorkflowState(branchOf(source)),
196
+ append: (data) => sink.appendEntry(WORKFLOW_STATE_TYPE, data),
197
+ appendVerified: (opts) => appendWorkflowStateClassified(sink, source, opts),
198
+ };
199
+ }
200
+
201
+ /** The exterior effects the lifecycle needs — production bound in `index.ts`, fakes in tests. */
202
+ export interface SessionIdentityPorts extends SessionIdentityReads {
203
+ /** Mark the handoff consumed (establish-before-consume: called only after a verified claim). */
204
+ markHandoffConsumed(runId: string, opts: { piSessionId?: string }): void;
205
+ /** Isolate the derived child's run scratch root (a throw is tolerated — warned, not fatal). */
206
+ ensureRunScratch(runId: string): void;
207
+ /** Mint a fresh run_id for the warm identity-less arm. */
208
+ mintRunId(): string;
209
+ /**
210
+ * The §8.3 exact-vintage stamp recorded by every identity-establishing arm
211
+ * (claim/fork/adopt/mint); undefined (the perkVersion() failure sentinel) drops the key on
212
+ * serialize and leaves the session on the timestamp-estimate arm.
213
+ */
214
+ versionStamp: string | undefined;
215
+ }
216
+
217
+ /** Which lifecycle arm settled (the failed claim/mint arms both read `unclaimed`). */
218
+ export type SessionIdentityArm = "claimed" | "kept" | "forked" | "adopted" | "minted" | "unclaimed";
219
+
220
+ /**
221
+ * What `session_start` consumes downstream: the settled arm, the resolved state the byte-
222
+ * identical derivations run over (`scopeStage`, `implStage`, the sentinel source —
223
+ * `arm === "minted" ? "mint" : decision.source`), the decision itself, and the per-arm
224
+ * problems/warnings the caller renders with today's exact report scopes ("workflow-state
225
+ * linkage error" `{alsoLog: true}`; "run scratch" warnings).
226
+ *
227
+ * A discriminated union on `arm`, correlating each settled arm with exactly the decision that
228
+ * can produce it: an impossible pair (e.g. `forked` carrying a claim decision) does not
229
+ * compile, and narrowing on `arm` proves the decision's fields. `unclaimed` is the shared
230
+ * failure arm of the two strict-append paths — a failed cold claim (`action: "claim"`) or a
231
+ * failed mint (`action: "none"`); the correlated decision says which.
232
+ */
233
+ export type EstablishIdentityOutcome = {
234
+ resolved: WorkflowState;
235
+ /** Caller-rendered with scope "workflow-state linkage error" (`{alsoLog: true}`). */
236
+ problems: string[];
237
+ /** Caller-rendered with scope "run scratch" (`{alsoLog: true}`). */
238
+ warnings: string[];
239
+ } & (
240
+ | { arm: "claimed"; decision: Extract<ClaimDecision, { action: "claim" }> }
241
+ | { arm: "kept"; decision: Extract<ClaimDecision, { action: "keep" }> }
242
+ | { arm: "forked"; decision: Extract<ClaimDecision, { action: "fork" }> }
243
+ | { arm: "adopted"; decision: Extract<ClaimDecision, { action: "adopt" }> }
244
+ | { arm: "minted"; decision: Extract<ClaimDecision, { action: "none" }> }
245
+ | { arm: "unclaimed"; decision: Extract<ClaimDecision, { action: "claim" | "none" }> }
246
+ );
247
+
248
+ /** Reflect a captured restriction without participating in identity or handoff authority. */
249
+ export function reflectSessionReadOnlyFloor(
250
+ store: SessionStateStore,
251
+ outcome: EstablishIdentityOutcome,
252
+ ): { outcome: EstablishIdentityOutcome; unexpectedFailure: boolean } {
253
+ if (outcome.arm === "unclaimed" || outcome.resolved.mode === "read-only") {
254
+ return { outcome, unexpectedFailure: false };
255
+ }
256
+ let appended: ClassifiedAppend;
257
+ try {
258
+ appended = store.appendVerified({
259
+ data: { mode: "read-only" },
260
+ field: "mode",
261
+ expected: "read-only",
262
+ scope: "child restriction",
263
+ failure:
264
+ "could not persist child read-only restriction; in-memory restriction remains active",
265
+ });
266
+ } catch {
267
+ // The Pi edge reports this escaped-contract failure once and continues startup with the floor.
268
+ return { outcome, unexpectedFailure: true };
269
+ }
270
+ return {
271
+ outcome:
272
+ appended.status === "applied"
273
+ ? { ...outcome, resolved: { ...outcome.resolved, mode: "read-only" } }
274
+ : outcome,
275
+ unexpectedFailure: false,
276
+ };
277
+ }
278
+
279
+ /** The refinement stage id (registry vocabulary; the admission check keys on it). */
280
+ const REFINE_STAGE_ID = "objective-refine";
281
+
282
+ /** The top-level handoff keys the plan-graph doors use as planning-link / plan-ref inputs. */
283
+ const PLANNING_LINK_KEYS = [
284
+ "objective_id",
285
+ "node_id",
286
+ "adopt_from",
287
+ "supersedes",
288
+ "gist_scope",
289
+ "consumed_learn",
290
+ ] as const;
291
+
292
+ /**
293
+ * Which planning-link / plan-ref input (if any) an `objective-refine` handoff carries at the
294
+ * top level — `null` for every non-refinement handoff and for a clean refinement one. A key is
295
+ * "carried" when present and not null/undefined (an empty `consumed_learn` list is clean).
296
+ */
297
+ export function refinementHandoffContamination(handoff: Handoff): string | null {
298
+ if (handoff.stage !== REFINE_STAGE_ID) return null;
299
+ const carried = PLANNING_LINK_KEYS.filter((key) => {
300
+ const value = handoff[key];
301
+ if (value === undefined || value === null) return false;
302
+ if (Array.isArray(value)) return value.length > 0;
303
+ return true;
304
+ });
305
+ return carried.length === 0 ? null : carried.join(", ");
306
+ }
307
+
308
+ /**
309
+ * Establish the session's run identity — the four `session_start` arms as one named operation:
310
+ *
311
+ * - **claim** (cold): read the handoff via the port (missing/mismatched ⇒ `unclaimed` with the
312
+ * loud problem; never falls through to mint) → build the ONE combined entry (`run_id`,
313
+ * `pi_session_id`, `mode`, the `perk_version` stamp, `stage`, and the `objective_node_claim`
314
+ * carrier when the handoff's `objective_id`/`node_id` are both non-blank strings) → ONE
315
+ * strict append verified on `run_id` → only on verified success, consume the handoff
316
+ * (establish-before-consume: a failed read-back ⇒ `unclaimed`, NOT consumed).
317
+ * - **fork** / **adopt**: isolate the derived child's scratch (a throw is a warning; identity
318
+ * still settles) → the derived-identity append — plain appends (honest tier, no read-back).
319
+ * Fork inherits the parent's `mode` and NO `stage` (LWW carries the parent's); adopt takes
320
+ * `mode` from the handoff and never impersonates stage or claim (and never re-consumes).
321
+ * - **mint** (`none`): mint a run_id → strict append verified on `run_id`; a failed read-back
322
+ * leaves the session unidentified (`unclaimed` — re-mints next `session_start`).
323
+ * - **keep** (reload): NO append — reload-generation reconstruction IS the LWW rebuild, and the
324
+ * deliberate no-version-backfill non-write is preserved.
325
+ */
326
+ export function establishSessionIdentity(
327
+ store: SessionStateStore,
328
+ ports: SessionIdentityPorts,
329
+ input: { currentSessionId: string | null; envRunId: string | null },
330
+ ): EstablishIdentityOutcome {
331
+ const problems: string[] = [];
332
+ const warnings: string[] = [];
333
+ const currentSessionId = input.currentSessionId;
334
+ const decision = decideClaim({
335
+ state: store.rebuild(),
336
+ currentSessionId,
337
+ envRunId: input.envRunId,
338
+ reads: ports,
339
+ });
340
+ const stamp = ports.versionStamp;
341
+
342
+ if (decision.action === "claim") {
343
+ // Cold claim — establish before consume (strict).
344
+ const handoff = ports.readHandoff(decision.runId);
345
+ if (handoff === null || handoff.run_id !== decision.runId) {
346
+ problems.push(`handoff missing or mismatched for run ${decision.runId}`);
347
+ return { arm: "unclaimed", resolved: {}, decision, problems, warnings };
348
+ }
349
+ // Refinement admission (contracts.md §8.67): the isolated `objective-refine` stage must
350
+ // never arrive carrying a planning link or plan-ref input — those top-level keys are what
351
+ // the claim arm below (and the plan-save recovery paths) read as a planning claim /
352
+ // adoption. A contaminated refinement handoff is REFUSED before any claim is recorded and
353
+ // is NOT consumed (the door that wrote it is the defect; nothing here rebinds or clears).
354
+ // Ordinary objective-plan handoffs (which legitimately carry objective_id/node_id) are
355
+ // untouched.
356
+ const contamination = refinementHandoffContamination(handoff);
357
+ if (contamination !== null) {
358
+ problems.push(
359
+ `refusing the objective-refine handoff for run ${decision.runId}: it carries ${contamination} (a planning link/plan-ref input a refinement session never accepts)`,
360
+ );
361
+ return { arm: "unclaimed", resolved: {}, decision, problems, warnings };
362
+ }
363
+ // The objective-plan cold door's handoff_extra carries the node link
364
+ // (objective_id/node_id): persist it as the objective_node_claim so the implement-here
365
+ // exits are structurally suppressed in COLD objective-plan sessions too (the warm
366
+ // `objective_node` tool records the claim; a cold factory session never calls it — the
367
+ // door marked the node before launch). Blank/absent ids persist nothing; the claim
368
+ // clears on a successful node-linked save exactly as the warm-recorded one does.
369
+ const handoffObjective = handoff.objective_id;
370
+ const handoffNode = handoff.node_id;
371
+ const nodeClaim =
372
+ typeof handoffObjective === "string" &&
373
+ handoffObjective.trim() !== "" &&
374
+ typeof handoffNode === "string" &&
375
+ handoffNode.trim() !== ""
376
+ ? { objective: handoffObjective, node: handoffNode }
377
+ : undefined;
378
+ const data: WorkflowState = {
379
+ run_id: decision.runId,
380
+ pi_session_id: currentSessionId ?? undefined,
381
+ mode: handoff.mode,
382
+ perk_version: stamp,
383
+ // Record the launched stage so the interior can tell e.g. objective-author from plan
384
+ // (both are read-only) and inject the right authoring context.
385
+ stage: handoff.stage,
386
+ ...(nodeClaim !== undefined ? { objective_node_claim: nodeClaim } : {}),
387
+ };
388
+ const appended = store.appendVerified({
389
+ data,
390
+ field: "run_id",
391
+ expected: decision.runId,
392
+ scope: "workflow-state linkage error",
393
+ failure: `read-back failed for run ${decision.runId}`,
394
+ });
395
+ if (appended.status !== "applied") {
396
+ // do NOT consume — the strict-append seam already reported the failure loudly.
397
+ return { arm: "unclaimed", resolved: {}, decision, problems, warnings };
398
+ }
399
+ ports.markHandoffConsumed(decision.runId, { piSessionId: currentSessionId ?? undefined });
400
+ return { arm: "claimed", resolved: data, decision, problems, warnings };
401
+ }
402
+
403
+ if (decision.action === "fork" || decision.action === "adopt") {
404
+ // Inherited/adopted run identity → isolate the child's scratch. A static redirect or
405
+ // filesystem failure is loud but does not prevent the derived workflow identity from
406
+ // settling; later eligible turns retry through the agent-scratch resolver. The adopt arm
407
+ // mirrors fork minus everything that belongs to the launched session (contracts §8.2):
408
+ // never re-consume the handoff, no `stage` (no stage impersonation), no claim.
409
+ try {
410
+ ports.ensureRunScratch(decision.childRunId);
411
+ } catch (error) {
412
+ const kind = decision.action === "fork" ? "fork" : "adopted";
413
+ warnings.push(
414
+ `could not create ${kind} run root for ${decision.childRunId}: ${String(error)}`,
415
+ );
416
+ }
417
+ const data: WorkflowState = {
418
+ run_id: decision.childRunId,
419
+ pi_session_id: currentSessionId ?? undefined,
420
+ predecessor: decision.parentRunId,
421
+ mode: decision.action === "fork" ? decision.state.mode : decision.mode,
422
+ perk_version: stamp,
423
+ };
424
+ store.append(data);
425
+ if (decision.action === "fork") {
426
+ return { arm: "forked", resolved: data, decision, problems, warnings };
427
+ }
428
+ return { arm: "adopted", resolved: data, decision, problems, warnings };
429
+ }
430
+
431
+ if (decision.action === "none") {
432
+ // A warm session with no identity mints its own run_id so per-run state (the session data
433
+ // dir) can key off it. No disk artifacts — dirs are the accessor's job; provenance is
434
+ // recorded separately. A failed cold claim above never falls here (claim stays a loud
435
+ // unclaimed error).
436
+ const runId = ports.mintRunId();
437
+ const data: WorkflowState = {
438
+ run_id: runId,
439
+ pi_session_id: currentSessionId ?? undefined,
440
+ perk_version: stamp,
441
+ };
442
+ const appended = store.appendVerified({
443
+ data,
444
+ field: "run_id",
445
+ expected: runId,
446
+ scope: "workflow-state linkage error",
447
+ failure: `read-back failed for minted run ${runId}`,
448
+ });
449
+ if (appended.status === "applied") {
450
+ return {
451
+ arm: "minted",
452
+ resolved: { ...decision.state, ...data },
453
+ decision,
454
+ problems,
455
+ warnings,
456
+ };
457
+ }
458
+ // Loud-but-non-fatal (the seam reported): the session stays unidentified and re-mints on
459
+ // the next session_start.
460
+ return { arm: "unclaimed", resolved: decision.state, decision, problems, warnings };
461
+ }
462
+
463
+ // keep (reload): NO append — reload-generation reconstruction IS the LWW rebuild; the
464
+ // deliberate no-version-backfill non-write is preserved (§8.3: an LWW backfill would
465
+ // mis-stamp an old session with today's version).
466
+ return { arm: "kept", resolved: decision.state, decision, problems, warnings };
467
+ }
468
+
469
+ // ------------------------------------------------------------- the two-phase startup facts
470
+
471
+ /**
472
+ * PHASE 1 (before the gate) — the tool-scope slice `gating.syncFromState` consumes, derived
473
+ * PURELY from the established identity: no store, handoff, registry, or checkout read can sit
474
+ * between identity establishment and gate synchronization (a read failure must never leave the
475
+ * gate unsynced). Mode is exactly the resolved mode (after optional floor reflection). The scope
476
+ * stage is the workflow-state `stage` key (§8.40): claim → the handoff-recorded stage just
477
+ * appended; keep/none → the branch-LWW stage; fork INHERITS the parent's stage (a forked
478
+ * implement session is an implement session); adopt NEVER impersonates (a subagent child's
479
+ * fresh branch carries no stage, so `session_tree` agrees). A failed claim leaves `resolved`
480
+ * empty → no stage → unscoped (stage scoping is fail-open). No stage validation or
481
+ * normalization — an unknown stage id passes through as-is.
482
+ */
483
+ export function sessionStartToolScope(
484
+ identity: EstablishIdentityOutcome,
485
+ ): Pick<WorkflowState, "mode" | "stage"> {
486
+ const { resolved, decision } = identity;
487
+ const stage =
488
+ decision.action === "adopt"
489
+ ? undefined
490
+ : (resolved.stage ?? (decision.action === "fork" ? decision.state.stage : undefined));
491
+ return { mode: resolved.mode, stage };
492
+ }
493
+
494
+ /**
495
+ * The exterior reads the post-gate facts may touch — the handoff (the launched-stage authority
496
+ * for claim/keep) and the checkout `cache.plan-ref` (read ONLY on the consuming arm). Production
497
+ * binds both cwd-bound in `index.ts`; the suites bind recording fakes.
498
+ */
499
+ export interface SessionStartFactReads extends Pick<SessionIdentityReads, "readHandoff"> {
500
+ /** The worktree's `cache.plan-ref` selector, or null (missing/unreadable). */
501
+ readPlanRef(): PlanRef | null;
502
+ }
503
+
504
+ /**
505
+ * The receiver-shaped feedback inputs both startup and navigation derive. Deliberately a local,
506
+ * inferred structural shape (no shared vocabulary with `hunkFeedback/receiver.ts`): the
507
+ * `index.ts` call site adds Pi's run `mode` and the receiver's own `ReceiverSyncArgs` accepts
508
+ * the result structurally. Eligibility stays receiver-owned (its fresh checkout read included).
509
+ */
510
+ interface SessionFeedbackFacts {
511
+ stage: string | null;
512
+ adopted: boolean;
513
+ runId: string | null;
514
+ piSessionId: string | null;
515
+ activePlanRef: PlanRef | null;
516
+ }
517
+
518
+ /**
519
+ * PHASE 2 (after the gate and the claimed-only refinement import) — the lazy linkage
520
+ * reconciliation plus the derived capture/feedback inputs. `resolved` is the same
521
+ * `WorkflowState`-shaped diagnostic startup always produced (the T3 sentinel's material) —
522
+ * lifecycle-local, not a feature-facing snapshot.
523
+ */
524
+ export interface SessionStartFacts {
525
+ resolved: WorkflowState;
526
+ /** Present only for an identified implement session (launched or fork-inherited stage). */
527
+ implementationCapture: { runId: string; parentSessionId: string | null } | null;
528
+ feedback: SessionFeedbackFacts;
529
+ }
530
+
531
+ /**
532
+ * The post-gate startup facts. Operation order (each step's port is the only one it touches):
533
+ *
534
+ * 1. Rebuild the live linked `active_plan_ref` ONCE (the store), before any handoff read.
535
+ * 2. Resolve the launched stage through the handoff reader — claim/keep only (`resolveRunStage`);
536
+ * fork/adopt/none never read a stage handoff here.
537
+ * 3. Registry admission: only a non-null launched stage can consume the checkout ref
538
+ * (`stageConsumesPlanRef`); a null registry stays PERMISSIVE when a stage is present (to
539
+ * preserve implement linkage); an unknown stage in an available registry does not consume.
540
+ * 4. On the consuming arm ONLY, read the checkout `cache.plan-ref`: same `(provider, pr_id)`
541
+ * keeps the existing linked object without appending; a different ref performs ONE strict
542
+ * verified append (`active_plan_ref`, `planRefsEqual`, the "workflow-state linkage error"
543
+ * scope) and folds the new ref in ONLY on `applied` — a rejected/unverified append leaves
544
+ * `resolved` exactly as it arrived (a kept session keeps its LWW ref; a fresh claim keeps
545
+ * none — never flattened, never rebuilt again to manufacture a fallback); no cached ref
546
+ * preserves a non-null linked ref. A non-consuming (or absent) launched stage preserves a
547
+ * non-null linked ref WITHOUT reading the checkout — the root selector must not leak in.
548
+ * 5. The implementation stage is the launched stage, with only a fork falling back to its
549
+ * parent's LWW stage; `implementationCapture` exists only for a truthy run id on
550
+ * `stage === "implement"` (parent provenance comes only from the fork decision).
551
+ * 6. `feedback` carries the implementation stage, the decision's adoption, the resolved
552
+ * run/ref, and startup's CURRENT Pi session handle (never an inherited branch handle).
553
+ *
554
+ * A throwing store/read propagates to the caller (Pi's hook error boundary) — unreadability is
555
+ * never turned into confirmed absence, and no later effect runs from guessed facts.
556
+ */
557
+ export function resolveSessionStartFacts(
558
+ store: SessionStateStore,
559
+ reads: SessionStartFactReads,
560
+ input: {
561
+ identity: EstablishIdentityOutcome;
562
+ /** The already-loaded registry (null when it failed to load) — never a loader. */
563
+ registry: Registry | null;
564
+ currentSessionId: string | null;
565
+ },
566
+ ): SessionStartFacts {
567
+ const { identity, registry, currentSessionId } = input;
568
+ const decision = identity.decision;
569
+ let resolved: WorkflowState = identity.resolved;
570
+
571
+ const linked = store.rebuild().active_plan_ref ?? null;
572
+ const launchedStage = resolveRunStage(decision, reads);
573
+ const consumesPlanRef =
574
+ launchedStage !== null && (registry === null || stageConsumesPlanRef(registry, launchedStage));
575
+ if (consumesPlanRef) {
576
+ const cachedRef = reads.readPlanRef();
577
+ if (cachedRef !== null) {
578
+ if (planRefsEqual(linked, cachedRef)) {
579
+ resolved = { ...resolved, active_plan_ref: linked };
580
+ } else {
581
+ const appended = store.appendVerified({
582
+ data: { active_plan_ref: cachedRef },
583
+ field: "active_plan_ref",
584
+ expected: cachedRef,
585
+ scope: "workflow-state linkage error",
586
+ failure: `plan-ref read-back failed for ${cachedRef.provider}:${cachedRef.pr_id}`,
587
+ equals: planRefsEqual,
588
+ });
589
+ if (appended.status === "applied") resolved = { ...resolved, active_plan_ref: cachedRef };
590
+ }
591
+ } else if (linked !== null) {
592
+ resolved = { ...resolved, active_plan_ref: linked };
593
+ }
594
+ } else if (linked !== null) {
595
+ resolved = { ...resolved, active_plan_ref: linked };
596
+ }
597
+
598
+ const implementationStage =
599
+ launchedStage ?? (decision.action === "fork" ? (decision.state.stage ?? null) : null);
600
+ const implementationCapture =
601
+ resolved.run_id && implementationStage === "implement"
602
+ ? {
603
+ runId: resolved.run_id,
604
+ parentSessionId:
605
+ decision.action === "fork" ? (decision.state.pi_session_id ?? null) : null,
606
+ }
607
+ : null;
608
+
609
+ return {
610
+ resolved,
611
+ implementationCapture,
612
+ feedback: {
613
+ stage: implementationStage,
614
+ adopted: decision.action === "adopt",
615
+ runId: resolved.run_id ?? null,
616
+ piSessionId: currentSessionId,
617
+ activePlanRef: resolved.active_plan_ref ?? null,
618
+ },
619
+ };
620
+ }
621
+
622
+ /**
623
+ * The navigation twin (`session_tree`), PURE over the ONE already-rebuilt selected-branch state:
624
+ * tool scope is the branch's per-field-LWW mode/stage (the §8.40 key); feedback uses the
625
+ * branch's stage/run/ref AND ITS OWN recorded session id (not startup's current-handle
626
+ * override) with `adopted: false` — an env-adopted child's fresh branch carries no stage, so the
627
+ * stage gate alone keeps it receiver-ineligible. No handoff/checkout read, claim, linkage, or
628
+ * implementation capture happens on navigation (a deliberate asymmetry with startup).
629
+ */
630
+ export function sessionTreeFacts(state: WorkflowState): {
631
+ toolScope: Pick<WorkflowState, "mode" | "stage">;
632
+ feedback: SessionFeedbackFacts;
633
+ } {
634
+ return {
635
+ toolScope: { mode: state.mode, stage: state.stage },
636
+ feedback: {
637
+ stage: state.stage ?? null,
638
+ adopted: false,
639
+ runId: state.run_id ?? null,
640
+ piSessionId: state.pi_session_id ?? null,
641
+ activePlanRef: state.active_plan_ref ?? null,
642
+ },
643
+ };
644
+ }