@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
@@ -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;
@@ -65,8 +69,13 @@ export interface WorkflowState {
65
69
  * Best-effort tier (per-field LWW in `rebuildWorkflowState`, no rebuild change).
66
70
  */
67
71
  review_posts?: unknown;
68
- /** Session-artifact provenance pointers, keyed by artifact name (§8.3). */
69
- session_artifacts?: Record<string, SessionArtifactPointer> | null;
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;
70
79
  /**
71
80
  * The objective node this session has claimed `planning` (§8.3) — the warm
72
81
  * node-link carrier an approval-triggered save recovers from. Written by the `objective_node`
@@ -120,45 +129,19 @@ export function branchOf(source: BranchSource): BranchEntry[] {
120
129
  }
121
130
 
122
131
  /**
123
- * Whether any entry on the branch already carries `needle` — the once-only injection dedup guard
124
- * (the bindingDelivery `branchHasHeader` form). Serializing each entry is the robust,
125
- * shape-agnostic scan; safe while the needle is a distinctive literal that other entries' data
126
- * can't casually contain (known accepted false positive: a tool result quoting perk's own source;
127
- * 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.
128
140
  */
129
141
  export function branchCarries(branch: readonly BranchEntry[], needle: string): boolean {
130
142
  return branch.some((entry) => JSON.stringify(entry).includes(needle));
131
143
  }
132
144
 
133
- /**
134
- * The branch entries still represented directly in model context. Before compaction that is the
135
- * full branch. After compaction, Pi keeps entries from `firstKeptEntryId` onward plus anything
136
- * appended later; historical entries before that cutoff survive only through the summary.
137
- * Compaction entries are excluded because text quoted by a summary is not a live custom block.
138
- */
139
- export function activeContextWindow(branch: readonly BranchEntry[]): BranchEntry[] {
140
- let latestCompaction = -1;
141
- for (let i = branch.length - 1; i >= 0; i--) {
142
- if (branch[i]?.type === "compaction") {
143
- latestCompaction = i;
144
- break;
145
- }
146
- }
147
- if (latestCompaction === -1) return [...branch];
148
-
149
- const firstKeptEntryId = (branch[latestCompaction] as { firstKeptEntryId?: unknown })
150
- .firstKeptEntryId;
151
- const firstKept =
152
- typeof firstKeptEntryId === "string"
153
- ? branch.findIndex(
154
- (entry, index) =>
155
- index < latestCompaction && (entry as { id?: unknown }).id === firstKeptEntryId,
156
- )
157
- : -1;
158
- const start = firstKept === -1 ? latestCompaction + 1 : firstKept;
159
- return branch.slice(start).filter((entry) => entry.type !== "compaction");
160
- }
161
-
162
145
  /**
163
146
  * Per-field last-write-wins over the `perk:workflow-state` custom entries on a branch.
164
147
  * Non-perk entries are ignored; `undefined` fields never clobber (but explicit `null` does).
@@ -188,156 +171,188 @@ export interface EntrySink {
188
171
  export function appendWorkflowState<K extends keyof WorkflowState>(
189
172
  sink: EntrySink,
190
173
  source: BranchSource & ReportTarget,
191
- opts: {
192
- /** The entry payload — may carry extra fields beyond the verified one (the claim record). */
193
- data: WorkflowState;
194
- /** The field verified on read-back. */
195
- field: K;
196
- /** The value the rebuilt field must equal. */
197
- expected: WorkflowState[K];
198
- /** report() scope, e.g. "plan-save", "workflow-state linkage error". */
199
- scope: string;
200
- /** The mismatch message (byte-preserved per site). */
201
- failure: string;
202
- /** Comparator; default: (a, b) => Object.is(a ?? null, b ?? null). */
203
- equals?: (rebuilt: WorkflowState[K] | undefined, expected: WorkflowState[K]) => boolean;
204
- },
174
+ opts: AppendWorkflowStateOpts<K>,
205
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 {
206
219
  const equals =
207
220
  opts.equals ??
208
221
  ((a: WorkflowState[K] | undefined, b: WorkflowState[K]) => Object.is(a ?? null, b ?? null));
209
222
  try {
210
223
  sink.appendEntry(WORKFLOW_STATE_TYPE, opts.data);
211
224
  const rebuilt = rebuildWorkflowState(branchOf(source))[opts.field];
212
- if (equals(rebuilt, opts.expected)) return true;
225
+ if (equals(rebuilt, opts.expected)) return { status: "applied" };
213
226
  report(source, opts.scope, "error", opts.failure, { alsoLog: true });
214
- return false;
227
+ return { status: "unverified", problem: opts.failure };
215
228
  } catch (error) {
216
- report(source, opts.scope, "error", `${String(opts.field)} append threw — ${String(error)}`, {
217
- alsoLog: true,
218
- });
219
- 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
+ }
220
238
  }
221
239
  }
222
240
 
223
241
  /**
224
- * Equality by identity (provider + pr_id) the plan-ref dedup key. Two refs to
225
- * 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.
226
246
  */
