@mgiles/perk 3.0.0 → 3.2.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 (89) hide show
  1. package/extension/adapters/planAdapterPlannotator.ts +12 -9
  2. package/extension/doors/address.ts +11 -0
  3. package/extension/doors/commitCompact.ts +98 -10
  4. package/extension/doors/draftReviewWaveTools.ts +43 -15
  5. package/extension/doors/dreamWaveTools.ts +489 -0
  6. package/extension/doors/land.ts +6 -0
  7. package/extension/doors/learn.ts +16 -3
  8. package/extension/doors/lifecycleGates.ts +36 -1
  9. package/extension/doors/objectiveReviewBrowser.ts +36 -13
  10. package/extension/doors/objectiveStack.ts +424 -24
  11. package/extension/doors/planReviewBrowser.ts +30 -8
  12. package/extension/doors/plannotatorHandoff.ts +80 -8
  13. package/extension/doors/prReview.ts +158 -50
  14. package/extension/doors/prReviewBrowser.ts +75 -27
  15. package/extension/doors/prReviewDynamic.ts +33 -13
  16. package/extension/doors/ready.ts +209 -17
  17. package/extension/doors/reviewWaveTools.ts +61 -17
  18. package/extension/doors/stackReviewBrowser.ts +573 -0
  19. package/extension/doors/submit.ts +36 -10
  20. package/extension/doors/submitPrReview.ts +116 -19
  21. package/extension/factories/objectiveDraft.ts +95 -27
  22. package/extension/factories/objectiveDreamReport.ts +347 -0
  23. package/extension/factories/objectivePlan.ts +12 -6
  24. package/extension/factories/objectiveSave.ts +77 -1
  25. package/extension/factories/planReview.ts +173 -10
  26. package/extension/index.ts +88 -16
  27. package/extension/substrate/agentScratch.ts +171 -0
  28. package/extension/substrate/bindingDelivery.ts +9 -11
  29. package/extension/substrate/cache.ts +92 -2
  30. package/extension/substrate/command.ts +9 -6
  31. package/extension/substrate/config.ts +10 -3
  32. package/extension/substrate/git.ts +85 -2
  33. package/extension/substrate/paths.ts +2 -7
  34. package/extension/substrate/resolverLease.ts +363 -0
  35. package/extension/substrate/result.ts +3 -2
  36. package/extension/substrate/sessionData.ts +6 -4
  37. package/extension/substrate/sessionPointers.ts +3 -4
  38. package/extension/substrate/toolGating.ts +25 -0
  39. package/extension/substrate/workflowState.ts +57 -5
  40. package/extension/surfaces/report.ts +38 -12
  41. package/extension/surfaces/surfaces.ts +129 -7
  42. package/extension/vendor/btw/btw.ts +38 -6
  43. package/extension/waves/adversarialReviewWave.ts +34 -3
  44. package/extension/waves/draftReviewWave.ts +17 -1
  45. package/extension/waves/dreamReducerWave.ts +700 -0
  46. package/extension/waves/dreamReport.ts +1494 -0
  47. package/extension/waves/dreamWave.ts +927 -0
  48. package/extension/waves/harvestWave.ts +1 -1
  49. package/extension/waves/ponytail.ts +104 -0
  50. package/extension/waves/prReviewDynamicWave.ts +115 -34
  51. package/extension/waves/prReviewWave.ts +122 -17
  52. package/extension/waves/reportWave.ts +103 -7
  53. package/extension/worker/readOnlySession.ts +2 -3
  54. package/package.json +6 -3
  55. package/prompts/_fixtures/live.yaml +112 -0
  56. package/prompts/commit-and-compact-continuation.md +13 -0
  57. package/prompts/contexts/adapters/plannotator-objective.md +7 -1
  58. package/prompts/contexts/adapters/plannotator-plan.md +7 -1
  59. package/prompts/contexts/adapters/tombell-plan.md +4 -0
  60. package/prompts/contexts/plan-authoring.md +6 -5
  61. package/prompts/stages/conflict-resolution-continuation.md +6 -0
  62. package/prompts/stages/conflict-resolution.md +2 -2
  63. package/prompts/stages/learn-dream.md +10 -0
  64. package/prompts/stages/objective-author/adopt.md +1 -1
  65. package/prompts/stages/objective-author/file.md +1 -1
  66. package/prompts/stages/objective-author/seed.md +1 -1
  67. package/prompts/stages/objective-reconcile-ready.md +7 -0
  68. package/prompts/stages/objective-review-browser.md +1 -1
  69. package/prompts/stages/objective-sync.md +1 -1
  70. package/prompts/stages/plan-review-browser.md +1 -1
  71. package/prompts/stages/pr-review-browser/active.md +1 -1
  72. package/prompts/stages/pr-review-browser/foreign.md +1 -1
  73. package/prompts/stages/pr-review-dynamic.md +5 -5
  74. package/prompts/stages/pr-review-terminal/active.md +1 -1
  75. package/prompts/stages/pr-review-terminal/foreign.md +1 -1
  76. package/prompts/stages/pr-review-terminal/local.md +1 -1
  77. package/prompts/stages/pr-review.md +5 -5
  78. package/prompts/stages/stack-review/cold.md +1 -0
  79. package/prompts/stages/stack-review-browser/stack.md +23 -0
  80. package/shared/README.md +0 -3
  81. package/shared/bindings.yaml +6 -0
  82. package/shared/contracts.md +3784 -1851
  83. package/shared/registry.yaml +28 -13
  84. package/shared/schemas/inputs/review-post-batch.schema.json +14 -1
  85. package/shared/schemas/outputs/objective-doctor.schema.json +39 -1
  86. package/shared/schemas/outputs/objective-stack-status.schema.json +172 -1
  87. package/shared/schemas/outputs/pr-land.schema.json +3 -3
  88. package/shared/schemas/outputs/pr-ready.schema.json +110 -2
  89. package/shared/contracts-history.md +0 -605
