@mgiles/perk 3.1.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/README.md +5 -0
  2. package/extension/authoring/gist/draft.ts +198 -0
  3. package/extension/authoring/gist/prose.ts +46 -0
  4. package/extension/authoring/gist/review.ts +133 -0
  5. package/extension/authoring/gist/save.ts +118 -0
  6. package/extension/authoring/objective/draft.ts +345 -0
  7. package/extension/{factories/objectiveDreamReport.ts → authoring/objective/dreamReportGate.ts} +74 -131
  8. package/extension/authoring/objective/planning.ts +124 -0
  9. package/extension/authoring/objective/prose.ts +103 -0
  10. package/extension/authoring/objective/review.ts +128 -0
  11. package/extension/authoring/objective/save.ts +224 -0
  12. package/extension/authoring/plan/draft.ts +84 -0
  13. package/extension/authoring/plan/prose.ts +41 -0
  14. package/extension/authoring/plan/review.ts +269 -0
  15. package/extension/authoring/plan/save.ts +256 -0
  16. package/extension/authoring/plan/source.ts +82 -0
  17. package/extension/authoring/refinement/context.ts +468 -0
  18. package/extension/authoring/refinement/draft.ts +261 -0
  19. package/extension/authoring/refinement/prose.ts +79 -0
  20. package/extension/authoring/refinement/review.ts +111 -0
  21. package/extension/authoring/refinement/save.ts +119 -0
  22. package/extension/authoring/review/approvalGate.ts +34 -0
  23. package/extension/authoring/review/draftContext.ts +68 -0
  24. package/extension/codeReview/automated.ts +352 -0
  25. package/extension/codeReview/submission.ts +229 -0
  26. package/extension/delivery/address.ts +295 -0
  27. package/extension/delivery/ci.ts +355 -0
  28. package/extension/delivery/commitCompact.ts +93 -0
  29. package/extension/delivery/conflictResolution.ts +247 -0
  30. package/extension/delivery/ready.ts +193 -0
  31. package/extension/delivery/stackConflict.ts +361 -0
  32. package/extension/delivery/stackObjective.ts +16 -0
  33. package/extension/delivery/stackReconcile.ts +165 -0
  34. package/extension/delivery/submit.ts +171 -0
  35. package/extension/index.ts +369 -359
  36. package/extension/learning/analystWave.ts +324 -0
  37. package/extension/learning/audit.ts +667 -0
  38. package/extension/learning/capture.ts +92 -0
  39. package/extension/learning/containment.ts +104 -0
  40. package/extension/{waves/dreamWave.ts → learning/dream.ts} +112 -94
  41. package/extension/learning/dreamAnalysis.ts +435 -0
  42. package/extension/{waves/dreamReducerWave.ts → learning/dreamReducer.ts} +46 -41
  43. package/extension/{waves → learning}/dreamReport.ts +35 -31
  44. package/extension/learning/harvest.ts +491 -0
  45. package/extension/learning/prose.ts +66 -0
  46. package/extension/learning/routing.ts +79 -0
  47. package/extension/pi/v1/bashScanTimeout.ts +64 -0
  48. package/extension/{doors/prReview.ts → pi/v1/codeReview/automated.ts} +215 -310
  49. package/extension/{doors/prReviewBrowser.ts → pi/v1/codeReview/browser.ts} +125 -57
  50. package/extension/{doors/hunkHandoff.ts → pi/v1/codeReview/checkout.ts} +12 -8
  51. package/extension/{doors/reviewWaveTools.ts → pi/v1/codeReview/reviewWave.ts} +169 -116
  52. package/extension/pi/v1/codeReview/stack.ts +606 -0
  53. package/extension/pi/v1/codeReview/submit.ts +354 -0
  54. package/extension/{doors/prReviewTerminal.ts → pi/v1/codeReview/terminal.ts} +32 -27
  55. package/extension/pi/v1/contextEvidence.ts +80 -0
  56. package/extension/pi/v1/contextInjection.ts +207 -0
  57. package/extension/{doors → pi/v1/delivery}/address.ts +163 -265
  58. package/extension/pi/v1/delivery/ci.ts +570 -0
  59. package/extension/pi/v1/delivery/commitCompact.ts +201 -0
  60. package/extension/pi/v1/delivery/conflictResolverEngine.ts +425 -0
  61. package/extension/{doors → pi/v1/delivery}/land.ts +128 -60
  62. package/extension/pi/v1/delivery/ready.ts +322 -0
  63. package/extension/pi/v1/delivery/stackConflictResolver.ts +172 -0
  64. package/extension/pi/v1/delivery/stackDrive.ts +120 -0
  65. package/extension/pi/v1/delivery/stackLand.ts +223 -0
  66. package/extension/pi/v1/delivery/stackRecover.ts +265 -0
  67. package/extension/pi/v1/delivery/stackStatus.ts +237 -0
  68. package/extension/pi/v1/delivery/stackSync.ts +658 -0
  69. package/extension/pi/v1/delivery/submit.ts +389 -0
  70. package/extension/pi/v1/delivery/submitConflict.ts +186 -0
  71. package/extension/pi/v1/draftReview.ts +431 -0
  72. package/extension/{doors → pi/v1}/draftReviewWaveTools.ts +141 -151
  73. package/extension/pi/v1/gist.ts +794 -0
  74. package/extension/pi/v1/learning/audit.ts +186 -0
  75. package/extension/pi/v1/learning/dream.ts +207 -0
  76. package/extension/{doors/learnFactory.ts → pi/v1/learning/factory.ts} +18 -65
  77. package/extension/{doors/harvestWaveTools.ts → pi/v1/learning/harvest.ts} +46 -100
  78. package/extension/pi/v1/learning/learn.ts +585 -0
  79. package/extension/{doors → pi/v1}/lifecycleGates.ts +14 -59
  80. package/extension/{factories → pi/v1}/objective.ts +53 -33
  81. package/extension/pi/v1/objectiveAuthoring.ts +672 -0
  82. package/extension/pi/v1/objectiveDreamGate.ts +160 -0
  83. package/extension/pi/v1/objectivePlanning.ts +776 -0
  84. package/extension/pi/v1/objectiveRefinement.ts +1320 -0
  85. package/extension/pi/v1/objectiveReview.ts +451 -0
  86. package/extension/{doors → pi/v1}/objectiveReviewBrowser.ts +259 -172
  87. package/extension/pi/v1/plan.ts +812 -0
  88. package/extension/pi/v1/planReview.ts +820 -0
  89. package/extension/{doors → pi/v1}/planReviewBrowser.ts +228 -152
  90. package/extension/{doors/annotationPush.ts → pi/v1/providers/annotations.ts} +158 -89
  91. package/extension/pi/v1/providers/plannotator.ts +487 -0
  92. package/extension/{doors → pi/v1/providers}/plannotatorHandoff.ts +152 -34
  93. package/extension/pi/v1/providers/selection.ts +43 -0
  94. package/extension/{adapters/planAdapterTombell.ts → pi/v1/providers/tombell.ts} +43 -72
  95. package/extension/pi/v1/review.ts +538 -0
  96. package/extension/pi/v1/reviewOutcome.ts +9 -0
  97. package/extension/pi/v1/scoutWave.ts +318 -0
  98. package/extension/{doors → pi/v1}/selfcheck.ts +4 -4
  99. package/extension/session/branchWorkflowSession.ts +60 -0
  100. package/extension/session/lifecycle.ts +644 -0
  101. package/extension/session/lifecycleGates.ts +64 -0
  102. package/extension/session/saveDestination.ts +87 -0
  103. package/extension/session/workflowSession.ts +971 -0
  104. package/extension/substrate/agentScratch.ts +27 -54
  105. package/extension/substrate/bashScanTimeout.ts +181 -0
  106. package/extension/substrate/bindingDelivery.ts +38 -30
  107. package/extension/substrate/bindings.ts +4 -5
  108. package/extension/substrate/cache.ts +64 -12
  109. package/extension/substrate/childRestrictions.ts +39 -0
  110. package/extension/substrate/coldDoor.ts +17 -1
  111. package/extension/substrate/config.ts +157 -19
  112. package/extension/substrate/git.ts +88 -6
  113. package/extension/substrate/modelVisible.ts +53 -0
  114. package/extension/substrate/paths.ts +2 -7
  115. package/extension/substrate/prompts.ts +22 -0
  116. package/extension/substrate/registry.ts +2 -0
  117. package/extension/substrate/resolverLease.ts +364 -0
  118. package/extension/substrate/sessionData.ts +85 -152
  119. package/extension/substrate/toolGating.ts +279 -84
  120. package/extension/substrate/unifiedDiff.ts +1 -1
  121. package/extension/substrate/workflowState.ts +191 -166
  122. package/extension/substrate/worktreeResolverLock.ts +261 -0
  123. package/extension/surfaces/surfaces.ts +79 -27
  124. package/extension/waves/adversarialReviewWave.ts +103 -48
  125. package/extension/waves/blockedReports.ts +59 -0
  126. package/extension/waves/draftReviewWave.ts +42 -42
  127. package/extension/waves/laneIdentity.ts +77 -0
  128. package/extension/waves/objectiveExplorerWave.ts +24 -24
  129. package/extension/waves/prReviewWave.ts +89 -77
  130. package/extension/waves/reportWave.ts +438 -578
  131. package/extension/waves/reviewClassifierWave.ts +22 -22
  132. package/extension/waves/rpcAdapter.ts +100 -15
  133. package/extension/waves/scoutWave.ts +192 -0
  134. package/extension/waves/transport.ts +480 -0
  135. package/extension/worker/sdkAdapter.ts +494 -0
  136. package/extension/worker/stageExecution.ts +679 -0
  137. package/extension/workerMain.ts +18 -19
  138. package/package.json +6 -4
  139. package/prompts/_fixtures/live.yaml +98 -10
  140. package/prompts/contexts/adapters/plannotator-gist.md +6 -0
  141. package/prompts/contexts/adapters/plannotator-objective.md +6 -0
  142. package/prompts/contexts/adapters/plannotator-plan.md +8 -1
  143. package/prompts/contexts/adapters/plannotator-refinement.md +22 -0
  144. package/prompts/contexts/adapters/tombell-plan.md +4 -0
  145. package/prompts/contexts/objective-refinement.md +17 -0
  146. package/prompts/contexts/plan-authoring.md +6 -5
  147. package/prompts/contexts/read-only.md +1 -1
  148. package/prompts/stages/conflict-resolution-continuation.md +9 -0
  149. package/prompts/stages/conflict-resolution.md +4 -4
  150. package/prompts/stages/objective-author/adopt.md +1 -1
  151. package/prompts/stages/objective-author/file.md +1 -1
  152. package/prompts/stages/objective-author/seed.md +1 -1
  153. package/prompts/stages/objective-plan/guidance.md +2 -2
  154. package/prompts/stages/objective-plan/seed.md +9 -1
  155. package/prompts/stages/objective-reconcile-ready.md +7 -0
  156. package/prompts/stages/objective-reconcile.md +1 -1
  157. package/prompts/stages/objective-refine/seed.md +18 -0
  158. package/prompts/stages/objective-review-browser.md +4 -4
  159. package/prompts/stages/objective-sync.md +1 -1
  160. package/prompts/stages/plan-review-browser.md +4 -4
  161. package/prompts/stages/pr-review-browser/active.md +3 -4
  162. package/prompts/stages/pr-review-browser/foreign.md +3 -4
  163. package/prompts/stages/pr-review-terminal/active.md +3 -3
  164. package/prompts/stages/pr-review-terminal/foreign.md +3 -3
  165. package/prompts/stages/pr-review.md +3 -3
  166. package/prompts/stages/stack-review/cold.md +1 -0
  167. package/prompts/stages/stack-review-browser/stack.md +22 -0
  168. package/shared/README.md +8 -3
  169. package/shared/bindings.yaml +6 -3
  170. package/shared/contracts.md +4499 -2147
  171. package/shared/fixtures/issues-table.json +130 -0
  172. package/shared/registry.yaml +29 -1
  173. package/shared/schemas/outputs/objective-node-engagement.schema.json +318 -0
  174. package/shared/schemas/outputs/objective-stack-status.schema.json +178 -2
  175. package/shared/schemas/outputs/pr-ready.schema.json +110 -2
  176. package/shared/schemas/outputs/pr-review-context.schema.json +54 -9
  177. package/shared/schemas/outputs/pr-review-stack-context.schema.json +196 -0
  178. package/extension/adapters/planAdapterPlannotator.ts +0 -362
  179. package/extension/doors/auditWaveTools.ts +0 -352
  180. package/extension/doors/ciExecutor.ts +0 -756
  181. package/extension/doors/commitCompact.ts +0 -251
  182. package/extension/doors/dreamWaveTools.ts +0 -475
  183. package/extension/doors/learn.ts +0 -655
  184. package/extension/doors/objectiveStack.ts +0 -1143
  185. package/extension/doors/prReviewDynamic.ts +0 -276
  186. package/extension/doors/ready.ts +0 -87
  187. package/extension/doors/submit.ts +0 -347
  188. package/extension/doors/submitPrReview.ts +0 -408
  189. package/extension/factories/gistAuthor.ts +0 -94
  190. package/extension/factories/gistDraft.ts +0 -265
  191. package/extension/factories/gistSave.ts +0 -251
  192. package/extension/factories/implementHere.ts +0 -116
  193. package/extension/factories/objectiveAuthor.ts +0 -98
  194. package/extension/factories/objectiveDraft.ts +0 -466
  195. package/extension/factories/objectivePlan.ts +0 -975
  196. package/extension/factories/objectiveSave.ts +0 -363
  197. package/extension/factories/planDraft.ts +0 -140
  198. package/extension/factories/planMode.ts +0 -205
  199. package/extension/factories/planReview.ts +0 -1237
  200. package/extension/factories/planSave.ts +0 -604
  201. package/extension/factories/planTitle.ts +0 -141
  202. package/extension/substrate/structuredOutput.ts +0 -202
  203. package/extension/waves/auditWave.ts +0 -312
  204. package/extension/waves/harvestWave.ts +0 -399
  205. package/extension/waves/learnWave.ts +0 -155
  206. package/extension/waves/memoryAdapter.ts +0 -139
  207. package/extension/waves/prReviewDynamicWave.ts +0 -777
  208. package/extension/worker/readOnlySession.ts +0 -294
  209. package/extension/worker/worker.ts +0 -899
  210. package/prompts/stages/pr-review-dynamic.md +0 -7
  211. package/shared/contracts-history.md +0 -605
