@mgiles/perk 3.0.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/extension/adapters/planAdapterPlannotator.ts +12 -9
  2. package/extension/doors/address.ts +11 -0
  3. package/extension/doors/commitCompact.ts +98 -10
  4. package/extension/doors/draftReviewWaveTools.ts +43 -15
  5. package/extension/doors/dreamWaveTools.ts +489 -0
  6. package/extension/doors/land.ts +6 -0
  7. package/extension/doors/learn.ts +16 -3
  8. package/extension/doors/lifecycleGates.ts +36 -1
  9. package/extension/doors/objectiveReviewBrowser.ts +36 -13
  10. package/extension/doors/objectiveStack.ts +424 -24
  11. package/extension/doors/planReviewBrowser.ts +30 -8
  12. package/extension/doors/plannotatorHandoff.ts +80 -8
  13. package/extension/doors/prReview.ts +158 -50
  14. package/extension/doors/prReviewBrowser.ts +75 -27
  15. package/extension/doors/prReviewDynamic.ts +33 -13
  16. package/extension/doors/ready.ts +209 -17
  17. package/extension/doors/reviewWaveTools.ts +61 -17
  18. package/extension/doors/stackReviewBrowser.ts +573 -0
  19. package/extension/doors/submit.ts +36 -10
  20. package/extension/doors/submitPrReview.ts +116 -19
  21. package/extension/factories/objectiveDraft.ts +95 -27
  22. package/extension/factories/objectiveDreamReport.ts +347 -0
  23. package/extension/factories/objectivePlan.ts +12 -6
  24. package/extension/factories/objectiveSave.ts +77 -1
  25. package/extension/factories/planReview.ts +173 -10
  26. package/extension/index.ts +88 -16
  27. package/extension/substrate/agentScratch.ts +171 -0
  28. package/extension/substrate/bindingDelivery.ts +9 -11
  29. package/extension/substrate/cache.ts +92 -2
  30. package/extension/substrate/command.ts +9 -6
  31. package/extension/substrate/config.ts +10 -3
  32. package/extension/substrate/git.ts +85 -2
  33. package/extension/substrate/paths.ts +2 -7
  34. package/extension/substrate/resolverLease.ts +363 -0
  35. package/extension/substrate/result.ts +3 -2
  36. package/extension/substrate/sessionData.ts +6 -4
  37. package/extension/substrate/sessionPointers.ts +3 -4
  38. package/extension/substrate/toolGating.ts +25 -0
  39. package/extension/substrate/workflowState.ts +57 -5
  40. package/extension/surfaces/report.ts +38 -12
  41. package/extension/surfaces/surfaces.ts +129 -7
  42. package/extension/vendor/btw/btw.ts +38 -6
  43. package/extension/waves/adversarialReviewWave.ts +34 -3
  44. package/extension/waves/draftReviewWave.ts +17 -1
  45. package/extension/waves/dreamReducerWave.ts +700 -0
  46. package/extension/waves/dreamReport.ts +1494 -0
  47. package/extension/waves/dreamWave.ts +927 -0
  48. package/extension/waves/harvestWave.ts +1 -1
  49. package/extension/waves/ponytail.ts +104 -0
  50. package/extension/waves/prReviewDynamicWave.ts +115 -34
  51. package/extension/waves/prReviewWave.ts +122 -17
  52. package/extension/waves/reportWave.ts +103 -7
  53. package/extension/worker/readOnlySession.ts +2 -3
  54. package/package.json +6 -3
  55. package/prompts/_fixtures/live.yaml +112 -0
  56. package/prompts/commit-and-compact-continuation.md +13 -0
  57. package/prompts/contexts/adapters/plannotator-objective.md +7 -1
  58. package/prompts/contexts/adapters/plannotator-plan.md +7 -1
  59. package/prompts/contexts/adapters/tombell-plan.md +4 -0
  60. package/prompts/contexts/plan-authoring.md +6 -5
  61. package/prompts/stages/conflict-resolution-continuation.md +6 -0
  62. package/prompts/stages/conflict-resolution.md +2 -2
  63. package/prompts/stages/learn-dream.md +10 -0
  64. package/prompts/stages/objective-author/adopt.md +1 -1
  65. package/prompts/stages/objective-author/file.md +1 -1
  66. package/prompts/stages/objective-author/seed.md +1 -1
  67. package/prompts/stages/objective-reconcile-ready.md +7 -0
  68. package/prompts/stages/objective-review-browser.md +1 -1
  69. package/prompts/stages/objective-sync.md +1 -1
  70. package/prompts/stages/plan-review-browser.md +1 -1
  71. package/prompts/stages/pr-review-browser/active.md +1 -1
  72. package/prompts/stages/pr-review-browser/foreign.md +1 -1
  73. package/prompts/stages/pr-review-dynamic.md +5 -5
  74. package/prompts/stages/pr-review-terminal/active.md +1 -1
  75. package/prompts/stages/pr-review-terminal/foreign.md +1 -1
  76. package/prompts/stages/pr-review-terminal/local.md +1 -1
  77. package/prompts/stages/pr-review.md +5 -5
  78. package/prompts/stages/stack-review/cold.md +1 -0
  79. package/prompts/stages/stack-review-browser/stack.md +23 -0
  80. package/shared/README.md +0 -3
  81. package/shared/bindings.yaml +6 -0
  82. package/shared/contracts.md +3784 -1851
  83. package/shared/registry.yaml +28 -13
  84. package/shared/schemas/inputs/review-post-batch.schema.json +14 -1
  85. package/shared/schemas/outputs/objective-doctor.schema.json +39 -1
  86. package/shared/schemas/outputs/objective-stack-status.schema.json +172 -1
  87. package/shared/schemas/outputs/pr-land.schema.json +3 -3
  88. package/shared/schemas/outputs/pr-ready.schema.json +110 -2
  89. package/shared/contracts-history.md +0 -605
