@mgiles/perk 3.0.0 → 3.1.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.
- package/extension/adapters/planAdapterPlannotator.ts +12 -9
- package/extension/doors/commitCompact.ts +98 -10
- package/extension/doors/draftReviewWaveTools.ts +43 -15
- package/extension/doors/dreamWaveTools.ts +475 -0
- package/extension/doors/objectiveReviewBrowser.ts +36 -13
- package/extension/doors/objectiveStack.ts +1 -1
- package/extension/doors/planReviewBrowser.ts +30 -8
- package/extension/doors/prReview.ts +156 -49
- package/extension/doors/prReviewDynamic.ts +33 -13
- package/extension/doors/reviewWaveTools.ts +37 -14
- package/extension/factories/objectiveDraft.ts +95 -27
- package/extension/factories/objectiveDreamReport.ts +347 -0
- package/extension/factories/objectiveSave.ts +74 -1
- package/extension/factories/planReview.ts +173 -10
- package/extension/index.ts +62 -15
- package/extension/substrate/agentScratch.ts +171 -0
- package/extension/substrate/bindingDelivery.ts +9 -11
- package/extension/substrate/cache.ts +92 -2
- package/extension/substrate/command.ts +9 -6
- package/extension/substrate/config.ts +6 -1
- package/extension/substrate/git.ts +85 -2
- package/extension/substrate/result.ts +3 -2
- package/extension/substrate/sessionData.ts +6 -4
- package/extension/substrate/sessionPointers.ts +3 -4
- package/extension/substrate/toolGating.ts +9 -0
- package/extension/substrate/workflowState.ts +44 -2
- package/extension/surfaces/report.ts +38 -12
- package/extension/surfaces/surfaces.ts +129 -7
- package/extension/vendor/btw/btw.ts +38 -6
- package/extension/waves/adversarialReviewWave.ts +19 -2
- package/extension/waves/draftReviewWave.ts +17 -1
- package/extension/waves/dreamReducerWave.ts +700 -0
- package/extension/waves/dreamReport.ts +1494 -0
- package/extension/waves/dreamWave.ts +927 -0
- package/extension/waves/harvestWave.ts +1 -1
- package/extension/waves/ponytail.ts +104 -0
- package/extension/waves/prReviewDynamicWave.ts +115 -34
- package/extension/waves/prReviewWave.ts +122 -17
- package/extension/waves/reportWave.ts +103 -7
- package/extension/worker/readOnlySession.ts +2 -3
- package/package.json +6 -3
- package/prompts/_fixtures/live.yaml +49 -0
- package/prompts/commit-and-compact-continuation.md +13 -0
- package/prompts/contexts/adapters/plannotator-objective.md +7 -1
- package/prompts/contexts/adapters/plannotator-plan.md +7 -1
- package/prompts/stages/conflict-resolution.md +1 -1
- package/prompts/stages/learn-dream.md +10 -0
- package/prompts/stages/objective-review-browser.md +1 -1
- package/prompts/stages/plan-review-browser.md +1 -1
- package/prompts/stages/pr-review-browser/active.md +1 -1
- package/prompts/stages/pr-review-browser/foreign.md +1 -1
- package/prompts/stages/pr-review-dynamic.md +5 -5
- package/prompts/stages/pr-review-terminal/active.md +1 -1
- package/prompts/stages/pr-review-terminal/foreign.md +1 -1
- package/prompts/stages/pr-review-terminal/local.md +1 -1
- package/prompts/stages/pr-review.md +5 -5
- package/shared/bindings.yaml +3 -0
- package/shared/contracts.md +2176 -500
- package/shared/registry.yaml +12 -12
- package/shared/schemas/inputs/review-post-batch.schema.json +14 -1
- package/shared/schemas/outputs/objective-doctor.schema.json +39 -1
- package/shared/schemas/outputs/pr-land.schema.json +3 -3
|
@@ -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}`
|
|
15
|
-
//
|
|
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
|
|
24
|
-
// the LEAF (mirroring planDraft←planSave's
|
|
25
|
-
// value-import `readObjectiveDraft`
|
|
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
|
-
|
|
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`;
|
|
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.
|
|
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`.
|
|
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
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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:
|
|
@@ -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
|
+
}
|