@@ -1,14 +1,15 @@
1
- // The `perk:workflow-state` session tier (contracts.md §8.3) — rebuild, claim, fork-derive,
2
- // and the strict-append seam (appendWorkflowState).
1
+ // The `perk:workflow-state` session tier (contracts.md §8.3) — rebuild and the strict-append
2
+ // seam (appendWorkflowState). The identity-lifecycle decisions (claim/fork-derive/stage
3
+ // resolution) live in `session/lifecycle.ts` — the named session operation.
3
4
  //
4
5
  // Mostly pure, fs-light logic kept separate from the `pi`/`ctx` effects (in index.ts); the
5
6
  // strict-append seam touches effects only through structural slices (`EntrySink`, `BranchSource`,
6
7
  // `ReportTarget`), so the whole module stays unit-testable under `node --test` with fakes. The
7
8
  // reconstruction discipline (scan getBranch on session_start AND session_tree, per-field LWW)
8
- // and the verified-linkage claim live here.
9
+ // lives here.
9
10
 
10
11
  import { type ReportTarget, report } from "../surfaces/report.ts";
11
- import { listRunIds, type PlanRef, readHandoff } from "./cache.ts";
12
+ import { type PlanRef, readPlanRef } from "./cache.ts";
12
13
 
13
14
  export const WORKFLOW_STATE_TYPE = "perk:workflow-state";
