@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,18 @@
1
- // The warm `/land` door. The in-session twin of the Python cold door
2
- // (`perk pr land`): a terminating tool + command that DELEGATE the GitHub merge (mutations
3
- // canonical in Python), then mirror the envelope's `pending_learn` for the in-session path
4
- // setting the `pending-learn` marker (an idempotent existence-semaphore; the worker sets it too
5
- // on the cold path) unless the cold door reports the learn-docs exemption (`pending_learn:
6
- // false` no marker, no /learn nudge). Never throws.
1
+ // The per-plan landing bindings: the terminating `land` tool + the `/land` command, the
2
+ // in-session twin of the Python cold door (`perk pr land`). A deliberately ZERO-POLICY adapter
3
+ // module (no feature operation backs it zero-policy passthrough): the tool DELEGATES the
4
+ // GitHub merge (mutations canonical in Python), then mirrors the envelope's `pending_learn` for
5
+ // the in-session path setting the `pending-learn` marker (an idempotent existence-semaphore;
6
+ // the worker sets it too on the cold path) unless the cold door reports the learn-docs
7
+ // exemption (`pending_learn: false` — no marker, no /learn nudge). Never throws; a verified
8
+ // land result survives every advisory failure (marker writes, malformed sub-objects) as a loud
9
+ // warning line, never a post-merge rejection.
7
10
 
8
11
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
- import { reconcileGuidance } from "../factories/objectivePlan.ts";
10
- import { bindingSuffix } from "../substrate/bindingDelivery.ts";
11
- import { PENDING_LEARN, setMarker } from "../substrate/cache.ts";
12
+ import { reconcileGuidance } from "../../../authoring/objective/prose.ts";
13
+ import { planningStageRefusal } from "../../../session/lifecycleGates.ts";
14
+ import { bindingSuffix } from "../../../substrate/bindingDelivery.ts";
15
+ import { PENDING_LEARN, setMarker } from "../../../substrate/cache.ts";
12
16
  import {
13
17
  booleanField,
14
18
  type ColdJson,
@@ -17,16 +21,21 @@ import {
17
21
  objectField,
18
22
  runColdDoor,
19
23
  stringField,
20
- } from "../substrate/coldDoor.ts";
21
- import { registerPerkCommand } from "../substrate/command.ts";
22
- import { failFor, ok, type Result } from "../substrate/result.ts";
23
- import { report } from "../surfaces/report.ts";
24
- import { planningStageRefusal } from "./lifecycleGates.ts";
24
+ } from "../../../substrate/coldDoor.ts";
25
+ import { registerPerkCommand } from "../../../substrate/command.ts";
26
+ import { failFor, ok, type Result } from "../../../substrate/result.ts";
27
+ import { report } from "../../../surfaces/report.ts";
25
28
 
26
29
  // Learn-consume skip reasons that are ordinary, not failures: non-factory plans carry no
27
30
  // `consumed_learn` (`no_consumed_learn`), and a dry run reports `dry_run`. Anything else surfaces.
28
31
  const BENIGN_LEARN_SKIPS = new Set(["no_consumed_learn", "dry_run"]);
29
32
 
