@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
@@ -1,18 +1,22 @@
1
- // The warm `submit_pr_review` tool — the agent-driven curated-posting surface shared by the two
2
- // PR-review doors (`/pr-review-terminal`, `/pr-review-browser`).
1
+ // The warm `submit_pr_review` tool — the agent-driven curated-posting surface shared by the
2
+ // PR-review doors (`/pr-review-terminal`, `/pr-review-browser`, `/stack-review-browser`).
3
3
  //
4
4
  // `submit_pr_review` implements the per-door posting contract (contracts §8.4): nothing perk-
5
5
  // driven reaches GitHub before the human triage; ALL perk-side posting flows through this tool
6
6
  // on every door (`gh` mutations and direct `perk pr review-submit` calls are forbidden); the
7
7
  // verdict lands last, atomically with the comments. On the terminal door this tool is the sole
8
- // posting path. On the browser door the human platform-posts from the plannotator UI — that
9
- // native posting IS the GitHub path; perk composes nothing by default and posts only what the
10
- // human explicitly hands it (typically a request-changes verdict, which the UI cannot post). It
11
- // delegates to the Python cold door (`perk pr review-submit` mutations canonical in Python)
12
- // via `runColdDoor` (the batch rides the run-scratch stdin channel), then appends `last_review`
13
- // to `perk:workflow-state`. The human gate splits: explicit conversational go-ahead ALWAYS
14
- // (pinned in the guidelines/skill); formal events (`approve`/`request-changes`) additionally get
15
- // the structural gate headless refuses, interactive raises a blocking `ctx.ui.confirm`.
8
+ // posting path. On the single-PR browser door the human platform-posts from the plannotator
9
+ // UI — that native posting IS the GitHub path; perk composes nothing by default and posts only
10
+ // what the human explicitly hands it (typically a request-changes verdict, which the UI cannot
11
+ // post). On the stack door the local-diff session has NO attached PR, so ALL posting is
12
+ // perk-side after triage: one real call per member PR, bottom→top, each under its own dry-run
13
+ // anchor validation. It delegates to the Python cold door (`perk pr review-submit` — mutations
14
+ // canonical in Python) via `runColdDoor` (the batch rides the run-scratch stdin channel), then
15
+ // appends `last_review` AND the accumulating `review_posts` ledger row to `perk:workflow-state`
16
+ // (the stack flow's resume authority — posted PRs are skipped, never replayed). The human gate
17
+ // splits: explicit conversational go-ahead ALWAYS (pinned in the guidelines/skill); formal
18
+ // events (`approve`/`request-changes`) additionally get the structural gate — headless refuses,
19
+ // interactive raises a blocking `ctx.ui.confirm`.
16
20
  // `dry_run` is the anchor-repair loop: no gates, no record, nothing posted.
17
21
 
18
22
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -34,7 +38,12 @@ import {
34
38
  stringParam,
35
39
  type ToolParams,
36
40
  } from "../substrate/toolParams.ts";
37
- import { appendWorkflowState, type EntrySink } from "../substrate/workflowState.ts";
41
+ import {
42
+ appendWorkflowState,
43
+ branchOf,
44
+ type EntrySink,
45
+ rebuildWorkflowState,
46
+ } from "../substrate/workflowState.ts";
38
47
  import type { Severity } from "../surfaces/report.ts";
39
48
 
40
49
  // ------------------------------------------------------------------------ params
@@ -55,6 +64,7 @@ export interface SubmitParams {
55
64
  body: string;
56
65
  comments?: SubmitComment[];
57
66
  dry_run?: boolean;
67
+ allow_repost?: boolean;
58
68
  }
59
69
 
60
70
  /** Decode the optional `comments` array; null = present-but-malformed (whole-batch refusal). */
@@ -87,7 +97,7 @@ function decodeSubmitComments(p: ToolParams): SubmitComment[] | undefined | null
87
97
  * malformed field ⇒ null (whole-batch refusal). `pr` must be an int; `event` exactly one of the
88
98
  * three flag spellings; `body` a string (EMPTY ALLOWED — the cold door owns the event-conditioned
89
99
  * body rule and reports `bad_batch`); each `comments` row strict on