14
15
 
@@ -16,6 +17,9 @@ export const WORKFLOW_STATE_TYPE = "perk:workflow-state";
16
17
  * A session-artifact provenance pointer (contracts §8.3): the session tier's proof
17
18
  * that a `scratch/runs/<run_id>/data/` file is current for THIS run. Reads validate the
18
19
  * on-disk file against the rebuilt pointer (run_id match + digest match) and refuse otherwise.
20
+ * This is the PERSISTENCE format only — rebuilt branch data is unvalidated, so consumption
21
+ * flows through the session engine's exported decode (`session/workflowSession.ts`), which
22
+ * narrows exactly the fields it validates.
19
23
  */
20
24
  export interface SessionArtifactPointer {
21
25
  run_id: string;
@@ -57,8 +61,21 @@ export interface WorkflowState {
57
61
  * `rebuildWorkflowState`, no rebuild change). The submitted PR review stays canonical.
58
62
  */
59
63
  last_review?: unknown;
60
- /** Session-artifact provenance pointers, keyed by artifact name (§8.3). */
61
- session_artifacts?: Record<string, SessionArtifactPointer> | null;
64
+ /**
65
+ * The accumulating per-PR posting ledger of a stacked review (§8.3/§8.4): one
66
+ * `{pr, event, at}` row per REAL `submit_pr_review` success, ordered by posting time
67
+ * (read-rebuild-append — the whole list is re-appended each time). The resume authority for
68
+ * a partially-posted stack sequence: confirmed successes are skipped, never replayed.
69
+ * Best-effort tier (per-field LWW in `rebuildWorkflowState`, no rebuild change).
70
+ */
71
+ review_posts?: unknown;
72
+ /**
73
+ * Session-artifact provenance pointers, keyed by artifact name (§8.3). The persisted wire
74
+ * shape is `SessionArtifactPointer`, but rebuilt branch data is UNVALIDATED (`rebuildWorkflowState`
75
+ * trusts entry data) — so the values are `unknown` and consumption goes through the session
76
+ * engine's exported persisted-pointer decode (`session/workflowSession.ts`), never a cast.
77
+ */
78
+ session_artifacts?: Record<string, unknown> | null;
62
79
  /**
63
80
  * The objective node this session has claimed `planning` (§8.3) — the warm
64
81
  * node-link carrier an approval-triggered save recovers from. Written by the `objective_node`
@@ -76,9 +93,11 @@ export interface WorkflowState {
76
93
  */
77
94
  dream_bundle_digest?: string;
78
95
  /**
79
- * The bounded conflict-resolution re-drive counter (§8.3). Incremented each time
80
- * `/submit` drives the `perk.conflict-resolver` subagent on a definitively-unmergeable PR;
81
- * reset to 0 on a clean submit. Best-effort tier (cheaply reconstructable). Per-field LWW in
96
+ * The bounded conflict-resolution re-drive counter (§8.3). Incremented on each
97
+ * `perk.conflict-resolver` dispatch from EITHER warm surface — `/submit`'s PR-rebase drive on
98
+ * a definitively-unmergeable PR, or `/objective-sync`'s retained-continuation drive; reset to
99
+ * 0 on any clean completion (a clean submit; a clean non-declined mutating stack
100
+ * sync/continue/abort/adopt). Best-effort tier (cheaply reconstructable). Per-field LWW in
82
101
  * `rebuildWorkflowState` handles it with no rebuild change.
83
102
  */
84
103
  conflict_resolution_attempts?: number;
@@ -110,45 +129,19 @@ export function branchOf(source: BranchSource): BranchEntry[] {
110
129
  }
111
130
 
112
131
  /**
113
- * Whether any entry on the branch already carries `needle` — the once-only injection dedup guard
114
- * (the bindingDelivery `branchHasHeader` form). Serializing each entry is the robust,
115
- * shape-agnostic scan; safe while the needle is a distinctive literal that other entries' data
116
- * can't casually contain (known accepted false positive: a tool result quoting perk's own source;
117
- * the typed customType scan is the escalation if that bites docs/learned/pi/context-injection.md).
132
+ * Whether any entry on the FULL selected branch already carries `needle` — the historical
133
+ * once-per-selected-branch guard (the toolGating read-only marker form): history across
134
+ * compaction, not live model context. Serializing each entry is the robust, shape-agnostic scan;
135
+ * safe while the needle is a distinctive literal that other entries' data can't casually contain
136
+ * (known accepted false positive: a tool result quoting perk's own source; the typed customType
137
+ * scan is the escalation if that bites — docs/learned/pi/context-injection.md). Live-delivery
138
+ * evidence ("is the copy still in model context?") is a different authority — Pi's own projection
139
+ * via `pi/v1/contextEvidence.ts` — never reconstructed from branch entries here.
118
140
  */
119
141
  export function branchCarries(branch: readonly BranchEntry[], needle: string): boolean {
120
142
  return branch.some((entry) => JSON.stringify(entry).includes(needle));
121
143
  }
122
144
 
123
- /**
124
- * The branch entries still represented directly in model context. Before compaction that is the
125
- * full branch. After compaction, Pi keeps entries from `firstKeptEntryId` onward plus anything
126
- * appended later; historical entries before that cutoff survive only through the summary.
127
- * Compaction entries are excluded because text quoted by a summary is not a live custom block.
128
- */
129
- export function activeContextWindow(branch: readonly BranchEntry[]): BranchEntry[] {
130
- let latestCompaction = -1;
131
- for (let i = branch.length - 1; i >= 0; i--) {
132
- if (branch[i]?.type === "compaction") {
133
- latestCompaction = i;
134
- break;
135
- }
136
- }
137
- if (latestCompaction === -1) return [...branch];
138
-
139
- const firstKeptEntryId = (branch[latestCompaction] as { firstKeptEntryId?: unknown })
140
- .firstKeptEntryId;
141
- const firstKept =
142
- typeof firstKeptEntryId === "string"
143
- ? branch.findIndex(
144
- (entry, index) =>
145
- index < latestCompaction && (entry as { id?: unknown }).id === firstKeptEntryId,
146
- )
147
- : -1;
148
- const start = firstKept === -1 ? latestCompaction + 1 : firstKept;
149
- return branch.slice(start).filter((entry) => entry.type !== "compaction");
150
- }
151
-
152
145
  /**
153
146
  * Per-field last-write-wins over the `perk:workflow-state` custom entries on a branch.
154
147
  * Non-perk entries are ignored; `undefined` fields never clobber (but explicit `null` does).
@@ -178,156 +171,188 @@ export interface EntrySink {
178
171
  export function appendWorkflowState<K extends keyof WorkflowState>(
179
172
  sink: EntrySink,
180
173
  source: BranchSource & ReportTarget,
181
- opts: {
182
- /** The entry payload — may carry extra fields beyond the verified one (the claim record). */
183
- data: WorkflowState;
184
- /** The field verified on read-back. */
185
- field: K;
186
- /** The value the rebuilt field must equal. */
187
- expected: WorkflowState[K];
188
- /** report() scope, e.g. "plan-save", "workflow-state linkage error". */
189
- scope: string;
190
- /** The mismatch message (byte-preserved per site). */
191
- failure: string;
192
- /** Comparator; default: (a, b) => Object.is(a ?? null, b ?? null). */
193
- equals?: (rebuilt: WorkflowState[K] | undefined, expected: WorkflowState[K]) => boolean;
194
- },
174
+ opts: AppendWorkflowStateOpts<K>,
195
175
  ): boolean {
176
+ return appendWorkflowStateClassified(sink, source, opts).status === "applied";
177
+ }
178
+
179
+ /** The strict-append options (shared by the boolean and classified entry points). */
180
+ export interface AppendWorkflowStateOpts<K extends keyof WorkflowState> {
181
+ /** The entry payload — may carry extra fields beyond the verified one (the claim record). */
182
+ data: WorkflowState;
183
+ /** The field verified on read-back. */
184
+ field: K;
185
+ /** The value the rebuilt field must equal. */
186
+ expected: WorkflowState[K];
187
+ /** report() scope, e.g. "plan-save", "workflow-state linkage error". */
188
+ scope: string;
189
+ /** The mismatch message (byte-preserved per site). */
190
+ failure: string;
191
+ /** Comparator; default: (a, b) => Object.is(a ?? null, b ?? null). */
192
+ equals?: (rebuilt: WorkflowState[K] | undefined, expected: WorkflowState[K]) => boolean;
193
+ }
194
+
195
+ /**
196
+ * The classified strict-append outcome. `rejected` is PROVEN refusal-before-effect: the append
197
+ * threw AND the rebuilt field is still not the expected value, so no entry landed. `unverified`
198
+ * means an effect may have landed unproven — the append returned but the read-back missed, or
199
+ * the post-throw rebuild itself failed.
200
+ */
201
+ export type ClassifiedAppend =
202
+ | { status: "applied" }
203
+ | { status: "rejected"; problem: string }
204
+ | { status: "unverified"; problem: string };
205
+
206
+ /**
207
+ * The classified sibling of `appendWorkflowState` (same report discipline — every failure arm
208
+ * reports loudly before returning). The extra classification work happens only on failure
209
+ * paths: a throwing `appendEntry` is re-checked against the rebuilt branch — a field still not
210
+ * equal to `expected` proves the entry never landed (`rejected`, the refusal-before-effect arm
211
+ * the session seam surfaces); a field that DOES equal `expected` landed despite the throw (the
212
+ * read-back is the proof authority — `applied`); a rebuild failure stays honest (`unverified`).
213
+ */
214
+ export function appendWorkflowStateClassified<K extends keyof WorkflowState>(
215
+ sink: EntrySink,
216
+ source: BranchSource & ReportTarget,
217
+ opts: AppendWorkflowStateOpts<K>,
218
+ ): ClassifiedAppend {
196
219
  const equals =
197
220
  opts.equals ??
198
221
  ((a: WorkflowState[K] | undefined, b: WorkflowState[K]) => Object.is(a ?? null, b ?? null));
199
222
  try {
200
223
  sink.appendEntry(WORKFLOW_STATE_TYPE, opts.data);
201
224
  const rebuilt = rebuildWorkflowState(branchOf(source))[opts.field];
202
- if (equals(rebuilt, opts.expected)) return true;
225
+ if (equals(rebuilt, opts.expected)) return { status: "applied" };
203
226
  report(source, opts.scope, "error", opts.failure, { alsoLog: true });
204
- return false;
227
+ return { status: "unverified", problem: opts.failure };
205
228
  } catch (error) {
206
- report(source, opts.scope, "error", `${String(opts.field)} append threw — ${String(error)}`, {
207
- alsoLog: true,
208
- });
209
- return false;
229
+ const problem = `${String(opts.field)} append threw — ${String(error)}`;
230
+ report(source, opts.scope, "error", problem, { alsoLog: true });
231
+ try {
232
+ const rebuilt = rebuildWorkflowState(branchOf(source))[opts.field];
233
+ if (equals(rebuilt, opts.expected)) return { status: "applied" };
234
+ return { status: "rejected", problem };
235
+ } catch {
236
+ return { status: "unverified", problem };
237
+ }
210
238
  }
211
239
  }