@@ -0,0 +1,171 @@
1
+ // Run-owned agent scratch provisioning and hidden model guidance (contracts.md §8.1).
2
+ //
3
+ // This module is guidance, not enforcement: it registers no tool and changes no process-global
4
+ // temp environment. Eligible write-capable model turns receive the repository-relative current-run
5
+ // path after the confined directory has been established. The context filter removes inherited or
6
+ // stale direct scratch custom blocks. A compaction summary may quote old prose/path text; that is
7
+ // not a live guidance delivery or authoritative provenance, and is deliberately left intact.
8
+
9
+ import { relative, sep } from "node:path";
10
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
+ import { type ReportTarget, report } from "../surfaces/report.ts";
12
+ import { agentScratchDir, ensureAgentScratch } from "./cache.ts";
13
+ import { activeSessionRunId, type SessionDataCtx } from "./sessionData.ts";
14
+ import {
15
+ activeContextWindow,
16
+ type BranchEntry,
17
+ type BranchSource,
18
+ branchOf,
19
+ rebuildWorkflowState,
20
+ } from "./workflowState.ts";
21
+
22
+ export const AGENT_SCRATCH_CONTEXT_TYPE = "perk:agent-scratch";
23
+
24
+ /** Perk-owned children whose canonical definitions are report-only. */
25
+ export const REPORT_ONLY_CHILD_AGENTS = [
26
+ "perk.adversarial-reviewer",
27
+ "perk.draft-reviewer",
28
+ "perk.dream-analyst",
29
+ "perk.dream-reducer",
30
+ "perk.harvest-analyst",
31
+ "perk.learn-analyst",
32
+ "perk.objective-explorer",
33
+ "perk.pr-reviewer",
34
+ "perk.review-angle-selector",
35
+ "perk.review-classifier",
36
+ ] as const;
37
+
38
+ const REPORT_ONLY_CHILD_SET = new Set<string>(REPORT_ONLY_CHILD_AGENTS);
39
+
40
+ export interface AgentScratchBlock {
41
+ runId: string;
42
+ /** Repository-relative POSIX-style path carried in model context. */
43
+ path: string;
44
+ marker: string;
45
+ content: string;
46
+ }
47
+
48
+ export type AgentScratchContext = SessionDataCtx & ReportTarget;
49
+
50
+ /** Render the exact run-aware hidden block; provisioning stays in the resolver below. */
51
+ export function renderAgentScratchBlock(cwd: string, runId: string): AgentScratchBlock {
52
+ const path = relative(cwd, agentScratchDir(cwd, runId)).split(sep).join("/");
53
+ const marker = `[PERK AGENT SCRATCH run=${runId} path=${path}]`;
54
+ const content = [
55
+ marker,
56
+ `Put disposable command/model intermediate files for this run in \`${path}/\` instead of shared \`/tmp\`.`,
57
+ "Use descriptive, non-colliding names. These files are non-authoritative: re-read canonical repository or backend sources before making durable decisions.",
58
+ ].join("\n");
59
+ return { runId, path, marker, content };
60
+ }
61
+
62
+ /**
63
+ * Eligibility follows the branch-LWW workflow mode and the locally exposed pi-subagents child
64
+ * identity. Unknown/custom children remain eligible because no generic report-only metadata exists.
65
+ */
66
+ export function isAgentScratchEligible(
67
+ ctx: BranchSource,
68
+ childAgent: string | undefined = process.env.PI_SUBAGENT_CHILD_AGENT,
69
+ ): boolean {
70
+ try {
71
+ if (rebuildWorkflowState(branchOf(ctx)).mode === "read-only") return false;
72
+ } catch {
73
+ // No rebuilt explicit read-only mode means the parent posture remains the fallback.
74
+ }
75
+ return childAgent === undefined || !REPORT_ONLY_CHILD_SET.has(childAgent);
76
+ }
77
+
78
+ export interface AgentScratchProvisioner {
79
+ resolve(ctx: AgentScratchContext): AgentScratchBlock | null;
80
+ }
81
+
82
+ /**
83
+ * Build one extension-activation-scoped resolver. Failures warn once per run but are retried on
84
+ * every call; one success clears suppression so a later regression is reported again.
85
+ */
86
+ export function createAgentScratchProvisioner(
87
+ deps: {
88
+ ensure?: typeof ensureAgentScratch;
89
+ warn?: (ctx: AgentScratchContext, runId: string, error: unknown) => void;
90
+ } = {},
91
+ ): AgentScratchProvisioner {
92
+ const ensure = deps.ensure ?? ensureAgentScratch;
93
+ const warn =
94
+ deps.warn ??
95
+ ((ctx: AgentScratchContext, runId: string, error: unknown) => {
96
+ report(
97
+ ctx,
98
+ "agent scratch",
99
+ "warning",
100
+ `could not provision scratch for run ${runId}: ${String(error)}`,
101
+ { alsoLog: true },
102
+ );
103
+ });
104
+ const suppressedRuns = new Set<string>();
105
+
106
+ return {
107
+ resolve(ctx): AgentScratchBlock | null {
108
+ const runId = activeSessionRunId(ctx);
109
+ if (runId === null) return null;
110
+ try {
111
+ ensure(ctx.cwd, runId);
112
+ } catch (error) {
113
+ if (!suppressedRuns.has(runId)) {
114
+ suppressedRuns.add(runId);
115
+ warn(ctx, runId, error);
116
+ }
117
+ return null;
118
+ }
119
+ suppressedRuns.delete(runId);
120
+ return renderAgentScratchBlock(ctx.cwd, runId);
121
+ },
122
+ };
123
+ }
124
+
125
+ /** Whether this exact current-run block remains directly represented after compaction. */
126
+ function branchHasBlock(branch: readonly BranchEntry[], block: AgentScratchBlock): boolean {
127
+ return activeContextWindow(branch).some(
128
+ (entry) =>
129
+ entry.customType === AGENT_SCRATCH_CONTEXT_TYPE &&
130
+ ((entry.type === "custom_message" && entry.content === block.content) ||
131
+ (entry.type === "custom" && entry.data?.content === block.content)),
132
+ );
133
+ }
134
+
135
+ /** Register eligible-turn delivery and direct scratch-custom context hygiene. */
136
+ export function registerAgentScratch(
137
+ pi: ExtensionAPI,
138
+ provisioner: AgentScratchProvisioner = createAgentScratchProvisioner(),
139
+ ): void {
140
+ pi.on("before_agent_start", async (_event, ctx) => {
141
+ if (!isAgentScratchEligible(ctx)) return;
142
+
143
+ // Provision before dedup: an externally deleted directory is repaired even while the live
144
+ // branch still carries this run's exact guidance block.
145
+ const block = provisioner.resolve(ctx);
146
+ if (block === null) return;
147
+ if (branchHasBlock(branchOf(ctx), block)) return;
148
+ return {
149
+ message: {
150
+ customType: AGENT_SCRATCH_CONTEXT_TYPE,
151
+ content: block.content,
152
+ display: false,
153
+ },
154
+ };
155
+ });
156
+
157
+ pi.on("context", async (event, ctx) => {
158
+ const eligible = isAgentScratchEligible(ctx);
159
+ const block = eligible ? provisioner.resolve(ctx) : null;
160
+ let keptCurrent = false;
161
+ return {
162
+ messages: event.messages.filter((message) => {
163
+ const candidate = message as { customType?: string; content?: unknown };
164
+ if (candidate.customType !== AGENT_SCRATCH_CONTEXT_TYPE) return true;
165
+ if (block === null || candidate.content !== block.content || keptCurrent) return false;
166
+ keptCurrent = true;
167
+ return true;
168
+ }),
169
+ };
170
+ });
171
+ }
@@ -14,9 +14,10 @@
14
14
  //