90
- * path/line(int)/side(LEFT|RIGHT)/body; `dry_run` a boolean.
100
+ * path/line(int)/side(LEFT|RIGHT)/body; `dry_run` and `allow_repost` booleans.
91
101
  */
92
102
  export function decodeSubmitParams(params: unknown): SubmitParams | null {
93
103
  const p = paramsOf(params);
@@ -102,9 +112,12 @@ export function decodeSubmitParams(params: unknown): SubmitParams | null {
102
112
  if (comments === null) return null;
103
113
  const dryRun = booleanParam(p, "dry_run");
104
114
  if (dryRun === null) return null;
115
+ const allowRepost = booleanParam(p, "allow_repost");
116
+ if (allowRepost === null) return null;
105
117
  const result: SubmitParams = { pr, event, body };
106
118
  if (comments !== undefined) result.comments = comments;
107
119
  if (dryRun !== undefined) result.dry_run = dryRun;
120
+ if (allowRepost !== undefined) result.allow_repost = allowRepost;
108
121
  return result;
109
122
  }
110
123
 
@@ -167,6 +180,42 @@ function decodeInvalidAnchors(payload: ColdJson): InvalidAnchor[] | null {
167
180
  return rows;
168
181
  }
169
182
 
183
+ // ------------------------------------------------------------------------ the posting ledger
184
+
185
+ /** One `review_posts` ledger row: a REAL submission that reached GitHub. */
186
+ export interface ReviewPostRow {
187
+ pr: number;
188
+ event: string;
189
+ at: string;
190
+ }
191
+
192
+ /**
193
+ * Tolerant re-narrow of the rebuilt `review_posts` list (best-effort tier: a malformed row is
194
+ * dropped, never a refusal — the ledger only ever grows from this tool's own writes).
195
+ */
196
+ export function reviewPostsOf(raw: unknown): ReviewPostRow[] {
197
+ if (!Array.isArray(raw)) return [];
198
+ const rows: ReviewPostRow[] = [];
199
+ for (const item of raw) {
200
+ const row = paramsOf(item);
201
+ if (row === null) continue;
202
+ if (typeof row.pr !== "number" || !Number.isInteger(row.pr)) continue;
203
+ if (typeof row.event !== "string" || typeof row.at !== "string") continue;
204
+ rows.push({ pr: row.pr, event: row.event, at: row.at });
205
+ }
206
+ return rows;
207
+ }
208
+
209
+ /** Ledger equality for the read-back verification (order-sensitive — posting order matters). */
210
+ function reviewPostsEqual(rebuilt: unknown, expected: unknown): boolean {
211
+ const a = reviewPostsOf(rebuilt);
212
+ const b = reviewPostsOf(expected);
213
+ if (a.length !== b.length) return false;
214
+ return a.every(
215
+ (row, i) => row.pr === b[i]?.pr && row.event === b[i]?.event && row.at === b[i]?.at,
216
+ );
217
+ }
218
+
170
219
  /** Flag spelling → the REST wire spelling shown in the human confirm. */
171
220
  const WIRE_EVENT: Record<ReviewEvent, string> = {
172
221
  approve: "APPROVE",
@@ -209,6 +258,27 @@ export async function submitPrReview(
209
258
  const dryRun = params.dry_run === true;
210
259
  const commentCount = params.comments?.length ?? 0;
211
260
 
261
+ // The enforced resume guard (before the confirm AND the cold-door mutation): a PR that
262
+ // already has a review_posts ledger row in this session is a confirmed success — a repeat
263
+ // real post is refused unless explicitly deliberate. A ledger row can only be MISSING
264
+ // spuriously (best-effort tier), never present spuriously — so the guard refuses on
265
+ // presence and stays silent on absence (a missing row still means: verify posted-vs-pending
266
+ // against GitHub before re-posting).
267
+ if (!dryRun && params.allow_repost !== true) {
268
+ const prior = reviewPostsOf(rebuildWorkflowState(branchOf(ctx)).review_posts).filter(
269
+ (row) => row.pr === params.pr,
270
+ );
271
+ const last = prior.at(-1);
272
+ if (last !== undefined) {
273
+ return fail(
274
+ `a ${last.event} review was already posted to PR #${params.pr} in this session ` +
275
+ `(review_posts row at ${last.at}) — on a stack resume skip this member; pass ` +
276
+ "allow_repost: true only for a deliberate second review of the same PR",
277
+ "already_posted",
278
+ );
279
+ }
280
+ }
281
+
212
282
  if (!dryRun && params.event !== "comment") {
213
283
  if (!ctx.hasUI) {
214
284
  return fail(
@@ -305,6 +375,21 @@ export async function submitPrReview(
305
375
  failure: "last_review read-back failed",
306
376
  });
307
377
 
378
+ // The accumulating per-PR ledger (read-rebuild-append): ordered rows, one per real success —
379
+ // the stack flow's partial-outcome/resume authority. Same best-effort tier as last_review.
380
+ const posts: ReviewPostRow[] = [
381
+ ...reviewPostsOf(rebuildWorkflowState(branchOf(ctx)).review_posts),
382
+ { pr: record.pr, event: params.event, at: record.at },
383
+ ];
384
+ appendWorkflowState(pi, ctx, {
385
+ data: { review_posts: posts },
386
+ field: "review_posts",
387
+ expected: posts,
388
+ scope: "review",
389
+ failure: "review_posts read-back failed",
390
+ equals: reviewPostsEqual,
391
+ });
392
+
308
393
  let text =
309
394
  `submitted ${params.event} review to PR #${record.pr} ` +
310
395
  `(${data.comment_count ?? commentCount} inline comment(s))`;
@@ -320,10 +405,10 @@ export async function submitPrReview(
320
405
 
321
406
  const TOOL_GUIDELINES = [
322
407
  "Call submit_pr_review only after the human triage has settled the batch AND the human has explicitly approved posting — nothing reaches GitHub before triage.",
323
- "Validate first with dry_run: true and repair any reported anchors until validation passes; a dry-run never posts, never gates, and records nothing.",
324
- "Make ONE real call: comments + body + event land atomically in a single review — the verdict never lands before the comments.",
408
+ "Validate first with dry_run: true and repair any reported anchors until validation passes; a dry-run never posts, never gates, and records nothing. A stack review dry-runs ALL per-PR batches before ANY real post.",
409
+ "Make ONE real call per target PR: comments + body + event land atomically in a single review — the verdict never lands before the comments. A stack review posts one review per member PR, bottom→top; each real success appends a {pr, event, at} row to the review_posts workflow-state ledger. The tool ENFORCES skip-on-resume: a real post to a PR that already has a ledger row is refused (already_posted) unless allow_repost: true — a deliberate second review only. A MISSING row is not proof of no post (the ledger is best-effort): verify posted-vs-pending against GitHub before re-posting.",
325
410
  "Formal events (approve / request-changes) additionally raise a blocking in-TUI confirm; headless sessions refuse them (use event: comment or re-run interactively).",
326
- "All perk-side GitHub posting flows through this tool on both review doors — never post via gh or bash (direct perk pr review-submit calls are forbidden). On /pr-review-terminal this tool is the sole posting path; on /pr-review-browser the plannotator UI's native platform-posting is the human's own GitHub path, and perk posts only what the human explicitly hands it (typically a request-changes verdict).",
411
+ "All perk-side GitHub posting flows through this tool on every review door — never post via gh or bash (direct perk pr review-submit calls are forbidden). On /pr-review-terminal this tool is the sole posting path. On /pr-review-browser the plannotator UI's native platform-posting is the human's own GitHub path, and perk posts only what the human explicitly hands it (typically a request-changes verdict). On /stack-review-browser the local-diff session has NO attached PR, so ALL posting is perk-side after triage.",
327
412
  ];
328
413
 
329
414
  // ------------------------------------------------------------------------ registration
@@ -334,9 +419,12 @@ export function registerSubmitPrReview(pi: ExtensionAPI): void {
334
419
  name: "submit_pr_review",
335
420
  label: "Submit PR review",
336
421
  description:
337
- "Submit the human-curated review-door outcome to the foreign PR as ONE atomic review " +
338
- "(comments + body + event) via the perk cold door. dry_run validates the anchors without " +
339
- "posting (the repair loop); a real submission records last_review in workflow-state.",
422
+ "Submit the human-curated review-door outcome to the target PR as ONE atomic review " +
423
+ "(comments + body + event) via the perk cold door the posting surface of the " +
424
+ "/pr-review-terminal, /pr-review-browser, and /stack-review-browser doors (a stack " +
425
+ "review makes one real call per member PR). dry_run validates the anchors without " +
426
+ "posting (the repair loop); a real submission records last_review and appends the " +
427
+ "review_posts ledger row in workflow-state.",
340
428
  promptSnippet: "Submit the curated review batch to the PR",
341
429
  promptGuidelines: TOOL_GUIDELINES,
342
430
  executionMode: "sequential",
@@ -363,7 +451,9 @@ export function registerSubmitPrReview(pi: ExtensionAPI): void {
363
451
  type: "array",
364
452
  description:
365
453
  "The curated inline comments — human-authored or human-approved only, each anchored " +
366
- "to a line in the PR diff (never re-anchor a child's finding).",
454
+ "to a line in the PR diff. Single-PR mode: never re-anchor a child's finding. Stack " +
455
+ "mode: the parent re-anchors combined-diff findings into per-PR coordinates under " +
456
+ "the dry-run loop.",
367
457
  items: {
368
458
  type: "object",
369
459
  additionalProperties: false,
@@ -386,6 +476,13 @@ export function registerSubmitPrReview(pi: ExtensionAPI): void {
386
476
  "Validate the batch + anchors without posting (the anchor-repair loop). No gates, " +
387
477
  "no last_review record.",
388
478
  },
479
+ allow_repost: {
480
+ type: "boolean",
481
+ description:
482
+ "Deliberately post ANOTHER review to a PR that already has a review_posts ledger " +
483
+ "row in this session — the enforced resume guard refuses with already_posted " +
484
+ "otherwise. Never pass it to work around a stack-resume refusal.",
485
+ },
389
486
  },
390
487
  },
391
488
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -11,8 +11,9 @@
11
11
  // `objective_save`/`/objective-save` still persist the objective to GitHub.
12
12
  //
13
13
  // Format doctrine: JSON is the storage/transport format, NEVER the human review surface. The
14
- // artifact carries `{schema_version, title?, prose, roadmap}` the structured roadmap rides
15
- // verbatim (node-shape validation stays with the Python plane at save time, the
14
+ // artifact carries `{schema_version, title?, prose, roadmap}` (plus, in a perk learn dream
15
+ // session, the tool-written `dream_report` block contracts §8.63) the structured roadmap
16
+ // rides verbatim (node-shape validation stays with the Python plane at save time, the
16
17
  // `parse_structured_roadmap` path). The review surface reads the draft via
17
18
  // `readObjectiveDraft` (over `readSessionArtifact` — digest-validated, fail-open) and renders
18
19
  // markdown via `renderObjectiveDraft` (the prose + a roadmap table) — never raw JSON; the
@@ -20,9 +21,10 @@
20
21
  // back as structured JSON.
21
22
  //
22
23
  // Vocabulary ownership: this module owns the shared draft/save param vocabulary
23
- // (`ObjectiveSaveParams`, `decodeObjectiveSaveParams`, `ROADMAP_PARAM_SCHEMA`) — objectiveDraft is
24
- // the LEAF (mirroring planDraft←planSave's direction); objectiveSave.ts consumes it, so it may
25
- // value-import `readObjectiveDraft` cycle-free for the approval→save orchestration.
24
+ // (`ObjectiveSaveParams`, `decodeObjectiveSaveParams`, `ROADMAP_PARAM_SCHEMA`,
25
+ // `DREAM_REPORT_PARAM_SCHEMA`) — objectiveDraft is the LEAF (mirroring planDraft←planSave's
26
+ // direction); objectiveSave.ts consumes it, so it may value-import `readObjectiveDraft`
27
+ // cycle-free for the approval→save orchestration.
26
28
  //
27
29
  // Imports stay node builtins + sibling seams (sessionData.ts, result.ts) so the module loads
28
30
  // under `node --test`; no manual `scratch`/`runs` path segments (cacheGuard.test.ts).
@@ -37,9 +39,15 @@ import {
37
39
  type SessionDataCtx,
38
40
  writeSessionArtifact,
39
41
  } from "../substrate/sessionData.ts";
40
- import { arrayParam, paramsOf, stringParam } from "../substrate/toolParams.ts";
42
+ import { arrayParam, objectParam, paramsOf, stringParam } from "../substrate/toolParams.ts";
41
43
  import type { EntrySink } from "../substrate/workflowState.ts";
42
44
  import type { ReportTarget } from "../surfaces/report.ts";
45
+ import { DREAM_REPORT_INPUT_SCHEMA } from "../waves/dreamReport.ts";
46
+ import {
47
+ decodeDreamReportBlock,
48
+ type ObjectiveDreamReportBlock,
49
+ resolveDreamReportGate,
50
+ } from "./objectiveDreamReport.ts";
43
51
 
44
52
  /** The reviewed objective delivery choice (contracts §8.45). */
45
53
  export type DeliveryChoice = "incremental" | "stacked";
@@ -53,6 +61,9 @@ export interface ObjectiveSaveParams {
53
61
  base?: string;
54
62
  // The reviewed delivery choice; omitted ⇒ incremental (the §8.42 absence rule).
55
63
  delivery?: DeliveryChoice;
64
+ // The dream-report input (perk learn dream only — §8.63); deep validation is the gate
65
+ // resolver's, so the decode keeps it opaque beyond the plain-object shape.
66
+ dream_report?: unknown;
56
67
  }
57
68
 
58
69
  /**
@@ -69,6 +80,20 @@ export const DELIVERY_PARAM_SCHEMA = {
69
80
  "one atomic PR train — capability-checked at save).",
70
81
  } as const;
71
82
 
83
+ /**
84
+ * The `dream_report` property, shared between `objective_save` and `objective_draft` so the
85
+ * two tools' dream contracts cannot drift: the §8.62 `DREAM_REPORT_INPUT_SCHEMA` embedded by
86
+ * identifier (the `DELIVERY_PARAM_SCHEMA`/`ROADMAP_PARAM_SCHEMA` shared-schema pattern) plus
87
+ * the gate description. Structurally reachable only inside a `perk learn dream` session (the
88
+ * resolver refuses it outside one).
89
+ */
90
+ export const DREAM_REPORT_PARAM_SCHEMA = {
91
+ ...DREAM_REPORT_INPUT_SCHEMA,
92
+ description:
93
+ "The perk learn dream session's final report input (the parent's decisions only) — " +
94
+ "required inside a dream session, refused outside one.",
95
+ } as const;
96
+
72
97
  /**
73
98
  * The roadmap-node items JSON schema, shared between `objective_save` and `objective_draft`
74
99
  * so the two tools' roadmap contracts cannot drift.
@@ -116,12 +141,22 @@ export function decodeObjectiveSaveParams(params: unknown): ObjectiveSaveParams
116
141
  const roadmap = arrayParam(p, "roadmap");
117
142
  const base = stringParam(p, "base");
118
143
  const delivery = stringParam(p, "delivery");
119
- if (prose === null || title === null || roadmap === null || base === null || delivery === null) {
144
+ // `dream_report` must be a plain object when present (absent undefined); deep validation
145
+ // stays with the gate resolver (resolveDreamReportGate).
146
+ const dreamReport = objectParam(p, "dream_report");
147
+ if (
148
+ prose === null ||
149
+ title === null ||
150
+ roadmap === null ||
151
+ base === null ||
152
+ delivery === null ||
153
+ dreamReport === null
154
+ ) {
120
155
  return null;
121
156
  }
122
157
  // The delivery enum is strict beyond `string`: an off-enum value is present-but-mistyped.
123
158
  if (delivery !== undefined && delivery !== "incremental" && delivery !== "stacked") return null;
124
- return { prose: prose ?? "", title, roadmap, base, delivery };
159
+ return { prose: prose ?? "", title, roadmap, base, delivery, dream_report: dreamReport };
125
160
  }
126
161
 
127
162
  /** The fixed working-objective artifact name (one JSON file: prose + the structured roadmap). */
@@ -144,7 +179,9 @@ export type ObjectiveDraftResult = Result<ObjectiveDraftOk>;
144
179
  * structured roadmap, verbatim — the draft never validates node shapes) as one JSON artifact and
145
180
  * write it through the accessor seam (file + `session_artifacts` provenance pointer). Soft
146
181
  * result, never throws — failure taxonomy: empty prose → `invalid_input`; no session run_id →
147
- * `no_run_id`; file-or-pointer write failure → `write_failed` (the seam already warned).
182
+ * `no_run_id`; a `dream_report` gate refusal → `invalid_input`/`bad_state` (the §8.63 matrix
183
+ * validated at write time so a report-less dream bundle can never reach review);
184
+ * file-or-pointer write failure → `write_failed` (the seam already warned).
148
185
  */
149
186
  export function writeObjectiveDraft(
150
187
  sink: EntrySink,
@@ -155,6 +192,7 @@ export function writeObjectiveDraft(
155
192
  roadmap?: unknown[];
156
193
  base?: string;
157
194
  delivery?: DeliveryChoice;
195
+ dream_report?: unknown;
158
196
  },
159
197
  ): ObjectiveDraftResult {
160
198
  const fail = failFor(ctx, "objective-draft");
@@ -168,6 +206,13 @@ export function writeObjectiveDraft(
168
206
  return fail("session has no run_id — cannot write the objective-draft artifact", "no_run_id");
169
207
  }
170
208
 
209
+ // The §8.63 gate: validated at draft-write time via buildDreamReport, the ONE stamp stored
210
+ // with the block; `absent` keeps the payload byte-identical (every non-dream path unchanged).
211
+ const gate = resolveDreamReportGate(ctx, opts.dream_report, new Date().toISOString());
212
+ if (gate.kind === "refuse") {
213
+ return fail(gate.detail, gate.errorType);
214
+ }
215
+
171
216
  // Deterministic key order via the explicit literal; `title`/`base`/`delivery` are omitted
172
217
  // when blank/absent (schema_version stays 1 — an additive optional field, fail-open readers).
173
218
  const title = opts.title?.trim();
@@ -179,6 +224,7 @@ export function writeObjectiveDraft(
179
224
  ...(title ? { title } : {}),
180
225
  ...(base ? { base } : {}),
181
226
  ...(delivery ? { delivery } : {}),
227
+ ...(gate.kind === "block" ? { dream_report: gate.block } : {}),
182
228
  prose: opts.prose,
183
229
  roadmap,
184
230
  };
@@ -216,6 +262,8 @@ export interface ObjectiveDraft {
216
262
  base?: string;
217
263
  // The reviewed delivery choice; kept only when exactly the enum (junk → absent, like `base`).
218
264
  delivery?: DeliveryChoice;
265
+ // The dream-report block (§8.63); a present-but-malformed block refuses the WHOLE draft.
266
+ dream_report?: ObjectiveDreamReportBlock;
219
267
  }
220
268
 
221
269
  /**
@@ -223,7 +271,10 @@ export interface ObjectiveDraft {
223
271
  * `readSessionArtifact`'s loud tier): no pointer/file/digest → `null` (the seam already spoke);
224
272
  * malformed JSON, a non-object payload, an unsupported `schema_version`, or blank prose → a
225
273
  * stderr warning + `null`. `roadmap` defaults to `[]` when absent/non-array; `title` is kept
226
- * only when a non-blank string. Never throws.
274
+ * only when a non-blank string. A present-but-malformed `dream_report` block refuses the WHOLE
275
+ * draft (warn + `null`) — deliberately stricter than the lenient junk→absent handling of
276
+ * `base`/`delivery`, because silently dropping a malformed report is exactly what §8.63
277
+ * forbids. Never throws.
227
278
  */
228
279
  export function readObjectiveDraft(ctx: SessionDataCtx): ObjectiveDraft | null {
229
280
  const artifact = readSessionArtifact(ctx, OBJECTIVE_DRAFT_ARTIFACT);
@@ -258,10 +309,19 @@ export function readObjectiveDraft(ctx: SessionDataCtx): ObjectiveDraft | null {
258
309
  payload.delivery === "incremental" || payload.delivery === "stacked"
259
310
  ? payload.delivery
260
311
  : undefined;
312
+ let dreamReport: ObjectiveDreamReportBlock | undefined;
313
+ if ("dream_report" in payload) {
314
+ const block = decodeDreamReportBlock(payload.dream_report);
315
+ if (block === null) {
316
+ return refuse("carries a malformed dream_report block");
317
+ }
318
+ dreamReport = block;
319
+ }
261
320
  return {
262
321
  ...(title !== undefined ? { title } : {}),
263
322
  ...(base !== undefined ? { base } : {}),
264
323
  ...(delivery !== undefined ? { delivery } : {}),
324
+ ...(dreamReport !== undefined ? { dream_report: dreamReport } : {}),
265
325
  prose,
266
326
  roadmap,
267
327
  };
@@ -294,7 +354,10 @@ function nodeDependsOn(node: unknown): string {
294
354
  * a `## Roadmap` section with ONE markdown table. A prominent `**Delivery:**` line renders
295
355
  * directly under the title unconditionally (the reviewed choice must be visible either way —
296
356
  * contracts §8.45). The `Phase` column appears only when some node carries a non-blank string
297
- * `phase`. Pure; never throws.
357
+ * `phase`. When the draft carries a `dream_report` block, the stored CANONICAL parts append as
358
+ * the final section — the review surface IS the approval bundle: the objective and its report
359
+ * review (and are approved or denied) together (§8.63); the parts carry their own
360
+ * `# Dream report — <run_id>` headers. Pure; never throws.
298
361
  */
299
362
  /** The always-present prominent `**Delivery:**` review line (contracts §8.45). */
300
363
  function deliveryLine(draft: ObjectiveDraft): string {
@@ -313,23 +376,27 @@ export function renderObjectiveDraft(draft: ObjectiveDraft): string {
313
376
  out += `${deliveryLine(draft)}\n\n`;
314
377
  out += draft.prose;
315
378
 
316
- if (draft.roadmap.length === 0) return out;
379
+ if (draft.roadmap.length > 0) {
380
+ const withPhase = draft.roadmap.some((node) => nodeString(node, "phase").trim().length > 0);
381
+ const header = withPhase
382
+ ? "| Node | Phase | Description | Depends On | Status |\n| --- | --- | --- | --- | --- |"
383
+ : "| Node | Description | Depends On | Status |\n| --- | --- | --- | --- |";
384
+ const rows = draft.roadmap.map((node) => {
385
+ const cells = [
386
+ tableCell(nodeString(node, "id")),
387
+ ...(withPhase ? [tableCell(nodeString(node, "phase"))] : []),
388
+ tableCell(nodeString(node, "description")),
389
+ tableCell(nodeDependsOn(node)),
390
+ tableCell(nodeString(node, "status") || "pending"),
391
+ ];
392
+ return `| ${cells.join(" | ")} |`;
393
+ });
394
+ out = `${out.trimEnd()}\n\n## Roadmap\n\n${header}\n${rows.join("\n")}\n`;
395
+ }
317
396
 
318
- const withPhase = draft.roadmap.some((node) => nodeString(node, "phase").trim().length > 0);
319
- const header = withPhase
320
- ? "| Node | Phase | Description | Depends On | Status |\n| --- | --- | --- | --- | --- |"
321
- : "| Node | Description | Depends On | Status |\n| --- | --- | --- | --- |";
322
- const rows = draft.roadmap.map((node) => {
323
- const cells = [
324
- tableCell(nodeString(node, "id")),
325
- ...(withPhase ? [tableCell(nodeString(node, "phase"))] : []),
326
- tableCell(nodeString(node, "description")),
327
- tableCell(nodeDependsOn(node)),
328
- tableCell(nodeString(node, "status") || "pending"),
329
- ];
330
- return `| ${cells.join(" | ")} |`;
331
- });
332
- return `${out.trimEnd()}\n\n## Roadmap\n\n${header}\n${rows.join("\n")}\n`;
397
+ if (draft.dream_report === undefined) return out;
398
+ // The approval bundle: objective first, then the stored CANONICAL report parts.
399
+ return `${out.trimEnd()}\n\n${draft.dream_report.parts.join("\n\n")}\n`;
333
400
  }
334
401
 
335
402
  const TOOL_GUIDELINES = [
@@ -371,6 +438,7 @@ export function registerObjectiveDraft(pi: ExtensionAPI): void {
371
438
  "Optional target branch for this objective's plans (omit to use the repo default).",
372
439
  },
373
440
  delivery: DELIVERY_PARAM_SCHEMA,
441
+ dream_report: DREAM_REPORT_PARAM_SCHEMA,
374
442
  roadmap: {
375
443
  type: "array",
376
444
  description: