@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.
- package/extension/adapters/planAdapterPlannotator.ts +12 -9
- package/extension/doors/address.ts +11 -0
- package/extension/doors/commitCompact.ts +98 -10
- package/extension/doors/draftReviewWaveTools.ts +43 -15
- package/extension/doors/dreamWaveTools.ts +489 -0
- package/extension/doors/land.ts +6 -0
- package/extension/doors/learn.ts +16 -3
- package/extension/doors/lifecycleGates.ts +36 -1
- package/extension/doors/objectiveReviewBrowser.ts +36 -13
- package/extension/doors/objectiveStack.ts +424 -24
- package/extension/doors/planReviewBrowser.ts +30 -8
- package/extension/doors/plannotatorHandoff.ts +80 -8
- package/extension/doors/prReview.ts +158 -50
- package/extension/doors/prReviewBrowser.ts +75 -27
- package/extension/doors/prReviewDynamic.ts +33 -13
- package/extension/doors/ready.ts +209 -17
- package/extension/doors/reviewWaveTools.ts +61 -17
- package/extension/doors/stackReviewBrowser.ts +573 -0
- package/extension/doors/submit.ts +36 -10
- package/extension/doors/submitPrReview.ts +116 -19
- package/extension/factories/objectiveDraft.ts +95 -27
- package/extension/factories/objectiveDreamReport.ts +347 -0
- package/extension/factories/objectivePlan.ts +12 -6
- package/extension/factories/objectiveSave.ts +77 -1
- package/extension/factories/planReview.ts +173 -10
- package/extension/index.ts +88 -16
- 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 +10 -3
- package/extension/substrate/git.ts +85 -2
- package/extension/substrate/paths.ts +2 -7
- package/extension/substrate/resolverLease.ts +363 -0
- 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 +25 -0
- package/extension/substrate/workflowState.ts +57 -5
- 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 +34 -3
- 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 +112 -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/contexts/adapters/tombell-plan.md +4 -0
- package/prompts/contexts/plan-authoring.md +6 -5
- package/prompts/stages/conflict-resolution-continuation.md +6 -0
- package/prompts/stages/conflict-resolution.md +2 -2
- package/prompts/stages/learn-dream.md +10 -0
- package/prompts/stages/objective-author/adopt.md +1 -1
- package/prompts/stages/objective-author/file.md +1 -1
- package/prompts/stages/objective-author/seed.md +1 -1
- package/prompts/stages/objective-reconcile-ready.md +7 -0
- package/prompts/stages/objective-review-browser.md +1 -1
- package/prompts/stages/objective-sync.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/prompts/stages/stack-review/cold.md +1 -0
- package/prompts/stages/stack-review-browser/stack.md +23 -0
- package/shared/README.md +0 -3
- package/shared/bindings.yaml +6 -0
- package/shared/contracts.md +3784 -1851
- package/shared/registry.yaml +28 -13
- 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/objective-stack-status.schema.json +172 -1
- package/shared/schemas/outputs/pr-land.schema.json +3 -3
- package/shared/schemas/outputs/pr-ready.schema.json +110 -2
- 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
|
|
824
|
-
"
|
|
825
|
-
|
|
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
|
-
|
|
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
|
|