@@ -0,0 +1,347 @@
1
+ // The `dream_report` gate for the objective draft/review/save path (contracts.md §8.63).
2
+ //
3
+ // ONE resolver implements the whole gate matrix — `writeObjectiveDraft` (objectiveDraft.ts)
4
+ // and `saveObjective` (objectiveSave.ts) both consume its typed outcome, so no parallel
5
+ // branch/message implementation can drift. "Dream session" is detected structurally, exactly
6
+ // like `run_dream_wave` (doors/dreamWaveTools.ts): the session's claimed `run_id` plus the
7
+ // existence of the run-scoped dream manifest (no claimed run counts as non-dream). The gate is
8
+ // fail-closed in BOTH directions: a dream session refuses a report-less objective (the
9
+ // objective and its report review as ONE bundle — an approval is always savable), and a
10
+ // `dream_report` outside a dream session refuses rather than being silently dropped. Absence
11
+ // on a non-dream path is byte-identical no-op behavior.
12
+ //
13
+ // Trusted-context recovery follows the session-artifacts digest-pointer doctrine: the bare
14
+ // run-scratch bundle is never trusted — the `dream_bundle_digest` workflow-state marker
15
+ // (cleared at wave entry, set to the finalized bytes' digest after a successful finalize) is
16
+ // the freshness/integrity authority, and the bundle is strictly re-decoded through
17
+ // `decodeFinalizedDreamBundle` on every recovery read (untrusted-at-rest posture). After a
18
+ // successful recovery the revalidation bracket (contracts.md §8.65) re-proves HEAD-unchanged +
19
+ // tree-clean against the manifest's stamped `commit_sha` — at draft-write AND save, since both
20
+ // consumers flow through the one resolver; drift refuses `bad_state` (the analysis is stale).
21
+ //
22
+ // Imports only the dream wave siblings, the substrate seams, and node builtins — cycle-free
23
+ // (nothing in `waves/` imports factories) and loadable under `node --test`.
24
+
25
+ import { existsSync, readFileSync } from "node:fs";
26
+ import { dirname, join } from "node:path";
27
+ import { runScratchDir } from "../substrate/cache.ts";
28
+ import { revalidationBracket } from "../substrate/git.ts";
29
+ import { digestSessionData, type SessionDataCtx } from "../substrate/sessionData.ts";
30
+ import { branchOf, rebuildWorkflowState, type WorkflowState } from "../substrate/workflowState.ts";
31
+ import { DREAM_ANALYSES_FILENAME, decodeFinalizedDreamBundle } from "../waves/dreamReducerWave.ts";
32
+ import { buildDreamReport, type DreamReportContext } from "../waves/dreamReport.ts";
33
+ import {
34
+ codePointLength,
35
+ DREAM_MANIFEST_FILENAME,
36
+ decodeDreamManifest,
37
+ } from "../waves/dreamWave.ts";
38
+
39
+ /**
40
+ * The shared part-invariance + size rule's comment-body cap (contracts §8.64) — the full
41
+ * rendered companion comment (marker + blank line + part) must fit with margin under GitHub's
42
+ * 65,536-char issue-comment limit. The Python twin is
43
+ * `perk.learn.dream_companion.COMPANION_COMMENT_MAX_CHARS` (parity-pinned fixtures).
44
+ */
45
+ export const COMPANION_COMMENT_MAX_CHARS = 65_000;
46
+
47
+ // The invariance shapes (the exact shapes the Linear transcoder `to_linear_markdown`
48
+ // rewrites/drops — derived locally by the same rule, mirroring the Python twin).
49
+ const MARKER_TEXT = "perk:learn-dream-report";
50
+ const PERK_HTML_MARKER_RE = /<!--\s*\/?perk:[^>]+?\s*-->/;
51
+ const DETAILS_OPEN_RE = /^<details><summary><code>[^<]*<\/code><\/summary>$/;
52
+ const DETAILS_CLOSE = "</details>";
53
+ // Every line boundary Python's `str.splitlines()` recognizes EXCEPT `\n` — the Linear
54
+ // transcoder splits on all of them and rejoins with `\n`, so any other boundary form would be
55
+ // normalized in the stored body and defeat the persistence-side byte comparison forever.
56
+ const NON_CANONICAL_LINE_BOUNDARIES = [
57
+ "\r",
58
+ "\v",
59
+ "\f",
60
+ "\u001c",
61
+ "\u001d",
62
+ "\u001e",
63
+ "\u0085",
64
+ "\u2028",
65
+ "\u2029",
66
+ ];
67
+
68
+ /**
69
+ * The TS mirror of Python's `validate_report_parts` (contracts §8.64) — run over the freshly
70
+ * rendered parts at draft-write AND save (both flow through `resolveDreamReportGate`), so an
71
+ * approved report is always Python-savable: no empty/blank part, no perk HTML-comment marker,
72
+ * no literal companion marker text, no perk-rendered `<details>` wrapper line (the shapes the
73
+ * Linear transcoder rewrites/drops — transcode-invariance keeps the persistence-side
74
+ * dual-candidate byte comparison exact), and every rendered comment body (marker + blank line +
75
+ * part) within `COMPANION_COMMENT_MAX_CHARS` code points. Returns named violations (`[]` =
76
+ * valid). Parity-pinned against the Python twin by the shared fixture set.
77
+ */
78
+ export function reportPartInvarianceViolations(parts: string[], runId: string): string[] {
79
+ const violations: string[] = [];
80
+ parts.forEach((part, i) => {
81
+ const index = i + 1;
82
+ const where = `part ${index}`;
83
+ if (part.trim() === "") {
84
+ violations.push(`${where}: empty part`);
85
+ return;
86
+ }
87
+ if (part.includes(MARKER_TEXT)) {
88
+ violations.push(`${where}: carries the literal '${MARKER_TEXT}' marker text`);
89
+ }
90
+ if (PERK_HTML_MARKER_RE.test(part)) {
91
+ violations.push(
92
+ `${where}: carries a perk HTML-comment marker (<!-- perk:… --> is rewritten by the ` +
93
+ "Linear transcoder)",
94
+ );
95
+ }
96
+ if (
97
+ part.split(/\r\n|\r|\n/).some((line) => DETAILS_OPEN_RE.test(line) || line === DETAILS_CLOSE)
98
+ ) {
99
+ violations.push(
100
+ `${where}: carries a perk-rendered <details> wrapper line (dropped by the Linear ` +
101
+ "transcoder)",
102
+ );
103
+ }
104
+ if (NON_CANONICAL_LINE_BOUNDARIES.some((boundary) => part.includes(boundary))) {
105
+ violations.push(
106
+ `${where}: carries a line boundary other than \\n (normalized by the Linear transcoder)`,
107
+ );
108
+ }
109
+ const bodyLength = codePointLength(`<!-- ${MARKER_TEXT}:${runId}:${index} -->\n\n${part}`);
110
+ if (bodyLength > COMPANION_COMMENT_MAX_CHARS) {
111
+ violations.push(
112
+ `${where}: rendered comment body is ${bodyLength} chars (cap ${COMPANION_COMMENT_MAX_CHARS})`,
113
+ );
114
+ }
115
+ });
116
+ if (parts.length === 0) violations.push("parts: empty list");
117
+ return violations;
118
+ }
119
+
120
+ /**
121
+ * The `dream_report` block the objective-draft artifact carries (tool-written only — the model
122
+ * never writes the artifact): the validated model input, the ONE `generated_at` stamp that
123
+ * keeps re-rendering deterministic across review and save, and the stored CANONICAL parts the
124
+ * review surface renders and the save byte-compares.
125
+ */
126
+ export interface ObjectiveDreamReportBlock {
127
+ input: unknown;
128
+ generated_at: string;
129
+ parts: string[];
130
+ }
131
+
132
+ function isRecord(value: unknown): value is Record<string, unknown> {
133
+ return typeof value === "object" && value !== null && !Array.isArray(value);
134
+ }
135
+
136
+ /**
137
+ * The artifact-side shape check `readObjectiveDraft` uses: a plain object carrying a
138
+ * plain-object `input`, a non-blank string `generated_at`, and a non-empty all-string `parts`.
139
+ * Deep validation stays with the resolver (the save re-runs the full gate); `null` = malformed.
140
+ */
141
+ export function decodeDreamReportBlock(value: unknown): ObjectiveDreamReportBlock | null {
142
+ if (!isRecord(value)) return null;
143
+ if (!isRecord(value.input)) return null;
144
+ if (typeof value.generated_at !== "string" || !value.generated_at.trim()) return null;
145
+ if (!Array.isArray(value.parts) || value.parts.length === 0) return null;
146
+ const parts: string[] = [];
147
+ for (const part of value.parts) {
148
+ if (typeof part !== "string") return null;
149
+ parts.push(part);
150
+ }
151
+ return { input: value.input, generated_at: value.generated_at, parts };
152
+ }
153
+
154
+ /**
155
+ * Recover the trusted `DreamReportContext` from the claimed run's scratch state — every arm
156
+ * fail-closed with a named detail (the caller maps them to `bad_state`):
157
+ *
158
+ * 1. the run-scoped manifest: read + parse + `decodeDreamManifest` (the strict §8.60 decoder,
159
+ * path bound at decode time). No `verifyDocContainment` — this path reads no doc files;
160
+ * the lexical decode suffices (resolved containment is the wave tool's pre-spawn concern);
161
+ * 2. the freshness check: the `dream_bundle_digest` marker (read from the caller's ONE
162
+ * workflow-state snapshot) must be present, non-empty, and equal the digest of the bundle
163
+ * bytes just read — a bare file is never trusted (missing marker = no finalized wave;
164
+ * empty = invalidated by a newer attempt, including a cleanup-failure residue; mismatch =
165
+ * stale/tampered bytes);
166
+ * 3. the strict finalized decode (`decodeFinalizedDreamBundle` over the digest of the
167
+ * manifest bytes just read — the marker authenticates the bundle bytes and the bundle's
168
+ * `manifest_digest` extends that authority to the manifest, so an at-rest manifest edit
169
+ * refuses; the analyses-only mid-wave shape refuses here too).
170
+ */
171
+ function recoverDreamReportContext(
172
+ ctx: SessionDataCtx,
173
+ runId: string,
174
+ marker: string | undefined,
175
+ generatedAt: string,
176
+ ): { ok: true; context: DreamReportContext } | { ok: false; detail: string } {
177
+ const manifestPath = join(runScratchDir(ctx.cwd, runId), DREAM_MANIFEST_FILENAME);
178
+ let manifestBytes: string;
179
+ let rawManifest: unknown;
180
+ try {
181
+ manifestBytes = readFileSync(manifestPath, "utf8");
182
+ rawManifest = JSON.parse(manifestBytes);
183
+ } catch (error) {
184
+ const detail = error instanceof Error ? error.message : String(error);
185
+ return { ok: false, detail: `dream manifest unreadable at '${manifestPath}': ${detail}` };
186
+ }
187
+ const manifest = decodeDreamManifest(rawManifest, manifestPath);
188
+ if (!manifest.ok) {
189
+ return { ok: false, detail: `dream manifest invalid: ${manifest.detail}` };
190
+ }
191
+
192
+ const bundlePath = join(dirname(manifestPath), DREAM_ANALYSES_FILENAME);
193
+ let bundleBytes: string;
194
+ try {
195
+ bundleBytes = readFileSync(bundlePath, "utf8");
196
+ } catch {
197
+ return {
198
+ ok: false,
199
+ detail: `no dream bundle at '${bundlePath}' — re-run the dream wave`,
200
+ };
201
+ }
202
+ if (marker === undefined || marker === "") {
203
+ return {
204
+ ok: false,
205
+ detail: "no finalized dream wave for this session — re-run the dream wave",
206
+ };
207
+ }
208
+ if (digestSessionData(bundleBytes) !== marker) {
209
+ return {
210
+ ok: false,
211
+ detail:
212
+ "the dream bundle does not match the session's finalized digest — re-run the dream wave",
213
+ };
214
+ }
215
+ let rawBundle: unknown;
216
+ try {
217
+ rawBundle = JSON.parse(bundleBytes);
218
+ } catch (error) {
219
+ const detail = error instanceof Error ? error.message : String(error);
220
+ return { ok: false, detail: `dream bundle is not valid JSON: ${detail}` };
221
+ }
222
+ const decoded = decodeFinalizedDreamBundle(
223
+ rawBundle,
224
+ manifest.manifest,
225
+ digestSessionData(manifestBytes),
226
+ );
227
+ if (!decoded.ok) {
228
+ return { ok: false, detail: `${decoded.detail} — re-run the dream wave` };
229
+ }
230
+ return {
231
+ ok: true,
232
+ context: {
233
+ manifest: manifest.manifest,
234
+ analyses: decoded.analyses,
235
+ reducers: decoded.reducers,
236
+ run_id: runId,
237
+ generated_at: generatedAt,
238
+ },
239
+ };
240
+ }
241
+
242
+ /** The typed gate outcome both consumers branch on — the whole matrix, one vocabulary. */
243
+ export type DreamReportGateOutcome =
244
+ | { kind: "absent" }
245
+ | { kind: "block"; block: ObjectiveDreamReportBlock }
246
+ | { kind: "refuse"; errorType: "invalid_input" | "bad_state"; detail: string };
247
+
248
+ /**
249
+ * The ONE gate resolver (contracts §8.63) — identical at draft-write and save. `input` is the
250
+ * model-supplied `dream_report` value, or `undefined` for "no dream_report" (callers pass the
251
+ * value only when present; an `{input: undefined}` carrier is never constructed). The matrix:
252
+ *
253
+ * | session | `dream_report` | outcome |
254
+ * | --------- | -------------- | ------- |
255
+ * | non-dream | absent | `absent` — unchanged, byte-identical behavior |
256
+ * | non-dream | present | refuse `invalid_input` (never silently dropped) |
257
+ * | dream | absent | refuse `invalid_input` (one approval bundle) |
258
+ * | dream | present | recover context → `buildDreamReport` → refuse or `block` |
259
+ *
260
+ * An UNREADABLE workflow state (a throwing branch read) refuses `bad_state` BEFORE the matrix —
261
+ * it is never conflated with a confirmed non-dream session (the `activeSessionRunId`
262
+ * null-on-throw sentinel would otherwise let a transient read failure surface as `absent`).
263
+ *
264
+ * Failure taxonomy: gate violations + `buildDreamReport` refusals → `invalid_input` (the
265
+ * bounded ≤25 named details newline-joined); an unreadable workflow state and
266
+ * context-recovery failures → `bad_state`.
267
+ */
268
+ export function resolveDreamReportGate(
269
+ ctx: SessionDataCtx,
270
+ input: unknown,
271
+ generatedAt: string,
272
+ bracket: (
273
+ cwd: string,
274
+ expectedSha: string,
275
+ ) => { ok: boolean; detail: string | null } = revalidationBracket,
276
+ ): DreamReportGateOutcome {
277
+ // ONE workflow-state snapshot for the whole gate (run identity + the freshness marker),
278
+ // read with error distinction: unreadable state fails closed, never "non-dream".
279
+ let state: WorkflowState;
280
+ try {
281
+ state = rebuildWorkflowState(branchOf(ctx));
282
+ } catch (error) {
283
+ const detail = error instanceof Error ? error.message : String(error);
284
+ return {
285
+ kind: "refuse",
286
+ errorType: "bad_state",
287
+ detail: `session workflow state is unreadable — cannot resolve the dream_report gate: ${detail}`,
288
+ };
289
+ }
290
+ const runId = typeof state.run_id === "string" && state.run_id.length > 0 ? state.run_id : null;
291
+ const dream =
292
+ runId !== null && existsSync(join(runScratchDir(ctx.cwd, runId), DREAM_MANIFEST_FILENAME));
293
+ if (runId === null || !dream) {
294
+ if (input === undefined) return { kind: "absent" };
295
+ return {
296
+ kind: "refuse",
297
+ errorType: "invalid_input",
298
+ detail:
299
+ "dream_report is only valid inside a perk learn dream session — refusing rather than " +
300
+ "silently dropping it",
301
+ };
302
+ }
303
+ if (input === undefined) {
304
+ return {
305
+ kind: "refuse",
306
+ errorType: "invalid_input",
307
+ detail:
308
+ "this dream session's objective must carry dream_report — the objective and its " +
309
+ "report review as one bundle",
310
+ };
311
+ }
312
+ const recovered = recoverDreamReportContext(ctx, runId, state.dream_bundle_digest, generatedAt);
313
+ if (!recovered.ok) {
314
+ return { kind: "refuse", errorType: "bad_state", detail: recovered.detail };
315
+ }
316
+ // The revalidation-bracket re-check (contracts.md §8.65): the manifest — with its stamped
317
+ // commit_sha — is now decoded and authenticated, so re-prove HEAD-unchanged + tree-clean
318
+ // against it. Both `writeObjectiveDraft` and `saveObjective` flow through this resolver, so
319
+ // the bracket re-fires at draft-write AND save; non-dream paths never reach it (the matrix
320
+ // above returned already). The parameter default is the production bracket — tests inject
321
+ // stubs.
322
+ const drift = bracket(ctx.cwd, recovered.context.manifest.commit_sha);
323
+ if (!drift.ok) {
324
+ return {
325
+ kind: "refuse",
326
+ errorType: "bad_state",
327
+ detail:
328
+ `the repository moved since the dream snapshot (${drift.detail}) — the analysis is ` +
329
+ "stale; re-run perk learn dream",
330
+ };
331
+ }
332
+ const built = buildDreamReport(input, recovered.context);
333
+ if (!built.ok) {
334
+ return { kind: "refuse", errorType: "invalid_input", detail: built.details.join("\n") };
335
+ }
336
+ // The §8.64 invariance mirror, at draft-write AND save (this resolver is both), so an
337
+ // approved report is always savable by the Python door's identical rule.
338
+ const violations = reportPartInvarianceViolations(built.parts, runId);
339
+ if (violations.length > 0) {
340
+ return {
341
+ kind: "refuse",
342
+ errorType: "invalid_input",
343
+ detail: `dream report parts violate the invariance rule: ${violations.join("; ")}`,
344
+ };
345
+ }
346
+ return { kind: "block", block: { input, generated_at: generatedAt, parts: built.parts } };
347
+ }
@@ -631,9 +631,10 @@ export function objectiveReadInstruction(
631
631
  * Fetch the objective's URL via `perk objective show <id> --json` (reading `objective.url`).
632
632
  * Lenient: returns "" on any failure / missing url — never throws (the seed prompt's step-1
633
633
  * `perk objective show <id>` step surfaces the URL anyway). Only called for the linear backend
634
- * (github needs no clause → no fetch).
634
+ * (github needs no clause → no fetch). Exported for the warm drives that compose the same
635
+ * backend-aware read clause (the ready-time reconcile drive in `doors/ready.ts`).
635
636
  */
636
- async function fetchObjectiveUrl(
637
+ export async function fetchObjectiveUrl(
637
638
  pi: ExtensionAPI,
638
639
  ctx: ExtensionContext,
639
640
  objectiveId: string,
@@ -680,7 +681,7 @@ export function reconcileGuidance(objective: string, backend = "github", url = "
680
681
  }
681
682
 
682
683
  const RECONCILE_TOOL_GUIDELINES = [
683
- "Call reconcile_objective only to rewrite the objective's Reconcilable prose region after a PR merged — the roadmap table and Immutable notes are never touched.",
684
+ "Call reconcile_objective only to rewrite the objective's Reconcilable prose region after a PR merged or after a stacked ready stamp (the ready-time pass) — the roadmap table and Immutable notes are never touched.",
684
685
  "Pass reconcile_objective the FULL replacement prose; it overwrites the marker-bounded Reconcilable region wholesale.",
685
686
  "Judgment + durable writes stay with you; skip reconcile_objective when nothing is stale (do not churn).",
686
687
  ];
@@ -688,6 +689,7 @@ const RECONCILE_TOOL_GUIDELINES = [
688
689
  const ADD_NODE_TOOL_GUIDELINES = [
689
690
  "Use add_objective_node SPARINGLY — only during reconciliation, when a genuine new unit of work emerged that wasn't planned: a deferred follow-up the PR flagged, an uncovered defect/gap, a missing prerequisite for a later node, or human-requested work from the engagement block.",
690
691
  "add_objective_node is only for genuinely-new, unplanned work — never to restate, rename, or re-scope an existing node (use objective_node's `description` for that).",
692
+ "Stacked objectives accept guarded `pending` tail-appends only — a refusal means the discovery is structural: route it to `perk objective replan`.",
691
693
  "Judgment + durable writes stay with you; add_objective_node delegates the write to the canonical Python plane.",
692
694
  ];
693
695
 
@@ -820,9 +822,13 @@ export function registerObjectivePlan(pi: ExtensionAPI, gating: ToolGating): voi
820
822
  label: "Reconcile objective prose",
821
823
  description:
822
824
  "Rewrite the objective's Reconcilable prose region (the marker-bounded prose in the " +
823
- "objective body) to reconcile it against a merged PR. The Mechanical roadmap table and any " +
824
- "Immutable notes are NEVER touched. Delegates the write to the perk cold door.",
825
- promptSnippet: "Reconcile the objective's Reconcilable prose region against the merged diff",
825
+ "objective body) to reconcile it against the pass's evidence a merged PR (post-land) or " +
826
+ "a stacked layer's pinned accepted diff range (the ready-time pass). The Mechanical " +
827
+ "roadmap table and any Immutable notes are NEVER touched. Delegates the write to the perk " +
828
+ "cold door.",
829
+ promptSnippet:
830
+ "Reconcile the objective's Reconcilable prose region against the pass's evidence " +
831
+ "(merged diff, or the ready-time pinned accepted range)",
826
832
  promptGuidelines: RECONCILE_TOOL_GUIDELINES,
827
833
  executionMode: "sequential",
828
834
  parameters: {
@@ -15,8 +15,10 @@
15
15
  // FAILSAFE invocation of the same seam, keeping the legacy drive-the-session behavior as the
16
16
  // no-draft fallback (objectives have no transcript scrape by design).
17
17
 
18
+ import { join } from "node:path";
18
19
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
19
20
  import { bindingSuffix } from "../substrate/bindingDelivery.ts";
21
+ import { atomicWriteFileSync, ensureRunScratch } from "../substrate/cache.ts";
20
22
  import {
21
23
  booleanField,
22
24
  type ColdJson,
@@ -34,14 +36,24 @@ import { OBJECTIVE_BUDGET_TYPE } from "./objective.ts";
34
36
  import {
35
37
  DELIVERY_PARAM_SCHEMA,
36
38
  type DeliveryChoice,
39
+ DREAM_REPORT_PARAM_SCHEMA,
37
40
  decodeObjectiveSaveParams,
38
41
  ROADMAP_PARAM_SCHEMA,
39
42
  readObjectiveDraft,
40
43
  } from "./objectiveDraft.ts";
44
+ import { resolveDreamReportGate } from "./objectiveDreamReport.ts";
41
45
 
42
46
  /** The `objective-save` registry stage id (the objectiveAuthor.ts constant's sibling). */
43
47
  export const OBJECTIVE_SAVE_STAGE = "objective-save";
44
48
 
49
+ /**
50
+ * The run-scoped dream-report transfer filename (contracts §8.64) — the extension→door handoff
51
+ * carrying the reviewed CANONICAL parts. The Python mirror is
52
+ * `perk.learn.dream_companion.DREAM_REPORT_TRANSFER_FILENAME` (parity-pinned by test), beside
53
+ * the existing `DREAM_MANIFEST_FILENAME` mirror pair.
54
+ */
55
+ export const DREAM_REPORT_TRANSFER_FILENAME = "dream-report-transfer.json";
56
+
45
57
  /** The ok-arm fields — the structured `details` surface doubles as branch-safe persisted state. */
46
58
  export interface ObjectiveSaveOk {
47
59
  /** `id` is the opaque string objective id (GitHub "7", Linear "ENG-7") — §8.21. */
@@ -70,6 +82,16 @@ function decodeObjectiveCreate(payload: ColdJson): ObjectiveCreatePayload | null
70
82
  * The single save implementation both surfaces call. Delegates the GitHub write to the Python cold
71
83
  * door, then links the live session (`active_objective` + budget marker). Returns a soft result
72
84
  * (never throws); failures set `details.ok = false` and append no linkage.
85
+ *
86
+ * `dream_report` is ONE carrier with two sources (§8.63): the direct tool path supplies
87
+ * `{input}` and the save stamps `generated_at`; the approval path passes the artifact block
88
+ * through with its stored stamp AND stored parts — the stored parts are byte-compared against
89
+ * the freshly re-rendered ones, so run-scratch drift or artifact tamper between draft-write
90
+ * and save refuses `bad_state` (nothing saved, the gate stays on). On the dream arm the
91
+ * reviewed CANONICAL parts cross to the Python plane through the run-scoped
92
+ * `dream-report-transfer.json` handoff (§8.64) — staged atomically before the cold door (a
93
+ * write failure is the soft `scratch_failed` refusal; the door is not invoked) — and
94
+ * `perk objective create` re-validates the transfer and converges the companion idempotently.
73
95
  */
74
96
  export async function saveObjective(
75
97
  pi: ExtensionAPI,
@@ -80,6 +102,7 @@ export async function saveObjective(
80
102
  roadmap?: unknown[];
81
103
  base?: string;
82
104
  delivery?: DeliveryChoice;
105
+ dream_report?: { input: unknown; generated_at?: string; parts?: string[] };
83
106
  },
84
107
  ): Promise<ObjectiveSaveResult> {
85
108
  const fail = failFor(ctx, "objective-save");
@@ -91,9 +114,52 @@ export async function saveObjective(
91
114
  return fail("roadmap must be a JSON array of nodes", "invalid_input");
92
115
  }
93
116
 
117
+ // The §8.63 fail-closed re-validation, before anything reaches the cold door. Presence is
118
+ // the `opts.dream_report === undefined` boundary — an `{input: undefined}` carrier is never
119
+ // constructed (the execute wraps only a present decoded value; the approval path passes the
120
+ // validated artifact block).
121
+ const generatedAt = opts.dream_report?.generated_at ?? new Date().toISOString();
122
+ const gate =
123
+ opts.dream_report === undefined
124
+ ? resolveDreamReportGate(ctx, undefined, generatedAt)
125
+ : resolveDreamReportGate(ctx, opts.dream_report.input, generatedAt);
126
+ if (gate.kind === "refuse") {
127
+ return fail(gate.detail, gate.errorType);
128
+ }
129
+ if (gate.kind === "block" && opts.dream_report?.parts !== undefined) {
130
+ // The approval path: the reviewed (stored) parts must byte-match the re-render against
131
+ // freshly recovered context — the same stored `generated_at` stamp keeps the comparison
132
+ // deterministic.
133
+ if (JSON.stringify(gate.block.parts) !== JSON.stringify(opts.dream_report.parts)) {
134
+ return fail(
135
+ "the reviewed report no longer matches the wave state — re-draft and re-review",
136
+ "bad_state",
137
+ );
138
+ }
139
+ }
140
+
94
141
  const branch = () => branchOf(ctx);
95
142
  const runId = rebuildWorkflowState(branch()).run_id ?? "";
96
143
 
144
+ if (gate.kind === "block") {
145
+ // The §8.64 transfer write (the dream arm only): the reviewed CANONICAL parts cross to the
146
+ // Python save door through the run-scoped scratch handoff — written atomically BEFORE the
147
+ // cold door is invoked. A write failure is the soft `scratch_failed` failure (the
148
+ // runColdDoor stdin-staging precedent): the cold door is NOT invoked, nothing activates,
149
+ // and the read-only gate stays on. Non-dream saves write nothing (byte-identical).
150
+ try {
151
+ const dir = ensureRunScratch(ctx.cwd, runId);
152
+ const content = `${JSON.stringify(
153
+ { schema_version: "1", run_id: runId, parts: gate.block.parts },
154
+ null,
155
+ 2,
156
+ )}\n`;
157
+ atomicWriteFileSync(join(dir, DREAM_REPORT_TRANSFER_FILENAME), content);
158
+ } catch (err) {
159
+ return fail(`could not stage the dream-report transfer: ${String(err)}`, "scratch_failed");
160
+ }
161
+ }
162
+
97
163
  const args = ["objective", "create", "--json"];
98
164
  if (opts.title) args.push("--title", opts.title);
99
165
  if (opts.base) args.push("--base", opts.base);
@@ -175,6 +241,9 @@ export async function objectiveApprovalSave(
175
241
  roadmap: draft.roadmap,
176
242
  base: draft.base,
177
243
  delivery: draft.delivery,
244
+ // The approval path passes the artifact block through whole — stored stamp + stored parts
245
+ // (the save re-validates and byte-compares, §8.63).
246
+ ...(draft.dream_report !== undefined ? { dream_report: draft.dream_report } : {}),
178
247
  });
179
248
  let gateExited = false;
180
249
  if (result.details.ok && wasReadOnly) {
@@ -231,6 +300,7 @@ export function registerObjectiveSave(pi: ExtensionAPI, gating: ToolGating): voi
231
300
  "Optional target branch for this objective's plans (omit to use the repo default).",
232
301
  },
233
302
  delivery: DELIVERY_PARAM_SCHEMA,
303
+ dream_report: DREAM_REPORT_PARAM_SCHEMA,
234
304
  roadmap: {
235
305
  type: "array",
236
306
  description:
@@ -251,7 +321,13 @@ export function registerObjectiveSave(pi: ExtensionAPI, gating: ToolGating): voi
251
321
  "bad_input",
252
322
  );
253
323
  }
254
- return saveObjective(pi, ctx, decoded);
324
+ // The direct tool path wraps ONLY a present decoded value as the `{input}` carrier (the
325
+ // save stamps generated_at); no stored parts, so no byte-compare on this path.
326
+ const { dream_report, ...rest } = decoded;
327
+ return saveObjective(pi, ctx, {
328
+ ...rest,
329
+ ...(dream_report !== undefined ? { dream_report: { input: dream_report } } : {}),
330
+ });
255
331
  },
256
332
  });
257
333