@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,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,15 +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
+ } 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";
24
28
 
25
29
  // Learn-consume skip reasons that are ordinary, not failures: non-factory plans carry no
26
30
  // `consumed_learn` (`no_consumed_learn`), and a dry run reports `dry_run`. Anything else surfaces.
27
31
  const BENIGN_LEARN_SKIPS = new Set(["no_consumed_learn", "dry_run"]);
28
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
+
29
39
  export interface ObjectiveLandUpdate {
30
40
  /** Opaque string objective id (GitHub "5", Linear "ENG-5") — §8.21. */
31
41
  id: string | null;
@@ -42,7 +52,7 @@ export interface LearnConsumeUpdate {
42
52
 
43
53
  /** The ok-arm fields — the structured `details` surface doubles as branch-safe persisted state.
44
54
  * `pending_learn` mirrors the cold envelope: `false` is the learn-docs exemption (no marker). */
45
- export interface LandOk {
55
+ interface LandOk {
46
56
  pr: { number: number; state: string };
47
57
  branch?: string;
48
58
  /** Opaque string plan-issue id (§8.21). */
@@ -52,8 +62,16 @@ export interface LandOk {
52
62
  learn?: LearnConsumeUpdate;
53
63
  }
54
64
 
55
- export type LandResult = Result<LandOk>;
56
- 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 };
57
75
 
58
76
  /** The decoded `perk pr land --json` payload — the cold door owns `pending_learn` (the
59
77
  * learn-docs-exemption decision point); decoded leniently so skew degrades to legacy. */
@@ -62,47 +80,59 @@ interface LandPayload {
62
80
  branch?: string;
63
81
  issue?: string;
64
82
  pending_learn: boolean;
65
- objective?: ObjectiveLandUpdate;
66
- learn?: LearnConsumeUpdate;
83
+ objective: AdvisoryDecode<ObjectiveLandUpdate>;
84
+ learn: AdvisoryDecode<LearnConsumeUpdate>;
67
85
  }
68
86
 
69
- /** Validate the optional `objective` sub-object; malformed undefined (advisory, never fatal). */
70
- 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" };
71
90
  const obj = objectField(payload, "objective");
72
- if (obj === undefined) return undefined;
91
+ if (obj === undefined) return { state: "malformed" };
73
92
  const id = obj.id;
74
- if (typeof id !== "string" && id !== null) return undefined;
93
+ if (typeof id !== "string" && id !== null) return { state: "malformed" };
75
94
  const nodesMarked = obj.nodes_marked;
76
95
  if (!Array.isArray(nodesMarked) || !nodesMarked.every((n) => typeof n === "string")) {
77
- return undefined;
96
+ return { state: "malformed" };
78
97
  }
79
98
  const skippedReason = nullableStringField(obj, "skipped_reason");
80
- if (skippedReason === undefined && obj.skipped_reason !== undefined) return undefined;
99
+ if (skippedReason === undefined && obj.skipped_reason !== undefined) {
100
+ return { state: "malformed" };
101
+ }
81
102
  // `closed` is an advisory display detail: decode leniently (missing/malformed → false) rather
82
103
  // than dropping the whole sub-object.
83
104
  return {
84
- id,
85
- nodes_marked: nodesMarked,
86
- skipped_reason: skippedReason ?? null,
87
- 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
+ },
88
112
  };
89
113
  }
90
114
 
91
- /** Validate the optional `learn` sub-object; malformed undefined (advisory, never fatal). */
92
- 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" };
93
118
  const learn = objectField(payload, "learn");
94
- if (learn === undefined) return undefined;
119
+ if (learn === undefined) return { state: "malformed" };
95
120
  const closed = learn.closed;
96
- 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
+ }
97
124
  const skippedReason = nullableStringField(learn, "skipped_reason");
98
- if (skippedReason === undefined && learn.skipped_reason !== undefined) return undefined;
99
- 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 } };
100
129
  }
101
130
 
102
131
  /**
103
132
  * Narrow the `perk pr land --json` success payload. Strict on `pr` (malformed → bad_output);
104
- * the optional `objective`/`learn` sub-objects are validated but dropped when malformed the
105
- * 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.
106
136
  */
107
137
  function decodeLand(payload: ColdJson): LandPayload | null {
108
138
  const pr = objectField(payload, "pr");
@@ -123,32 +153,58 @@ function decodeLand(payload: ColdJson): LandPayload | null {
123
153
  };
124
154
  }
125
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
+
126
166
  /**
127
- * The single land implementation both surfaces call. Delegates the merge to the Python cold door,
128
- * then mirrors the envelope's `pending_learn` (in-session path): marker + /learn nudge on the
129
- * 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
130
172
  * (never throws).
131
173
  */