227
- export function planRefsEqual(
228
- a: PlanRef | null | undefined,
229
- b: PlanRef | null | undefined,
230
- ): boolean {
231
- if (a === null || a === undefined || b === null || b === undefined) {
232
- return (a === null || a === undefined) && (b === null || b === undefined);
233
- }
234
- 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;
235
251
  }
236
252
 
237
253
  /**
238
- * Derive a fork-child run_id: `<parent>.<n>` where `n` is the max existing sibling + 1
239
- * (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".
240
261
  */
241
- export function deriveForkRunId(parentRunId: string, cwd: string): string {
242
- const prefix = `${parentRunId}.`;
243
- let max = 0;
244
- for (const id of listRunIds(cwd)) {
245
- if (!id.startsWith(prefix)) continue;
246
- const segment = id.slice(prefix.length).split(".")[0] ?? "";
247
- const n = Number.parseInt(segment, 10);
248
- 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;
249
280
  }
250
- 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
+ });
251
292
  }
252
293
 
253
- export type ClaimDecision =
254
- | { action: "keep"; source: "session"; state: WorkflowState }
255
- | {
256
- action: "fork";
257
- source: "fork";
258
- childRunId: string;
259
- parentRunId: string;
260
- state: WorkflowState;
261
- }
262
- | { action: "claim"; source: "env"; runId: string }
263
- | {
264
- action: "adopt";
265
- source: "env-child";
266
- childRunId: string;
267
- parentRunId: string;
268
- /** Inherited from the parent's handoff so read-only gating survives into the child. */
269
- mode?: string;
270
- }
271
- | { 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
+ }
272
304
 
273
305
  /**
274
- * Decide what `session_start` should do, from the rebuilt state + the current session handle
275
- * + the launch env. Reload vs fork is distinguished by the `run_id ↔ pi_session_id` mapping
276
- * (NOT `event.reason`, which is "startup" for a headless `pi --fork`): if the branch already
277
- * carries a `run_id` whose recorded `pi_session_id` differs from the current session, the id
278
- * was inherited across a fork → derive a child; if it matches (or is absent), it's a reload.
279
- * An env-inherited run id whose handoff was already CONSUMED by a different session is a
280
- * 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.
281
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
+
282
322
  /**
283
- * The registry stage id the launched run is acting on, read from its handoff blob, or null.
284
- * Only `claim` (cold) and `keep` (reload) sessions have a settled run whose handoff records a
285
- * `stage`; `fork`, `adopt`, and `none` carry no launched stage (an adopted env-child must never
286
- * impersonate the launched stage; LWW restores fork/none state instead). The stage gates whether
287
- * `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.
288
327
  */
289
- export function resolveRunStage(decision: ClaimDecision, cwd: string): string | null {
290
- const runId =
291
- decision.action === "claim"
292
- ? decision.runId
293
- : decision.action === "keep"
294
- ? decision.state.run_id
295
- : null;
296
- if (runId === undefined || runId === null) return null;
297
- const stage = readHandoff(cwd, runId)?.stage;
298
- 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
+ }
299
344
  }
300
345
 
301
- export function decideClaim(args: {
302
- state: WorkflowState;
303
- currentSessionId: string | null;
304
- envRunId: string | null;
305
- cwd: string;
306
- }): ClaimDecision {
307
- const { state, currentSessionId, envRunId, cwd } = args;
308
- if (state.run_id !== undefined) {
309
- if (state.pi_session_id === undefined || state.pi_session_id === currentSessionId) {
310
- return { action: "keep", source: "session", state };
311
- }
312
- const childRunId = deriveForkRunId(state.run_id, cwd);
313
- return { action: "fork", source: "fork", childRunId, parentRunId: state.run_id, state };
314
- }
315
- if (envRunId !== null && envRunId !== "") {
316
- // Env-child detection (contracts §8.2): subagent children are spawned as separate `pi`
317
- // processes with the parent's env, so they arrive here carrying the parent's PERK_RUN_ID.
318
- // A handoff already consumed by a DIFFERENT (or unrecorded) session belongs to someone else:
319
- // adopt a derived `<run_id>.<n>` child identity instead of re-claiming — never re-consume the
320
- // handoff, never capture pointers, never impersonate the launched stage. The parent's `mode`
321
- // is inherited so read-only gating survives into exploration children. Everything else —
322
- // absent/corrupt/mismatched handoff (the loud unclaimed error), unconsumed (the normal cold
323
- // claim), or consumed by THIS session (idempotent re-claim after lost branch state) — stays
324
- // the claim arm.
325
- const handoff = readHandoff(cwd, envRunId);
326
- if (
327
- handoff !== null &&
328
- handoff.run_id === envRunId &&
329
- handoff.consumed === true &&
330
- handoff.pi_session_id !== currentSessionId
331
- ) {
332
- return {
333
- action: "adopt",
334
- source: "env-child",
335
- childRunId: deriveForkRunId(envRunId, cwd),
336
- parentRunId: envRunId,
337
- mode: handoff.mode,
338
- };
339
- }
340
- 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);
341
356
  }
342
- return { action: "none", source: "none", state };
357
+ return a.provider === b.provider && a.pr_id === b.pr_id;
343
358
  }