33
+ /** The marker-safe id vocabulary the reconcile drive requires of the objective id — the
34
+ * interpolated guidance is a steering message rendering the id as an unquoted CLI argument,
35
+ * so an out-of-vocabulary id never drives and an option-shaped `-`-leading id never passes
36
+ * (alphanumeric-first). */
37
+ const OBJECTIVE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
38
+
30
39
  export interface ObjectiveLandUpdate {
31
40
  /** Opaque string objective id (GitHub "5", Linear "ENG-5") — §8.21. */
32
41
  id: string | null;
@@ -43,7 +52,7 @@ export interface LearnConsumeUpdate {
43
52
 
44
53
  /** The ok-arm fields — the structured `details` surface doubles as branch-safe persisted state.
45
54
  * `pending_learn` mirrors the cold envelope: `false` is the learn-docs exemption (no marker). */
46
- export interface LandOk {
55
+ interface LandOk {
47
56
  pr: { number: number; state: string };
48
57
  branch?: string;
49
58
  /** Opaque string plan-issue id (§8.21). */
@@ -53,8 +62,16 @@ export interface LandOk {
53
62
  learn?: LearnConsumeUpdate;
54
63
  }
55
64
 
56
- export type LandResult = Result<LandOk>;
57
- export type LandDetails = LandResult["details"];
65
+ type LandResult = Result<LandOk>;
66
+ type LandDetails = LandResult["details"];
67
+
68
+ /** An optional advisory sub-object's three-state decode: absent is ordinary; malformed is
69
+ * DISTINGUISHED (the merge already succeeded, so the success report survives — but the
70
+ * unverified state is reported loudly, never silently dropped). */
71
+ type AdvisoryDecode<T> =
72
+ | { state: "absent" }
73
+ | { state: "malformed" }
74
+ | { state: "present"; value: T };
58
75
 
59
76
  /** The decoded `perk pr land --json` payload — the cold door owns `pending_learn` (the
60
77
  * learn-docs-exemption decision point); decoded leniently so skew degrades to legacy. */
@@ -63,47 +80,59 @@ interface LandPayload {
63
80
  branch?: string;
64
81
  issue?: string;
65
82
  pending_learn: boolean;
66
- objective?: ObjectiveLandUpdate;
67
- learn?: LearnConsumeUpdate;
83
+ objective: AdvisoryDecode<ObjectiveLandUpdate>;
84
+ learn: AdvisoryDecode<LearnConsumeUpdate>;
68
85
  }
69
86
 
70
- /** Validate the optional `objective` sub-object; malformed undefined (advisory, never fatal). */
71
- function decodeObjective(payload: ColdJson): ObjectiveLandUpdate | undefined {
87
+ /** Validate the optional `objective` sub-object three-state (absent/malformed/present). */
88
+ function decodeObjective(payload: ColdJson): AdvisoryDecode<ObjectiveLandUpdate> {
89
+ if (payload.objective === undefined) return { state: "absent" };
72
90
  const obj = objectField(payload, "objective");
73
- if (obj === undefined) return undefined;
91
+ if (obj === undefined) return { state: "malformed" };
74
92
  const id = obj.id;
75
- if (typeof id !== "string" && id !== null) return undefined;
93
+ if (typeof id !== "string" && id !== null) return { state: "malformed" };
76
94
  const nodesMarked = obj.nodes_marked;
77
95
  if (!Array.isArray(nodesMarked) || !nodesMarked.every((n) => typeof n === "string")) {
78
- return undefined;
96
+ return { state: "malformed" };
79
97
  }
80
98
  const skippedReason = nullableStringField(obj, "skipped_reason");
81
- if (skippedReason === undefined && obj.skipped_reason !== undefined) return undefined;
99
+ if (skippedReason === undefined && obj.skipped_reason !== undefined) {
100
+ return { state: "malformed" };
101
+ }
82
102
  // `closed` is an advisory display detail: decode leniently (missing/malformed → false) rather
83
103
  // than dropping the whole sub-object.
84
104
  return {
85
- id,
86
- nodes_marked: nodesMarked,
87
- skipped_reason: skippedReason ?? null,
88
- closed: obj.closed === true,
105
+ state: "present",
106
+ value: {
107
+ id,
108
+ nodes_marked: nodesMarked,
109
+ skipped_reason: skippedReason ?? null,
110
+ closed: obj.closed === true,
111
+ },
89
112
  };
90
113
  }
91
114
 
92
- /** Validate the optional `learn` sub-object; malformed undefined (advisory, never fatal). */
93
- function decodeLearn(payload: ColdJson): LearnConsumeUpdate | undefined {
115
+ /** Validate the optional `learn` sub-object three-state (absent/malformed/present). */
116
+ function decodeLearn(payload: ColdJson): AdvisoryDecode<LearnConsumeUpdate> {
117
+ if (payload.learn === undefined) return { state: "absent" };
94
118
  const learn = objectField(payload, "learn");
95
- if (learn === undefined) return undefined;
119
+ if (learn === undefined) return { state: "malformed" };
96
120
  const closed = learn.closed;
97
- if (!Array.isArray(closed) || !closed.every((n) => typeof n === "string")) return undefined;
121
+ if (!Array.isArray(closed) || !closed.every((n) => typeof n === "string")) {
122
+ return { state: "malformed" };
123
+ }
98
124
  const skippedReason = nullableStringField(learn, "skipped_reason");
99
- if (skippedReason === undefined && learn.skipped_reason !== undefined) return undefined;
100
- return { closed, skipped_reason: skippedReason ?? null };
125
+ if (skippedReason === undefined && learn.skipped_reason !== undefined) {
126
+ return { state: "malformed" };
127
+ }
128
+ return { state: "present", value: { closed, skipped_reason: skippedReason ?? null } };
101
129
  }
102
130
 
103
131
  /**
104
132
  * Narrow the `perk pr land --json` success payload. Strict on `pr` (malformed → bad_output);
105
- * the optional `objective`/`learn` sub-objects are validated but dropped when malformed the
106
- * merge already succeeded, so the success report must survive a malformed advisory field.
133
+ * the optional `objective`/`learn` sub-objects decode three-state a malformed advisory field
134
+ * is dropped from the details (the merge already succeeded, so the success report must
135
+ * survive it) but reported as an UNVERIFIED-state warning line, never silently.
107
136
  */
108
137
  function decodeLand(payload: ColdJson): LandPayload | null {
109
138
  const pr = objectField(payload, "pr");
@@ -124,13 +153,25 @@ function decodeLand(payload: ColdJson): LandPayload | null {
124
153
  };
125
154
  }
126
155
 
156
+ /** The malformed-advisory warning line (the `<objective|learn>` slots are the report name and
157
+ * its unverified-state name). */
158
+ function malformedAdvisoryWarning(field: "objective" | "learn"): string {
159
+ const stateName = field === "objective" ? "objective reconcile" : "learn";
160
+ return (
161
+ `Warning: the land envelope's ${field} report was malformed — ${stateName} state ` +
162
+ "UNVERIFIED; inspect the objective (or run /objective-reconcile) manually."
163
+ );
164
+ }
165
+
127
166
  /**
128
- * The single land implementation both surfaces call. Delegates the merge to the Python cold door,
129
- * then mirrors the envelope's `pending_learn` (in-session path): marker + /learn nudge on the
130
- * ordinary arm; no marker, no nudge on the learn-docs exemption. Returns a soft result
167
+ * The single land implementation both surfaces call. Delegates the merge to the Python cold
168
+ * door, then mirrors the envelope's `pending_learn` (in-session path): marker + /learn nudge on
169
+ * the ordinary arm; no marker, no nudge on the learn-docs exemption. A marker WRITE failure is
170
+ * loud-not-fatal: the merge is already verified, so the success result (and the reconcile
171
+ * drive) survive with a warning line naming the /learn remediation. Returns a soft result
131
172
  * (never throws).
132
173
  */
133
- export async function landPr(pi: ExtensionAPI, ctx: ExtensionContext): Promise<LandResult> {
174
+ async function landPr(pi: ExtensionAPI, ctx: ExtensionContext): Promise<LandResult> {
134
175
  const fail = failFor(ctx, "land");
135
176
 
136
177
  // Planning sessions never legitimately land — the first check, before any cold-door
@@ -144,17 +185,26 @@ export async function landPr(pi: ExtensionAPI, ctx: ExtensionContext): Promise<L
144
185
  });
145
186
  if (!r.ok) return fail(r.message, r.errorType);
146
187
 
147
- if (r.data.pending_learn) {
148
- // Set the semaphore for the in-session path (idempotent; the cold door also set it on disk).
149
- setMarker(ctx.cwd, PENDING_LEARN);
150
- }
151
-
152
188
  const lines = [
153
189
  r.data.pending_learn
154
190
  ? `Landed PR #${r.data.pr.number}; run /learn to release the worktree.`
155
191
  : `Landed PR #${r.data.pr.number}; learn-docs plan — no learn pass needed; the worktree is releasable.`,
156
192
  ];
157
- const obj = r.data.objective;
193
+ if (r.data.pending_learn) {
194
+ // Set the semaphore for the in-session path (idempotent; the cold door also set it on
195
+ // disk). Guarded: a filesystem failure must never erase a VERIFIED land result — the
196
+ // success report + reconcile drive survive, with the /learn remediation named loudly.
197
+ try {
198
+ setMarker(ctx.cwd, PENDING_LEARN);
199
+ } catch (error) {
200
+ const detail = error instanceof Error ? error.message : String(error);
201
+ lines.push(
202
+ `Warning: the pending-learn marker could not be written (${detail}); run /learn ` +
203
+ "before releasing the worktree.",
204
+ );
205
+ }
206
+ }
207
+ const obj = r.data.objective.state === "present" ? r.data.objective.value : undefined;
158
208
  if (obj?.nodes_marked.length && obj.id !== null) {
159
209
  // The reconcile pass is auto-driven after land (see driveReconcileAfterLand); just report it.
160
210
  lines.push(
@@ -165,7 +215,7 @@ export async function landPr(pi: ExtensionAPI, ctx: ExtensionContext): Promise<L
165
215
  if (obj?.closed && obj.id !== null) {
166
216
  lines.push(`Objective #${obj.id} complete — closed.`);
167
217
  }
168
- const learn = r.data.learn;
218
+ const learn = r.data.learn.state === "present" ? r.data.learn.value : undefined;
169
219
  if (learn?.closed.length) {
170
220
  // hop-2: the consumed perk:learn issues were closed + labelled perk:consolidated on land.
171
221
  lines.push(
@@ -179,21 +229,31 @@ export async function landPr(pi: ExtensionAPI, ctx: ExtensionContext): Promise<L
179
229
  if (learn?.skipped_reason && !BENIGN_LEARN_SKIPS.has(learn.skipped_reason)) {
180
230
  lines.push(`Warning: learn consume incomplete — ${learn.skipped_reason}.`);
181
231
  }
232
+ // A malformed advisory sub-object leaves the merge verified but its state UNKNOWN: the
233
+ // details omit the field (as before) and the drive stays suppressed, but the miss is loud.
234
+ if (r.data.objective.state === "malformed") lines.push(malformedAdvisoryWarning("objective"));
235
+ if (r.data.learn.state === "malformed") lines.push(malformedAdvisoryWarning("learn"));
182
236
 
183
- return ok(
184
- lines.join("\n"),
185
- { ...r.data, pending_learn: r.data.pending_learn },
186
- { terminate: true },
187
- );
237
+ const details: LandOk = {
238
+ pr: r.data.pr,
239
+ branch: r.data.branch,
240
+ issue: r.data.issue,
241
+ pending_learn: r.data.pending_learn,
242
+ objective: obj,
243
+ learn: learn,
244
+ };
245
+ return ok(lines.join("\n"), details, { terminate: true });
188
246
  }
189
247
 
190
248
  /**
191
- * After a successful land that marked at least one objective node done, drive the session into the
192
- * reconcile pass by injecting the exact guidance `/objective-reconcile` injects (warm-door driving
193
- * pattern). The terminating `land` tool stays terminating — terminate only skips the *automatic*
194
- * follow-up LLM call, while a `followUp` user message is a separate deliberate new turn. Short-
195
- * circuits (sends nothing) unless the land succeeded with an objective node marked done — the exact
196
- * condition that gated the old copy-pasteable nudge.
249
+ * After a successful land that marked at least one objective node done, drive the session into
250
+ * the reconcile pass by injecting the exact guidance `/objective-reconcile` injects (warm-door
251
+ * driving pattern). The terminating `land` tool stays terminating — terminate only skips the
252
+ * *automatic* follow-up LLM call, while a `followUp` user message is a separate deliberate new
253
+ * turn. Short-circuits (sends nothing) unless the land succeeded with an objective node marked
254
+ * done AND the id passes the marker-safe vocabulary — the id is interpolated into a steering
255
+ * message, so a poisoned envelope id never drives. Exported for the offline suite (the
256
+ * streaming `followUp` branch is unreachable through the idle harness).
197
257
  */
198
258
  export function driveReconcileAfterLand(
199
259
  pi: ExtensionAPI,
@@ -203,6 +263,7 @@ export function driveReconcileAfterLand(
203
263
  if (!details.ok) return;
204
264
  const obj = details.objective;
205
265
  if (!obj || obj.id === null || obj.nodes_marked.length === 0) return;
266
+ if (!OBJECTIVE_ID_RE.test(obj.id)) return;
206
267
  const message = reconcileGuidance(obj.id) + bindingSuffix(ctx.cwd, "command:objective-reconcile");
207
268
  if (ctx.isIdle()) {
208
269
  // The `/land` command path (idle): inject an immediate turn.
@@ -219,8 +280,9 @@ const TOOL_GUIDELINES = [
219
280
  "land refuses a stacked-delivery plan (`delivery_lineage`): stacked layers land as one atomic train, never individually.",
220
281
  ];
221
282
 
222
- /** Register the warm door: the `land` terminating tool + the `/land` command twin. */
223
- export function registerLand(pi: ExtensionAPI): void {
283
+ /** Install the per-plan landing bindings: the `land` terminating tool + the `/land` command
284
+ * twin. */
285
+ export function installLandBindings(pi: ExtensionAPI): void {
224
286
  pi.registerTool({
225
287
  name: "land",
226
288
  label: "Land PR",
@@ -0,0 +1,322 @@
1
+ // The ready + handoff bindings: the `ready` terminating tool + the `/ready` command twin,
2
+ // adapting the Pi-free ready operation in `delivery/ready.ts`. The in-session twin of the Python
3
+ // cold door (`perk pr ready`): a terminating surface that DELEGATES the mechanics (mutations
4
+ // canonical in Python). For an incremental plan this is the review gate — perk deliberately does
5
+ // NOT auto-publish on submit; `/ready` is the explicit gesture that opens the draft PR for
6
+ // review. For a STACKED layer it is the deliberate HUMAN handoff made after review + address: it
7
+ // stamps the exact verified published head into the delivery journal (draft AND non-draft PRs —
8
+ // mark-ready mechanics first, then the journal append), and every successful stacked stamp
9
+ // continues into the ready-time reconcile pass (`driveReadyContinuation` — the warm
10
+ // continuation, contracts.md §8.66). Write nothing, delegate via the cold-door seam, surface the
11
+ // structured result, never throw. This tier is pure decoding, rendering, process invocation, and
12
+ // Pi delivery — the arm order, the strict evidence vocabulary, and the continuation decision
13
+ // live in the feature op.
14
+
15
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
16
+ import { objectiveReadInstruction } from "../../../authoring/objective/prose.ts";
17
+ import {
18
+ type ReadyDeps,
19
+ type ReadyDriveEvidence,
20
+ type ReadyFacts,
21
+ type ReadyHandoff,
22
+ type ReadyOutcome,
23
+ type ReadyPr,
24
+ readyChange,
25
+ } from "../../../delivery/ready.ts";
26
+ import { bindingSuffix } from "../../../substrate/bindingDelivery.ts";
27
+ import {
28
+ booleanField,
29
+ type ColdJson,
30
+ numberField,
31
+ objectField,
32
+ runColdDoor,
33
+ stringField,
34
+ } from "../../../substrate/coldDoor.ts";
35
+ import { registerPerkCommand } from "../../../substrate/command.ts";
36
+ import { resolveIssueBackendId } from "../../../substrate/config.ts";
37
+ import { render } from "../../../substrate/prompts.ts";
38
+ import { failFor, ok } from "../../../substrate/result.ts";
39
+ import type { ToolGating } from "../../../substrate/toolGating.ts";
40
+ import { report } from "../../../surfaces/report.ts";
41
+ import { fetchObjectiveUrl } from "../objective.ts";
42
+
43
+ /**
44
+ * Narrow the `perk pr ready --json` success payload into the correlated facts variants; strict
45
+ * on `pr`, lenient on the rest. The stacked continuation decodes as ONE cohort: a
46
+ * partial/wrong-typed cohort is validated-and-dropped whole into `stacked_unverified` (the
47
+ * worker's own success already proved the mechanics — advisory detail is never half-rendered),
48
+ * keeping the routing fact as the visible mismatch signal. An absent-or-false `stacked` (an old
49
+ * worker included) is the `incremental` variant, false-vs-absent preserved for the wire.
50
+ */
51
+ function decodeReady(payload: ColdJson): ReadyFacts | null {
52
+ const prField = objectField(payload, "pr");
53
+ if (prField === undefined) return null;
54
+ const number = numberField(prField, "number");
55
+ const url = stringField(prField, "url");
56
+ if (number === undefined || url === undefined) return null;
57
+ const pr: ReadyPr = { number, url };
58
+ const wasDraft = booleanField(payload, "was_draft");
59
+ const stacked = booleanField(payload, "stacked");
60
+ if (stacked !== true) {
61
+ return { route: "incremental", pr, was_draft: wasDraft, stacked };
62
+ }
63
+ const objective = stringField(payload, "objective");
64
+ const node = stringField(payload, "node");
65
+ const stampedHead = stringField(payload, "stamped_head");
66
+ const stampAdvanced = booleanField(payload, "stamp_advanced");
67
+ const plan = stringField(payload, "plan");
68
+ const parentCheckpoint = stringField(payload, "parent_checkpoint");
69
+ if (
70
+ objective === undefined ||
71
+ node === undefined ||
72
+ stampedHead === undefined ||
73
+ stampAdvanced === undefined ||
74
+ plan === undefined ||
75
+ parentCheckpoint === undefined
76
+ ) {
77
+ return { route: "stacked_unverified", pr, was_draft: wasDraft };
78
+ }
79
+ return {
80
+ route: "stacked",
81
+ pr,
82
+ was_draft: wasDraft,
83
+ handoff: {
84
+ objective,
85
+ node,
86
+ stamped_head: stampedHead,
87
+ stamp_advanced: stampAdvanced,
88
+ plan,
89
+ parent_checkpoint: parentCheckpoint,
90
+ },
91
+ };
92
+ }
93
+
94
+ /** The ONE production `MarkReady` adapter: `perk pr ready --json` through the cold-door seam
95
+ * (cancellation, envelope validation, and version-skew diagnostics ride the seam).
96
+ * Module-private: production composes only through `readyDepsFor`. */
97
+ function createReadyMarker(pi: ExtensionAPI, ctx: ExtensionContext): ReadyDeps["markReady"] {
98
+ return async () => {
99
+ const r = await runColdDoor<ReadyFacts>(pi, ctx, ["pr", "ready", "--json"], {
100
+ label: "perk pr ready",
101
+ decode: decodeReady,
102
+ });
103
+ if (!r.ok) return { ok: false, message: r.message, errorType: r.errorType };
104
+ return { ok: true, facts: r.data };
105
+ };
106
+ }
107
+
108
+ /** The one production `ReadyDeps` composition (module-private: the one-composition invariant
109
+ * is structural). The gate read is the injected capability — the feature op reads it only on
110
+ * the stamped-with-cohort path. */
111
+ function readyDepsFor(pi: ExtensionAPI, ctx: ExtensionContext, gating: ToolGating): ReadyDeps {
112
+ return {
113
+ markReady: createReadyMarker(pi, ctx),
114
+ sessionReadOnly: () => gating.isActive(),
115
+ };
116
+ }
117
+
118
+ /** The wire-identical ok-arm details rebuilt from the facts variants: incremental ⇒ `stacked`
119
+ * passthrough (false or absent), no `handoff`; `stacked_unverified` ⇒ `stacked: true`, no
120
+ * `handoff`; `stacked` ⇒ `stacked: true` + the six-field cohort. Optional keys carry
121
+ * `undefined` exactly where the old decode left them absent (the JSON round-trip drops them). */
122
+ interface ReadyDetails {
123
+ pr: ReadyPr;
124
+ was_draft?: boolean;
125
+ stacked?: boolean;
126
+ handoff?: ReadyHandoff;
127
+ }
128
+
129
+ function readyDetails(facts: ReadyFacts): ReadyDetails {
130
+ switch (facts.route) {
131
+ case "incremental":
132
+ return { pr: facts.pr, was_draft: facts.was_draft, stacked: facts.stacked };
133
+ case "stacked_unverified":
134
+ return { pr: facts.pr, was_draft: facts.was_draft, stacked: true };
135
+ case "stacked":
136
+ return { pr: facts.pr, was_draft: facts.was_draft, stacked: true, handoff: facts.handoff };
137
+ }
138
+ }
139
+
140
+ /** Render the success message: the ready line, plus — exactly when the facts route is
141
+ * `stacked` — the handoff-stamped line. Stamp facts only: the continuation is announced by
142
+ * `driveReadyContinuation`, and only once the feature op's refusal arms have accepted. */
143
+ function renderReadyMessage(facts: ReadyFacts): string {
144
+ const verb = facts.was_draft ? "Marked ready" : "Already ready";
145
+ let message = `${verb}: PR #${facts.pr.number} is open for review.`;
146
+ if (facts.route === "stacked") {
147
+ const handoff = facts.handoff;
148
+ const stamped = handoff.stamp_advanced ? "Handoff stamped" : "Handoff already stamped";
149
+ message +=
150
+ ` ${stamped}: objective #${handoff.objective} node ${handoff.node} at ` +
151
+ `${handoff.stamped_head}.`;
152
+ }
153
+ return message;
154
+ }
155
+
156
+ /** One loud skipped-pass warning (the stamp itself stands; re-running `/ready` re-enters). */
157
+ function warnSkippedPass(ctx: ExtensionContext, reason: string, retry: string): void {
158
+ report(
159
+ ctx,
160
+ "ready",
161
+ "warning",
162
+ `ready-time reconcile pass not driven — ${reason}. The handoff stamp stands; re-run ${retry} to enter the pass.`,
163
+ );
164
+ }
165
+
166
+ /** The retry gesture from the feature op's safe-interpolation policy: a marker-safe plan id
167
+ * interpolates; `null` renders the `<plan>` placeholder. */
168
+ function retryGesture(retryPlan: string | null): string {
169
+ return `\`perk ready ${retryPlan ?? "<plan>"}\``;
170
+ }
171
+
172
+ /** Inject the rendered ready-time reconcile pass (contracts.md §8.66) — the warm twin of the
173
+ * cold wrapper's seeded launch. Interpolates exclusively from the mint-only evidence. */
174
+ async function driveReconcilePass(
175
+ pi: ExtensionAPI,
176
+ ctx: ExtensionContext,
177
+ evidence: ReadyDriveEvidence,
178
+ ): Promise<void> {
179
+ const backend = resolveIssueBackendId(ctx.cwd);
180
+ const url = backend === "linear" ? await fetchObjectiveUrl(pi, ctx, evidence.objective) : "";
181
+ const readClause = objectiveReadInstruction(backend, evidence.objective, url);
182
+ const message =
183
+ render("stages/objective-reconcile-ready.md", {
184
+ objective: evidence.objective,
185
+ node: evidence.node,
186
+ plan: evidence.plan,
187
+ pr: String(evidence.pr),
188
+ parent_checkpoint: evidence.parent_checkpoint,
189
+ stamped_head: evidence.stamped_head,
190
+ read_clause: readClause,
191
+ }) + bindingSuffix(ctx.cwd, "command:objective-reconcile");
192
+ // Announce the continuation only HERE — after every refusal arm has accepted the drive.
193
+ report(
194
+ ctx,
195
+ "ready",
196
+ "info",
197
+ `continuing into the ready-time reconcile pass — objective #${evidence.objective}, ` +
198
+ `pinned range ${evidence.parent_checkpoint}..${evidence.stamped_head}`,
199
+ );
200
+ if (ctx.isIdle()) {
201
+ // The `/ready` command path (idle): inject an immediate turn.
202
+ pi.sendUserMessage(message);
203
+ } else {
204
+ // The `ready` tool path (streaming): deliver after the terminating ready batch.
205
+ pi.sendUserMessage(message, { deliverAs: "followUp" });
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Translate the feature op's outcome into the warm continuation (contracts.md §8.66): the drive
211
+ * fires on EVERY accepted stacked stamp (`stamp_advanced: false` re-stamps included — re-running
212
+ * `/ready` re-enters reconciliation). The refusal arms are LOUD, never silent; the stamp itself
213
+ * always stands. `failed` and `completed` are explicit quiet arms. Exported as the one direct
214
+ * test seam — the streaming `followUp` branch is unreachable through the idle harness.
215
+ */
216
+ export async function driveReadyContinuation(
217
+ pi: ExtensionAPI,
218
+ ctx: ExtensionContext,
219
+ outcome: ReadyOutcome,
220
+ ): Promise<void> {
221
+ switch (outcome.kind) {
222
+ case "failed":
223
+ case "completed":
224
+ return; // exterior failure / incremental: nothing to drive, quietly
225
+ case "stamp_facts_unverified":
226
+ // A successful stacked stamp whose continuation cohort failed to decode — a
227
+ // malformed/mixed-version envelope must never fail silent.
228
+ warnSkippedPass(
229
+ ctx,
230
+ "the worker reported a stacked stamp but its continuation facts were malformed " +
231
+ "(a mixed-version envelope?)",
232
+ "/ready",
233
+ );
234
+ return;
235
+ case "stamped": {
236
+ const continuation = outcome.continuation;
237
+ switch (continuation.kind) {
238
+ case "refused_read_only":
239
+ warnSkippedPass(
240
+ ctx,
241
+ "this session is read-only (the pass's write tools are gated off); exit the " +
242
+ "read-only session or run the pass from a terminal",
243
+ retryGesture(continuation.retryPlan),
244
+ );
245
+ return;
246
+ case "evidence_invalid":
247
+ warnSkippedPass(
248
+ ctx,
249
+ "the stamp evidence failed strict validation (ids marker-safe; both diff-range " +
250
+ "endpoints full 40-hex lowercase)",
251
+ retryGesture(continuation.retryPlan),
252
+ );
253
+ return;
254
+ case "drive":
255
+ await driveReconcilePass(pi, ctx, continuation.evidence);
256
+ return;
257
+ }
258
+ // Exhaustive over the continuation (no catch-all): union growth breaks the adapter here.
259
+ const exhaustiveContinuation: never = continuation;
260
+ throw new Error(`unreachable ready continuation: ${JSON.stringify(exhaustiveContinuation)}`);
261
+ }
262
+ }
263
+ // Exhaustive over the outcome (no catch-all): union growth breaks the adapter here.
264
+ const exhaustive: never = outcome;
265
+ throw new Error(`unreachable ready outcome: ${JSON.stringify(exhaustive)}`);
266
+ }
267
+
268
+ const TOOL_GUIDELINES = [
269
+ "For an incremental plan, call ready only when the PR is ready for human review; it marks the draft PR ready (the deliberate review gate). submit keeps the PR draft on purpose.",
270
+ "For a STACKED plan, /ready is the deliberate HUMAN handoff made AFTER review + address: it stamps the exact verified published head into the delivery journal (draft and non-draft PRs alike), and the recorded stamp unblocks planning of the layer's direct dependents. Never call it as routine post-submit choreography — review happens on the draft layer PR; only invoke it when the human explicitly asks.",
271
+ "ready operates on the active plan's worktree — it takes no arguments; the PR is discovered from the local plan-ref's branch. Idempotent: an already-ready PR is success, and a re-run converges on the same stamp.",
272
+ "A failed stamp (error_type ready_stamp_failed) names its own remediation: the ambiguous/transient arms converge on re-run; deterministic failures need their named repair first.",
273
+ ];
274
+
275
+ /** Install the ready + handoff bindings: the `ready` terminating tool + the `/ready` command
276
+ * twin. */
277
+ export function installReadyBindings(pi: ExtensionAPI, gating: ToolGating): void {
278
+ pi.registerTool({
279
+ name: "ready",
280
+ label: "Mark PR ready",
281
+ description:
282
+ "Ready the active plan's PR. Incremental: mark the draft PR ready for review (the " +
283
+ "deliberate review gate; submit keeps the PR draft). Stacked: the deliberate post-review " +
284
+ "HUMAN handoff — stamps the exact verified published head (draft and non-draft PRs); " +
285
+ "never routine post-submit choreography, never auto-run. Terminating: ends the turn.",
286
+ promptSnippet:
287
+ "Ready the PR: open the draft for review (incremental) or record the post-review " +
288
+ "handoff stamp (stacked; human-asked only). Terminates the turn.",
289
+ promptGuidelines: TOOL_GUIDELINES,
290
+ executionMode: "sequential",
291
+ parameters: { type: "object", additionalProperties: false, properties: {} },
292
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
293
+ const fail = failFor(ctx, "ready");
294
+ const outcome = await readyChange(readyDepsFor(pi, ctx, gating));
295
+ if (outcome.kind === "failed") return fail(outcome.message, outcome.errorType);
296
+ const result = ok(renderReadyMessage(outcome.facts), readyDetails(outcome.facts), {
297
+ terminate: true,
298
+ });
299
+ await driveReadyContinuation(pi, ctx, outcome);
300
+ return result;
301
+ },
302
+ });
303
+
304
+ registerPerkCommand(pi, "ready", {
305
+ description:
306
+ "Ready the plan's PR: open the draft for review (incremental) or record the " +
307
+ "post-review handoff stamp (stacked).",
308
+ handler: async (_args, ctx) => {
309
+ const fail = failFor(ctx, "ready");
310
+ const outcome = await readyChange(readyDepsFor(pi, ctx, gating));
311
+ // Failure is reported loudly via failFor (the single error surface) — success only.
312
+ if (outcome.kind === "failed") {
313
+ fail(outcome.message, outcome.errorType);
314
+ return;
315
+ }
316
+ // Report-before-drive (load-bearing order): the success line lands before the injected
317
+ // reconcile turn.
318
+ report(ctx, "ready", "info", renderReadyMessage(outcome.facts));
319
+ await driveReadyContinuation(pi, ctx, outcome);
320
+ },
321
+ });
322
+ }