132
- export async function landPr(pi: ExtensionAPI, ctx: ExtensionContext): Promise<LandResult> {
174
+ async function landPr(pi: ExtensionAPI, ctx: ExtensionContext): Promise<LandResult> {
133
175
  const fail = failFor(ctx, "land");
134
176
 
177
+ // Planning sessions never legitimately land — the first check, before any cold-door
178
+ // delegation (a positioned stacked planning session's cwd binding is the PREDECESSOR).
179
+ const planningRefusal = planningStageRefusal(ctx, "land");
180
+ if (planningRefusal !== null) return fail(planningRefusal, "planning_session");
181
+
135
182
  const r = await runColdDoor<LandPayload>(pi, ctx, ["pr", "land", "--json"], {
136
183
  label: "perk pr land",
137
184
  decode: decodeLand,
138
185
  });
139
186
  if (!r.ok) return fail(r.message, r.errorType);
140
187
 
141
- if (r.data.pending_learn) {
142
- // Set the semaphore for the in-session path (idempotent; the cold door also set it on disk).
143
- setMarker(ctx.cwd, PENDING_LEARN);
144
- }
145
-
146
188
  const lines = [
147
189
  r.data.pending_learn
148
190
  ? `Landed PR #${r.data.pr.number}; run /learn to release the worktree.`
149
191
  : `Landed PR #${r.data.pr.number}; learn-docs plan — no learn pass needed; the worktree is releasable.`,
150
192
  ];
151
- 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;
152
208
  if (obj?.nodes_marked.length && obj.id !== null) {
153
209
  // The reconcile pass is auto-driven after land (see driveReconcileAfterLand); just report it.
154
210
  lines.push(
@@ -159,7 +215,7 @@ export async function landPr(pi: ExtensionAPI, ctx: ExtensionContext): Promise<L
159
215
  if (obj?.closed && obj.id !== null) {
160
216
  lines.push(`Objective #${obj.id} complete — closed.`);
161
217
  }
162
- const learn = r.data.learn;
218
+ const learn = r.data.learn.state === "present" ? r.data.learn.value : undefined;
163
219
  if (learn?.closed.length) {
164
220
  // hop-2: the consumed perk:learn issues were closed + labelled perk:consolidated on land.
165
221
  lines.push(
@@ -173,21 +229,31 @@ export async function landPr(pi: ExtensionAPI, ctx: ExtensionContext): Promise<L
173
229
  if (learn?.skipped_reason && !BENIGN_LEARN_SKIPS.has(learn.skipped_reason)) {
174
230
  lines.push(`Warning: learn consume incomplete — ${learn.skipped_reason}.`);
175
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"));
176
236
 
177
- return ok(
178
- lines.join("\n"),
179
- { ...r.data, pending_learn: r.data.pending_learn },
180
- { terminate: true },
181
- );
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 });
182
246
  }
183
247
 
184
248
  /**
185
- * After a successful land that marked at least one objective node done, drive the session into the
186
- * reconcile pass by injecting the exact guidance `/objective-reconcile` injects (warm-door driving
187
- * pattern). The terminating `land` tool stays terminating — terminate only skips the *automatic*
188
- * follow-up LLM call, while a `followUp` user message is a separate deliberate new turn. Short-
189
- * circuits (sends nothing) unless the land succeeded with an objective node marked done — the exact
190
- * 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).
191
257
  */
192
258
  export function driveReconcileAfterLand(
193
259
  pi: ExtensionAPI,
@@ -197,6 +263,7 @@ export function driveReconcileAfterLand(
197
263
  if (!details.ok) return;
198
264
  const obj = details.objective;
199
265
  if (!obj || obj.id === null || obj.nodes_marked.length === 0) return;
266
+ if (!OBJECTIVE_ID_RE.test(obj.id)) return;
200
267
  const message = reconcileGuidance(obj.id) + bindingSuffix(ctx.cwd, "command:objective-reconcile");
201
268
  if (ctx.isIdle()) {
202
269
  // The `/land` command path (idle): inject an immediate turn.
@@ -213,8 +280,9 @@ const TOOL_GUIDELINES = [
213
280
  "land refuses a stacked-delivery plan (`delivery_lineage`): stacked layers land as one atomic train, never individually.",
214
281
  ];
215
282
 
216
- /** Register the warm door: the `land` terminating tool + the `/land` command twin. */
217
- 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 {
218
286
  pi.registerTool({
219
287
  name: "land",
220
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
+ }