212
240
 
213
241
  /**
214
- * Equality by identity (provider + pr_id) the plan-ref dedup key. Two refs to
215
- * the same plan are equal even if other fields drift; absent compares equal only to absent.
242
+ * The rebuilt `conflict_resolution_attempts` counter, read WITHOUT a catch: a throwing
243
+ * `getBranch()` propagates unreadable bounding state is never treated as proven zero (the
244
+ * load-bearing failure path both conflict drives rely on). A READABLE but malformed persisted
245
+ * value (non-integer, negative, string, object, null, absent) narrows to 0.
216
246
  */
217
- export function planRefsEqual(
218
- a: PlanRef | null | undefined,
219
- b: PlanRef | null | undefined,
220
- ): boolean {
221
- if (a === null || a === undefined || b === null || b === undefined) {
222
- return (a === null || a === undefined) && (b === null || b === undefined);
223
- }
224
- return a.provider === b.provider && a.pr_id === b.pr_id;
247
+ export function conflictResolutionAttempts(source: BranchSource): number {
248
+ const value = rebuildWorkflowState(branchOf(source)).conflict_resolution_attempts;
249
+ if (typeof value === "number" && Number.isInteger(value) && value >= 0) return value;
250
+ return 0;
225
251
  }
226
252
 
227
253
  /**
228
- * Derive a fork-child run_id: `<parent>.<n>` where `n` is the max existing sibling + 1
229
- * (scanning `scratch/runs/`), else 1.
254
+ * The checked `conflict_resolution_attempts` write: a non-integer/negative value is refused
255
+ * loudly (`false`, no append — the reader narrows such values to 0, so persisting one would
256
+ * silently reopen the budget); equal-value short-circuit (no append) → `true`; otherwise the
257
+ * strict-read-back boolean from `appendWorkflowState` (its loud report path is the loudness
258
+ * channel). The counter is shared across the two warm conflict drives, so
259
+ * `scope` names the TRUE writing surface for failure reports — `/submit` passes "submit", the
260
+ * stack door "objective-sync".
230
261
  */
231
- export function deriveForkRunId(parentRunId: string, cwd: string): string {
232
- const prefix = `${parentRunId}.`;
233
- let max = 0;
234
- for (const id of listRunIds(cwd)) {
235
- if (!id.startsWith(prefix)) continue;
236
- const segment = id.slice(prefix.length).split(".")[0] ?? "";
237
- const n = Number.parseInt(segment, 10);
238
- if (Number.isInteger(n) && n > max) max = n;
262
+ export function setConflictAttempts(
263
+ sink: EntrySink,
264
+ source: BranchSource & ReportTarget,
265
+ opts: { attempts: number; scope: string },
266
+ ): boolean {
267
+ // The write seam enforces the same invariant the reader narrows by: persisting a value the
268
+ // reader would coerce to 0 would silently reopen the conflict budget on the next read, so an
269
+ // invalid write is refused loudly instead of persisted (invalid counter states are
270
+ // unrepresentable through this seam).
271
+ if (!Number.isInteger(opts.attempts) || opts.attempts < 0) {
272
+ report(
273
+ source,
274
+ opts.scope,
275
+ "warning",
276
+ `refused an invalid conflict_resolution_attempts write (${opts.attempts}) — the counter is a non-negative integer`,
277
+ { alsoLog: true },
278
+ );
279
+ return false;
239
280
  }
240
- return `${parentRunId}.${max + 1}`;
281
+ if (conflictResolutionAttempts(source) === opts.attempts) return true;
282
+ return appendWorkflowState(sink, source, {
283
+ data: { conflict_resolution_attempts: opts.attempts },
284
+ field: "conflict_resolution_attempts",
285
+ expected: opts.attempts,
286
+ scope: opts.scope,
287
+ failure:
288
+ opts.attempts === 0
289
+ ? "conflict_resolution_attempts reset read-back failed (expected 0)"
290
+ : `conflict_resolution_attempts read-back failed (expected ${opts.attempts})`,
291
+ });
241
292
  }
242
293
 
243
- export type ClaimDecision =
244
- | { action: "keep"; source: "session"; state: WorkflowState }
245
- | {
246
- action: "fork";
247
- source: "fork";
248
- childRunId: string;
249
- parentRunId: string;
250
- state: WorkflowState;
251
- }
252
- | { action: "claim"; source: "env"; runId: string }
253
- | {
254
- action: "adopt";
255
- source: "env-child";
256
- childRunId: string;
257
- parentRunId: string;
258
- /** Inherited from the parent's handoff so read-only gating survives into the child. */
259
- mode?: string;
260
- }
261
- | { action: "none"; source: "none"; state: WorkflowState };
294
+ /** Structural claim equality (objective + node match); absent compares equal only to absent. */
295
+ export function nodeClaimsEqual(
296
+ a: WorkflowState["objective_node_claim"] | undefined,
297
+ b: WorkflowState["objective_node_claim"] | undefined,
298
+ ): boolean {
299
+ const an = a ?? null;
300
+ const bn = b ?? null;
301
+ if (an === null || bn === null) return an === bn;
302
+ return an.objective === bn.objective && an.node === bn.node;
303
+ }
262
304
 
263
305
  /**
264
- * Decide what `session_start` should do, from the rebuilt state + the current session handle
265
- * + the launch env. Reload vs fork is distinguished by the `run_id ↔ pi_session_id` mapping
266
- * (NOT `event.reason`, which is "startup" for a headless `pi --fork`): if the branch already
267
- * carries a `run_id` whose recorded `pi_session_id` differs from the current session, the id
268
- * was inherited across a fork → derive a child; if it matches (or is absent), it's a reload.
269
- * An env-inherited run id whose handoff was already CONSUMED by a different session is a
270
- * spawned child, not the launched session → `adopt` (derive a sibling id, inherit `mode`).
306
+ * Resolve the active plan-ref: the worktree's `cache.plan-ref` file first, then the rebuilt
307
+ * workflow-state's `active_plan_ref` (the branch-carried linkage). The worktree read is itself
308
+ * null-lenient and runs OUTSIDE the catch (a throw from it propagates); only the branch
309
+ * rebuild is fail-open null. Structural + Pi-free: `ExtensionContext` satisfies the slice.
271
310
  */
311
+ export function activePlanRef(source: { cwd: string } & BranchSource): PlanRef | null {
312
+ const fromWorktree = readPlanRef(source.cwd);
313
+ if (fromWorktree) return fromWorktree;
314
+ try {
315
+ const branch = branchOf(source);
316
+ return (rebuildWorkflowState(branch).active_plan_ref as PlanRef | null) ?? null;
317
+ } catch {
318
+ return null;
319
+ }
320
+ }
321
+
272
322
  /**
273
- * The registry stage id the launched run is acting on, read from its handoff blob, or null.
274
- * Only `claim` (cold) and `keep` (reload) sessions have a settled run whose handoff records a
275
- * `stage`; `fork`, `adopt`, and `none` carry no launched stage (an adopted env-child must never
276
- * impersonate the launched stage; LWW restores fork/none state instead). The stage gates whether
277
- * `session_start` reconciles `cache.plan-ref` into `active_plan_ref`.
323
+ * The three-tier stack-objective resolution shared by every stack tool + command: an explicit
324
+ * id wins; else the branch-rebuilt `active_objective`; else the worktree plan-ref's
325
+ * `objective_id`. Each fallible tier fails open to the next; null when nothing resolves.
326
+ * Structural + Pi-free: `ExtensionContext` satisfies the slice.
278
327
  */
279
- export function resolveRunStage(decision: ClaimDecision, cwd: string): string | null {
280
- const runId =
281
- decision.action === "claim"
282
- ? decision.runId
283
- : decision.action === "keep"
284
- ? decision.state.run_id
285
- : null;
286
- if (runId === undefined || runId === null) return null;
287
- const stage = readHandoff(cwd, runId)?.stage;
288
- return typeof stage === "string" && stage !== "" ? stage : null;
328
+ export function resolveStackObjective(
329
+ explicit: string | undefined,
330
+ source: { cwd: string } & BranchSource,
331
+ ): string | null {
332
+ if (explicit !== undefined && explicit.length > 0) return explicit;
333
+ try {
334
+ const active = rebuildWorkflowState(branchOf(source)).active_objective;
335
+ if (active !== undefined && active !== null) return active;
336
+ } catch {
337
+ // fall through to the plan-ref tier
338
+ }
339
+ try {
340
+ return readPlanRef(source.cwd)?.objective_id ?? null;
341
+ } catch {
342
+ return null;
343
+ }
289
344
  }
290
345
 
291
- export function decideClaim(args: {
292
- state: WorkflowState;
293
- currentSessionId: string | null;
294
- envRunId: string | null;
295
- cwd: string;
296
- }): ClaimDecision {
297
- const { state, currentSessionId, envRunId, cwd } = args;
298
- if (state.run_id !== undefined) {
299
- if (state.pi_session_id === undefined || state.pi_session_id === currentSessionId) {
300
- return { action: "keep", source: "session", state };
301
- }
302
- const childRunId = deriveForkRunId(state.run_id, cwd);
303
- return { action: "fork", source: "fork", childRunId, parentRunId: state.run_id, state };
304
- }
305
- if (envRunId !== null && envRunId !== "") {
306
- // Env-child detection (contracts §8.2): subagent children are spawned as separate `pi`
307
- // processes with the parent's env, so they arrive here carrying the parent's PERK_RUN_ID.
308
- // A handoff already consumed by a DIFFERENT (or unrecorded) session belongs to someone else:
309
- // adopt a derived `<run_id>.<n>` child identity instead of re-claiming — never re-consume the
310
- // handoff, never capture pointers, never impersonate the launched stage. The parent's `mode`
311
- // is inherited so read-only gating survives into exploration children. Everything else —
312
- // absent/corrupt/mismatched handoff (the loud unclaimed error), unconsumed (the normal cold
313
- // claim), or consumed by THIS session (idempotent re-claim after lost branch state) — stays
314
- // the claim arm.
315
- const handoff = readHandoff(cwd, envRunId);
316
- if (
317
- handoff !== null &&
318
- handoff.run_id === envRunId &&
319
- handoff.consumed === true &&
320
- handoff.pi_session_id !== currentSessionId
321
- ) {
322
- return {
323
- action: "adopt",
324
- source: "env-child",
325
- childRunId: deriveForkRunId(envRunId, cwd),
326
- parentRunId: envRunId,
327
- mode: handoff.mode,
328
- };
329
- }
330
- return { action: "claim", source: "env", runId: envRunId };
346
+ /**
347
+ * Equality by identity (provider + pr_id) — the plan-ref dedup key. Two refs to
348
+ * the same plan are equal even if other fields drift; absent compares equal only to absent.
349
+ */
350
+ export function planRefsEqual(
351
+ a: PlanRef | null | undefined,
352
+ b: PlanRef | null | undefined,
353
+ ): boolean {
354
+ if (a === null || a === undefined || b === null || b === undefined) {
355
+ return (a === null || a === undefined) && (b === null || b === undefined);
331
356
  }
332
- return { action: "none", source: "none", state };
357
+ return a.provider === b.provider && a.pr_id === b.pr_id;
333
358
  }