15
15
  // This is the SINGLE delivery path for perk's own nudges. Delivery NEVER double-delivers: the
16
16
  // cold↔warm dedup marker is `BINDING_HEADER` itself — the cold door's initial prompt and every warm
17
- // injection carry it, so Mechanism A injects ONLY when neither the branch NOR the submitting
18
- // turn's prompt already carries the header (idempotent across turns/reloads; after compaction
19
- // drops the original it re-delivers). The prompt scan is load-bearing on the launch turn: at
17
+ // injection carry it, so Mechanism A injects ONLY when neither the compaction-active branch window
18
+ // NOR the submitting turn's prompt already carries the header (idempotent across turns/reloads;
19
+ // after compaction drops the original from model context it re-delivers). The prompt scan is
20
+ // load-bearing on the launch turn: at
20
21
  // `before_agent_start` the just-submitted prompt is NOT yet on the branch, so the branch scan
21
22
  // alone would miss a cold seed's binding suffix and double-deliver.
22
23
  //
@@ -30,6 +31,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
30
31
  import { loadDefaultBindings, resolveBindings, type SkillBinding } from "./bindings.ts";
31
32
  import { loadPerkConfig } from "./config.ts";
32
33
  import {
34
+ activeContextWindow,
33
35
  type BranchEntry,
34
36
  branchCarries,
35
37
  branchOf,
@@ -154,13 +156,9 @@ function stripFrontmatter(text: string): string {
154
156
  return text; // no closing delimiter — leave the text unchanged
155
157
  }
156
158
 
157
- /**
158
- * Whether anything on the branch already carries `BINDING_HEADER` — the cold launch's initial
159
- * prompt OR a prior warm injection. Serializing each entry is the robust, shape-agnostic scan: the
160
- * header is a distinctive literal, so a substring hit means "already delivered on this branch".
161
- */
159
+ /** Whether a cold prompt or warm injection still active in model context carries the marker. */
162
160
  function branchHasHeader(branch: readonly BranchEntry[]): boolean {
163
- return branchCarries(branch, BINDING_HEADER);
161
+ return branchCarries(activeContextWindow(branch), BINDING_HEADER);
164
162
  }
165
163
 
166
164
  /** The launched stage's `stage:<id>` render, or `null` when there is no stage / nothing matches. */
@@ -178,8 +176,8 @@ function activeStageRender(cwd: string, branch: readonly BranchEntry[]): Binding
178
176
  */
179
177
  export function registerBindingDelivery(pi: ExtensionAPI): void {
180
178
  // Mechanism A — inject the launched stage's resolved bindings as a hidden context message,
181
- // but ONLY when no entry on the branch AND not the submitting turn's prompt already carries
182
- // BINDING_HEADER (the cold door's initial prompt or a prior warm inject) — the cold↔warm
179
+ // but ONLY when no entry in the compaction-active branch window AND not the submitting turn's
180
+ // prompt already carries BINDING_HEADER (the cold door's initial prompt or a prior warm inject) — the cold↔warm
183
181
  // idempotency guard. The `event.prompt` scan covers the launch turn, where the just-submitted
184
182
  // prompt is not yet on the branch; a worker prompt carries no header, so Mechanism A still
185
183
  // fires there (contracts.md §8.38).
@@ -23,15 +23,18 @@
23
23
 
24
24
  import { randomBytes } from "node:crypto";
25
25
  import {
26
+ chmodSync,
26
27
  existsSync,
28
+ lstatSync,
27
29
  mkdirSync,
28
30
  readdirSync,
29
31
  readFileSync,
32
+ realpathSync,
30
33
  renameSync,
31
34
  rmSync,
32
35
  writeFileSync,
33
36
  } from "node:fs";
34
- import { join } from "node:path";
37
+ import { join, relative } from "node:path";
35
38
 
36
39
  /**
37
40
  * Atomically replace `path` with `content` (the interior atomic-write seam).
@@ -129,6 +132,11 @@ export function runScratchDir(cwd: string, runId: string): string {
129
132
  return join(scratchDir(cwd), "runs", runId);
130
133
  }
131
134
 
135
+ /** The run-owned directory for disposable, non-authoritative model intermediates. */
136
+ export function agentScratchDir(cwd: string, runId: string): string {
137
+ return join(runScratchDir(cwd, runId), "agent");
138
+ }
139
+
132
140
  /**
133
141
  * The session data dir for a run — a dedicated `data/` subdir so
134
142
  * run-scoped session artifacts never overlap perk machine records (dispatch.json,
@@ -139,9 +147,91 @@ export function sessionDataDir(cwd: string, runId: string): string {
139
147
  return join(runScratchDir(cwd, runId), "data");
140
148
  }
141
149
 
150
+ /** Reject run ids that could select anything except one child of the shared runs directory. */
151
+ function assertSafeRunId(runId: string): void {
152
+ if (
153
+ runId.length === 0 ||
154
+ runId === "." ||
155
+ runId === ".." ||
156
+ runId.includes("/") ||
157
+ runId.includes("\\") ||
158
+ runId.includes("\0")
159
+ ) {
160
+ throw new Error(`refusing unsafe run id ${JSON.stringify(runId)}`);
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Ensure one checkout-owned path component is a real directory, never a static redirect.
166
+ * Check-before-create races against a same-UID process are intentionally out of scope.
167
+ */
168
+ function ensureUnredirectedDirectory(
169
+ path: string,
170
+ opts: { createMode: number; rejectGroupWorldWrite: boolean },
171
+ ): void {
172
+ let stat: ReturnType<typeof lstatSync>;
173
+ try {
174
+ stat = lstatSync(path);
175
+ } catch (error) {
176
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
177
+ mkdirSync(path, { mode: opts.createMode });
178
+ stat = lstatSync(path);
179
+ }
180
+ if (stat.isSymbolicLink()) throw new Error(`refusing a symlinked run-scratch path: ${path}`);
181
+ if (!stat.isDirectory()) throw new Error(`refusing a non-directory run-scratch path: ${path}`);
182
+ if (opts.rejectGroupWorldWrite && (stat.mode & 0o022) !== 0) {
183
+ throw new Error(`refusing a group/world-writable run-scratch path: ${path}`);
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Establish a run root beneath this checkout without following redirected checkout content.
189
+ * Symlinks above `cwd` remain legal; every existing component from `.perk` through the run root
190
+ * must be a real directory without group/world write permission, and missing components are
191
+ * created no broader than 0755 even under a permissive umask. Run-id validation happens before
192
+ * the first filesystem write.
193
+ */
142
194
  export function ensureRunScratch(cwd: string, runId: string): string {
195
+ assertSafeRunId(runId);
143
196
  const dir = runScratchDir(cwd, runId);
144
- mkdirSync(dir, { recursive: true });
197
+ const components = [
198
+ join(cwd, ".perk"),
199
+ join(cwd, ".perk", "workflow"),
200
+ scratchDir(cwd),
201
+ join(scratchDir(cwd), "runs"),
202
+ dir,
203
+ ];
204
+ for (const component of components) {
205
+ ensureUnredirectedDirectory(component, {
206
+ createMode: 0o755,
207
+ rejectGroupWorldWrite: true,
208
+ });
209
+ }
210
+
211
+ const expected = join(realpathSync(cwd), relative(cwd, dir));
212
+ if (realpathSync(dir) !== expected) {
213
+ throw new Error(`refusing a redirected run-scratch dir: ${dir}`);
214
+ }
215
+ return dir;
216
+ }
217
+
218
+ /**
219
+ * Create the private run-owned agent directory as 0700 from the outset, then re-apply that mode on
220
+ * reuse. The mode protects against other OS users; it is not a sandbox from another process running
221
+ * as the same user.
222
+ */
223
+ export function ensureAgentScratch(cwd: string, runId: string): string {
224
+ const runDir = ensureRunScratch(cwd, runId);
225
+ const dir = agentScratchDir(cwd, runId);
226
+ ensureUnredirectedDirectory(dir, {
227
+ createMode: 0o700,
228
+ rejectGroupWorldWrite: false,
229
+ });
230
+ const expected = join(realpathSync(runDir), "agent");
231
+ if (realpathSync(dir) !== expected) {
232
+ throw new Error(`refusing a redirected agent scratch dir: ${dir}`);
233
+ }
234
+ chmodSync(dir, 0o700);
145
235
  return dir;
146
236
  }
147
237
 
@@ -1,14 +1,16 @@
1
1
  // The single chokepoint that gives every perk command a uniform, immediate "running…"
2
2
  // acknowledgement at entry. pi does not echo the invoked command and handlers only report() at the
3
3
  // end, so without this a command's async work (cold-door subprocess calls, GitHub round-trips) is
4
- // dead air between Enter and completion. registerPerkCommand wraps the handler to emit one transient
5
- // entry toast through the headless-safe report() seam (no cleanup state, headless-fail-safe for
6
- // free) before awaiting the original handler. The toast fires synchronously before the first await,
7
- // so it lands before any cold-door work, sendUserMessage drive, or gate transition; the wrapper does
8
- // not try/catch, so errors propagate exactly as before.
4
+ // dead air between Enter and completion. registerPerkCommand attaches the command's durable report-
5
+ // detail sink to the exact context object, then emits one transient entry toast through report()
6
+ // before awaiting the original handler. The toast fires synchronously before the first await, so it
7
+ // lands before any cold-door work, sendUserMessage drive, or gate transition; the wrapper does not
8
+ // try/catch, so errors propagate exactly as before. The WeakMap attachment deliberately survives the
9
+ // handler for background work launched with the same context.
9
10
 
10
11
  import type { ExtensionAPI, RegisteredCommand } from "@earendil-works/pi-coding-agent";
11
- import { report } from "../surfaces/report.ts";
12
+ import { attachReportDetailSink, report } from "../surfaces/report.ts";
13
+ import { createReportDetailSink } from "../surfaces/surfaces.ts";
12
14
 
13
15
  export function registerPerkCommand(
14
16
  pi: ExtensionAPI,
@@ -18,6 +20,7 @@ export function registerPerkCommand(
18
20
  pi.registerCommand(name, {
19
21
  ...options,
20
22
  handler: async (args, ctx) => {
23
+ attachReportDetailSink(ctx, createReportDetailSink(pi));
21
24
  report(ctx, name, "info", "running…");
22
25
  await options.handler(args, ctx);
23
26
  },
@@ -49,7 +49,8 @@ export interface PerkConfig {
49
49
  * The agent-keyed `[models.subagents]` table: a per-agent model override for each perk-owned
50
50
  * project agent (`pr-reviewer`, `review-classifier`, `objective-explorer`, `conflict-resolver`,
51
51
  * `learn-analyst`, `adversarial-reviewer`, `review-angle-selector`, `draft-reviewer`,
52
- * `harvest-analyst` — consumed by `run_harvest_wave` at execute time — and the
52
+ * `harvest-analyst` — consumed by `run_harvest_wave` at execute time — `dream-analyst` and
53
+ * `dream-reducer` — consumed by `run_dream_wave` at execute time — and the
53
54
  * dev-only `session-auditor`, whose def is repo-local to perk's own repository
54
55
  * (`.pi/agents/perk-dev/session-auditor.md`, never delivered by `perk init`), so the key is
55
56
  * dormant in consumer repos). Each configured
@@ -57,8 +58,10 @@ export interface PerkConfig {
57
58
  * workflowScript call — a default flowing onto every lane, single-child runs included (as
58
59
  * /pr-review does); when a key is absent the agent's frontmatter `model` (in
59
60
  * `.pi/agents/perk/<name>.md`; the session-auditor's in its repo-local def) is the default.
60
- * (`subagents.agentOverrides` does NOT reach project agents — `pi-subagents`'
61
- * `applyBuiltinOverrides` applies only to builtins so this inline injection is the mechanism.)
61
+ * (Since pi-subagents 0.52, `subagents.agentOverrides` also reaches custom/project agents —
62
+ * but only as a frontmatter-sensitive fill that never displaces a field the def's own
63
+ * frontmatter sets; every perk def pins `model:` in frontmatter, so this inline workflow-level
64
+ * injection remains the mechanism.)
62
65
  * A value may carry a `:thinking` suffix (`"anthropic/claude-sonnet-4-5:high"`) or be the
63
66
  * `"inherit"` sentinel (child inherits the parent session's model) — both resolved by
64
67
  * pi-subagents on the injected value (the last-colon segment counts as thinking only when it
@@ -75,6 +78,8 @@ export interface PerkConfig {
75
78
  "review-angle-selector"?: string;
76
79
  "draft-reviewer"?: string;
77
80
  "harvest-analyst"?: string;
81
+ "dream-analyst"?: string;
82
+ "dream-reducer"?: string;
78
83
  "session-auditor"?: string;
79
84
  };
80
85
  /**
@@ -326,6 +331,8 @@ const SUBAGENT_KEYS = [
326
331
  "review-angle-selector",
327
332
  "draft-reviewer",
328
333
  "harvest-analyst",
334
+ "dream-analyst",
335
+ "dream-reducer",
329
336
  // Dev-only: the perk-dev session-audit judgment wave's auditor (dormant in consumer repos).
330
337
  "session-auditor",
331
338
  ] as const;
@@ -3,6 +3,8 @@
3
3
  // Node builtins only (so it loads cleanly under `node --test`); shells `git` via `execFileSync`,
4
4
  // never with a shell. Fail-open by design: every failure degrades to the caller's `cwd` (or null
5
5
  // where stated) rather than throwing — the carriers that use this must never wedge a session.
6
+ // The ONE deliberate fail-closed composition is `revalidationBracket` (documented there): a
7
+ // snapshot proof must treat an unprovable probe as drift, never as "unchanged".
6
8
 
7
9
  import { execFileSync } from "node:child_process";
8
10
  import { isAbsolute, resolve } from "node:path";
@@ -115,9 +117,90 @@ export function worktreeDirty(cwd: string): boolean | null {
115
117
  }
116
118
 
117
119
  /**
118
- * The `git log --oneline <fromSha>..HEAD` listing of commits made since `fromSha` or every
120
+ * Whether the index carries `assume-unchanged` (a lowercase `git ls-files -v` tag) or
121
+ * `skip-worktree` (`S`/`s` — sparse checkouts) entries. Either bit hides worktree edits from
122
+ * `git status --porcelain`, so a status-based cleanliness proof over a flagged index is not a
123
+ * proof. **Fail-open to null** on any failure (not a repo, git missing) — callers must NOT
124
+ * conflate null with "no flags". Own `execFileSync` rather than the `git()` helper: `git()`
125
+ * conflates empty output (an empty index — meaningful here) with failure.
126
+ */
127
+ export function indexHidesChanges(cwd: string): boolean | null {
128
+ try {
129
+ const out = execFileSync("git", ["ls-files", "-v"], {
130
+ cwd,
131
+ encoding: "utf8",
132
+ stdio: ["ignore", "pipe", "ignore"],
133
+ });
134
+ return out.split("\n").some((line) => {
135
+ const tag = line[0];
136
+ return tag !== undefined && (tag === "S" || (tag >= "a" && tag <= "z"));
137
+ });
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * The dream-snapshot revalidation bracket (contracts.md §8.65) — the module's ONE deliberately
145
+ * **fail-closed** composition (a documented exception to the fail-open charter above): it exists
146
+ * to PROVE the repository still matches a stamped snapshot, so an unprovable probe must read as
147
+ * drift, never as "unchanged". The claim is END-STATE equality only — HEAD unchanged, the
148
+ * working tree clean, and no assume-unchanged/skip-worktree index flags (which would hide edits
149
+ * from the status probe) at the moment of the check — never mid-window byte immutability (a
150
+ * transient modify-and-restore inside the window is invisible by design; §8.65's accepted
151
+ * residuals). `probes` defaults to the real `headSha`/`worktreeDirty`/`indexHidesChanges` and
152
+ * exists so tests can pin each fail-closed arm independently (from a non-repo fixture the HEAD
153
+ * arm returns first, making the later null arms reachable only through the seam).
154
+ */
155
+ export function revalidationBracket(
156
+ cwd: string,
157
+ expectedSha: string,
158
+ probes?: {
159
+ head?: (cwd: string) => string | null;
160
+ dirty?: (cwd: string) => boolean | null;
161
+ flags?: (cwd: string) => boolean | null;
162
+ },
163
+ ): { ok: boolean; detail: string | null } {
164
+ const head = probes?.head ?? headSha;
165
+ const dirty = probes?.dirty ?? worktreeDirty;
166
+ const flags = probes?.flags ?? indexHidesChanges;
167
+ const actual = head(cwd);
168
+ if (actual === null) {
169
+ return {
170
+ ok: false,
171
+ detail: "HEAD could not be resolved — cannot prove the snapshot is unchanged",
172
+ };
173
+ }
174
+ if (actual !== expectedSha) {
175
+ return { ok: false, detail: `HEAD moved from ${expectedSha} to ${actual}` };
176
+ }
177
+ const isDirty = dirty(cwd);
178
+ if (isDirty === null) {
179
+ return { ok: false, detail: "working-tree cleanliness could not be verified" };
180
+ }
181
+ if (isDirty) {
182
+ return { ok: false, detail: "the working tree is no longer clean" };
183
+ }
184
+ const hidden = flags(cwd);
185
+ if (hidden === null) {
186
+ return { ok: false, detail: "index flag state could not be verified" };
187
+ }
188
+ if (hidden) {
189
+ return {
190
+ ok: false,
191
+ detail:
192
+ "the index carries assume-unchanged/skip-worktree flag(s) — worktree cleanliness " +
193
+ "cannot be proven against the snapshot",
194
+ };
195
+ }
196
+ return { ok: true, detail: null };
197
+ }
198
+
199
+ /**
200
+ * The `git log --oneline <fromSha>..HEAD` listing of commits now ahead of `fromSha` — or every
119
201
  * commit (`git log --oneline HEAD`) when `fromSha` is null (HEAD was unborn at capture time).
120
- * **Fail-open**: null on failure or when the range is empty.
202
+ * This is range evidence, not proof that this command created every listed commit. **Fail-open**:
203
+ * null on failure or when the range is empty.
121
204
  */
122
205
  export function commitsSince(cwd: string, fromSha: string | null): string | null {
123
206
  const range = fromSha === null ? "HEAD" : `${fromSha}..HEAD`;
@@ -1,8 +1,8 @@
1
1
  // perk-owned dot-directory path construction — the TS twin of perk/substrate/paths.py
2
2
  // (contracts.md §8.1).
3
3
  //
4
- // The sole construction site for the perk-owned config family (`config.toml`/`local.toml`) and
5
- // the perk dir. The config family now lives at `.perk/` (TS reads the target only — the legacy
4
+ // The sole construction site for the perk-owned config family (`config.toml`/`local.toml`).
5
+ // The config family now lives at `.perk/` (TS reads the target only — the legacy
6
6
  // `.pi/perk.toml` migration is Python-side). The workflow family lives in the established cache
7
7
  // seam (substrate/cache.ts's `workflowDir`); together these two modules own every perk-owned
8
8
  // dot-path on this plane.
@@ -17,11 +17,6 @@ import { join } from "node:path";
17
17
  export const CONFIG_FILENAME = "config.toml";
18
18
  export const LOCAL_CONFIG_FILENAME = "local.toml";
19
19
 
20
- /** The perk-owned dot-dir root (shared with Pi today). */
21
- export function perkDir(cwd: string): string {
22
- return join(cwd, ".pi");
23
- }
24
-
25
20
  /** The single config-family redirection point (the file helpers derive from it). */
26
21
  export function configDir(cwd: string): string {
27
22
  return join(cwd, ".perk");