@mgiles/perk 2.3.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.
Files changed (145) hide show
  1. package/README.md +1 -1
  2. package/extension/adapters/planAdapterPlannotator.ts +135 -91
  3. package/extension/adapters/planAdapterTombell.ts +5 -3
  4. package/extension/doors/address.ts +235 -37
  5. package/extension/doors/annotationPush.ts +924 -0
  6. package/extension/doors/auditWaveTools.ts +352 -0
  7. package/extension/doors/ciExecutor.ts +220 -55
  8. package/extension/doors/commitCompact.ts +98 -10
  9. package/extension/doors/draftReviewWaveTools.ts +412 -0
  10. package/extension/doors/dreamWaveTools.ts +475 -0
  11. package/extension/doors/harvestWaveTools.ts +279 -0
  12. package/extension/doors/land.ts +1 -0
  13. package/extension/doors/learn.ts +24 -6
  14. package/extension/doors/learnFactory.ts +33 -7
  15. package/extension/doors/lifecycleGates.ts +1 -1
  16. package/extension/doors/objectiveReviewBrowser.ts +537 -0
  17. package/extension/doors/objectiveStack.ts +1143 -0
  18. package/extension/doors/planReviewBrowser.ts +475 -0
  19. package/extension/doors/plannotatorHandoff.ts +120 -49
  20. package/extension/doors/prReview.ts +180 -63
  21. package/extension/doors/prReviewBrowser.ts +31 -23
  22. package/extension/doors/prReviewDynamic.ts +57 -30
  23. package/extension/doors/prReviewTerminal.ts +6 -11
  24. package/extension/doors/reviewWaveTools.ts +397 -0
  25. package/extension/doors/selfcheck.ts +1 -1
  26. package/extension/doors/submit.ts +82 -7
  27. package/extension/factories/gistAuthor.ts +4 -4
  28. package/extension/factories/gistDraft.ts +1 -1
  29. package/extension/factories/gistSave.ts +2 -2
  30. package/extension/factories/objective.ts +7 -7
  31. package/extension/factories/objectiveAuthor.ts +5 -4
  32. package/extension/factories/objectiveDraft.ts +153 -30
  33. package/extension/factories/objectiveDreamReport.ts +347 -0
  34. package/extension/factories/objectivePlan.ts +163 -9
  35. package/extension/factories/objectiveSave.ts +90 -2
  36. package/extension/factories/planMode.ts +5 -3
  37. package/extension/factories/planReview.ts +304 -46
  38. package/extension/factories/planTitle.ts +27 -9
  39. package/extension/hunkFeedback/inbox.ts +389 -0
  40. package/extension/hunkFeedback/perkFeedback.ts +364 -0
  41. package/extension/hunkFeedback/receiver.ts +296 -0
  42. package/extension/hunkFeedback/store.ts +533 -0
  43. package/extension/index.ts +169 -57
  44. package/extension/substrate/agentScratch.ts +171 -0
  45. package/extension/substrate/bindingDelivery.ts +9 -11
  46. package/extension/substrate/cache.ts +166 -23
  47. package/extension/substrate/coldDoor.ts +2 -3
  48. package/extension/substrate/command.ts +9 -6
  49. package/extension/substrate/config.ts +41 -12
  50. package/extension/substrate/git.ts +95 -2
  51. package/extension/substrate/providers.ts +4 -33
  52. package/extension/substrate/resources.ts +11 -0
  53. package/extension/substrate/result.ts +16 -7
  54. package/extension/substrate/sessionData.ts +8 -6
  55. package/extension/substrate/sessionPointers.ts +4 -5
  56. package/extension/substrate/structuredOutput.ts +78 -19
  57. package/extension/substrate/toolGating.ts +179 -23
  58. package/extension/substrate/workflowState.ts +50 -2
  59. package/extension/surfaces/footerProvider.ts +6 -6
  60. package/extension/surfaces/report.ts +38 -12
  61. package/extension/surfaces/surfaces.ts +213 -275
  62. package/extension/vendor/btw/btw.ts +94 -17
  63. package/extension/waves/adversarialReviewWave.ts +181 -0
  64. package/extension/waves/auditWave.ts +312 -0
  65. package/extension/waves/draftReviewWave.ts +205 -0
  66. package/extension/waves/dreamReducerWave.ts +700 -0
  67. package/extension/waves/dreamReport.ts +1494 -0
  68. package/extension/waves/dreamWave.ts +927 -0
  69. package/extension/waves/harvestWave.ts +399 -0
  70. package/extension/waves/memoryAdapter.ts +14 -1
  71. package/extension/waves/objectiveExplorerWave.ts +120 -0
  72. package/extension/waves/ponytail.ts +104 -0
  73. package/extension/waves/prReviewDynamicWave.ts +383 -72
  74. package/extension/waves/prReviewWave.ts +175 -25
  75. package/extension/waves/reportWave.ts +503 -107
  76. package/extension/waves/reviewClassifierWave.ts +109 -0
  77. package/extension/waves/rpcAdapter.ts +65 -2
  78. package/extension/worker/readOnlySession.ts +4 -5
  79. package/extension/worker/worker.ts +71 -93
  80. package/extension/workerMain.ts +4 -6
  81. package/package.json +18 -5
  82. package/prompts/README.md +19 -0
  83. package/prompts/_fixtures/live.yaml +115 -65
  84. package/prompts/commit-and-compact-continuation.md +13 -0
  85. package/prompts/common/resume-advisory.md +1 -0
  86. package/prompts/contexts/adapters/plannotator-gist.md +13 -0
  87. package/prompts/contexts/adapters/plannotator-objective.md +8 -3
  88. package/prompts/contexts/adapters/plannotator-plan.md +8 -3
  89. package/prompts/contexts/gist-authoring.md +6 -20
  90. package/prompts/contexts/objective-authoring.md +6 -18
  91. package/prompts/stages/address/action.md +3 -14
  92. package/prompts/stages/address/preview.md +2 -13
  93. package/prompts/stages/audit.md +18 -0
  94. package/prompts/stages/conflict-resolution.md +1 -1
  95. package/prompts/stages/gist-author/seed.md +4 -4
  96. package/prompts/stages/gist-save.md +1 -1
  97. package/prompts/stages/implement.md +3 -1
  98. package/prompts/stages/learn-code.md +1 -1
  99. package/prompts/stages/learn-docs.md +2 -2
  100. package/prompts/stages/learn-dream.md +10 -0
  101. package/prompts/stages/learn-harvest.md +15 -0
  102. package/prompts/stages/learn-orchestrate.md +1 -1
  103. package/prompts/stages/objective-author/adopt.md +5 -3
  104. package/prompts/stages/objective-author/file.md +5 -4
  105. package/prompts/stages/objective-author/seed.md +5 -4
  106. package/prompts/stages/objective-land.md +7 -0
  107. package/prompts/stages/objective-plan/guidance.md +2 -13
  108. package/prompts/stages/objective-plan/seed.md +5 -12
  109. package/prompts/stages/objective-recover.md +8 -0
  110. package/prompts/stages/objective-replan.md +4 -2
  111. package/prompts/stages/objective-review-browser.md +9 -0
  112. package/prompts/stages/objective-sync.md +6 -0
  113. package/prompts/stages/plan-from/adopt.md +2 -2
  114. package/prompts/stages/plan-from/file.md +2 -2
  115. package/prompts/stages/plan-review-browser.md +9 -0
  116. package/prompts/stages/pr-review-browser/active.md +6 -14
  117. package/prompts/stages/pr-review-browser/foreign.md +6 -14
  118. package/prompts/stages/pr-review-dynamic.md +6 -6
  119. package/prompts/stages/pr-review-terminal/active.md +7 -15
  120. package/prompts/stages/pr-review-terminal/foreign.md +7 -15
  121. package/prompts/stages/pr-review-terminal/local.md +1 -1
  122. package/prompts/stages/pr-review.md +5 -5
  123. package/prompts/stages/replan.md +3 -3
  124. package/shared/README.md +2 -2
  125. package/shared/bindings.yaml +15 -0
  126. package/shared/contracts-history.md +11 -0
  127. package/shared/contracts.md +5373 -281
  128. package/shared/providers.yaml +27 -60
  129. package/shared/registry.yaml +42 -18
  130. package/shared/schemas/inputs/review-post-batch.schema.json +14 -1
  131. package/shared/schemas/outputs/objective-doctor.schema.json +487 -0
  132. package/shared/schemas/outputs/objective-stack-land.schema.json +732 -0
  133. package/shared/schemas/outputs/objective-stack-recover.schema.json +397 -0
  134. package/shared/schemas/outputs/objective-stack-status.schema.json +577 -0
  135. package/shared/schemas/outputs/objective-stack-sync.schema.json +184 -0
  136. package/shared/schemas/outputs/plan-save.schema.json +12 -0
  137. package/shared/schemas/outputs/pr-land.schema.json +3 -3
  138. package/shared/schemas/outputs/pr-submit.schema.json +177 -1
  139. package/extension/adapters/todoAdapterJuicesharp.ts +0 -102
  140. package/extension/checkpoints/checkpoints.ts +0 -550
  141. package/extension/checkpoints/planSteps.ts +0 -108
  142. package/extension/doors/askUser.ts +0 -198
  143. package/prompts/common/output-schemas/objective-explorer.md +0 -36
  144. package/prompts/common/output-schemas/review-classifier.md +0 -47
  145. package/prompts/contexts/adapters/juicesharp-todo.md +0 -7
@@ -10,9 +10,56 @@
10
10
  // the read-only gate engages. The Python twins (src/perk/state/cache.py) deliberately keep
11
11
  // RAISING `CacheError` (exterior plane, launch-time fail-loud) — the cross-plane contract is
12
12
  // the *files*, not error semantics.
13
+ //
14
+ // Write discipline (contracts.md §8.1): every `.perk/workflow/` write goes through
15
+ // `atomicWriteFileSync` (temp file in the same directory + atomic rename) so a concurrent
16
+ // writer can never tear a file — a reader sees either the old bytes or the new bytes, never a
17
+ // mix (guard-tested by writeGuard.test.ts). The exemptions are the append-only NDJSON streams
18
+ // — the worker's `events.ndjson` (worker/worker.ts) and the §8.58 hunk-watch `outbox.ndjson` /
19
+ // `delivered.ndjson` (hunkFeedback/perkFeedback.ts / hunkFeedback/store.ts) — where O_APPEND
20
+ // appends cannot truncate-tear and whole-file replace would introduce a read-modify-write race
21
+ // between independent processes. Atomicity is not mutual exclusion — whole-file
22
+ // last-writer-wins between concurrent writers is the accepted residual.
23
+
24
+ import { randomBytes } from "node:crypto";
25
+ import {
26
+ chmodSync,
27
+ existsSync,
28
+ lstatSync,
29
+ mkdirSync,
30
+ readdirSync,
31
+ readFileSync,
32
+ realpathSync,
33
+ renameSync,
34
+ rmSync,
35
+ writeFileSync,
36
+ } from "node:fs";
37
+ import { join, relative } from "node:path";
13
38
 
14
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
15
- import { join } from "node:path";
39
+ /**
40
+ * Atomically replace `path` with `content` (the interior atomic-write seam).
41
+ *
42
+ * Writes a temp file in the same directory (so the rename never crosses filesystems) then swaps
43
+ * it into place; a concurrent reader sees either the old bytes or the new bytes, never a torn
44
+ * mix. On failure the temp file is best-effort removed and the error re-thrown. Precondition:
45
+ * the parent directory exists (same contract as `writeFileSync`; call sites mkdir first).
46
+ * Deliberately no fsync — crash durability is out of scope; the target is inter-process tearing
47
+ * of regenerable, gitignored workflow state.
48
+ */
49
+ export function atomicWriteFileSync(path: string, content: string): void {
50
+ const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
51
+ try {
52
+ writeFileSync(tmp, content, "utf8");
53
+ renameSync(tmp, path);
54
+ } catch (error) {
55
+ try {
56
+ rmSync(tmp, { force: true });
57
+ } catch {
58
+ // best-effort cleanup — a cleanup failure must never mask the original write/rename error
59
+ }
60
+ throw error;
61
+ }
62
+ }
16
63
 
17
64
  export interface Handoff {
18
65
  run_id: string;
@@ -67,7 +114,7 @@ export function markHandoffConsumed(
67
114
  if (data === null) return;
68
115
  data.consumed = true;
69
116
  if (opts.piSessionId !== undefined) data.pi_session_id = opts.piSessionId;
70
- writeFileSync(handoffPath(cwd, runId), `${JSON.stringify(data, null, 2)}\n`, "utf8");
117
+ atomicWriteFileSync(handoffPath(cwd, runId), `${JSON.stringify(data, null, 2)}\n`);
71
118
  }
72
119
 
73
120
  // --- scratch -----------------------------------------------------------------------------
@@ -85,6 +132,11 @@ export function runScratchDir(cwd: string, runId: string): string {
85
132
  return join(scratchDir(cwd), "runs", runId);
86
133
  }
87
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
+
88
140
  /**
89
141
  * The session data dir for a run — a dedicated `data/` subdir so
90
142
  * run-scoped session artifacts never overlap perk machine records (dispatch.json,
@@ -95,9 +147,91 @@ export function sessionDataDir(cwd: string, runId: string): string {
95
147
  return join(runScratchDir(cwd, runId), "data");
96
148
  }
97
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
+ */
98
194
  export function ensureRunScratch(cwd: string, runId: string): string {
195
+ assertSafeRunId(runId);
99
196
  const dir = runScratchDir(cwd, runId);
100
- 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);
101
235
  return dir;
102
236
  }
103
237
 
@@ -142,29 +276,37 @@ export function readPlanRef(cwd: string): PlanRef | null {
142
276
 
143
277
  export function writePlanRef(cwd: string, ref: PlanRef): void {
144
278
  mkdirSync(workflowDir(cwd), { recursive: true });
145
- writeFileSync(planRefPath(cwd), `${JSON.stringify(ref, null, 2)}\n`, "utf8");
279
+ atomicWriteFileSync(planRefPath(cwd), `${JSON.stringify(ref, null, 2)}\n`);
146
280
  }
147
281
 
148
- // --- plan body cache (`cache.plan`) ------------------------------------------------------
282
+ // --- hunk-watch: the watch-feedback bridge family (contracts.md §8.58) ---------------------
283
+ //
284
+ // Worktree-local, disposable: append-only NDJSON streams plus the single-consumer lease dir.
285
+ // This module is the INTERIOR construction site for the family; the hunk-plane twin is the
286
+ // self-contained bundled publisher (extension/hunkFeedback/perkFeedback.ts), pinned to these
287
+ // helpers by a path-parity test.
149
288
 
150
- /**
151
- * The materialized plan-body cache (`cache.plan`, contracts §8.1). Written by the Python cold door
152
- * (`perk implement` → `launch.materialize_plan_body`) when it positions the worktree; read here so
153
- * in-session checkpoints seed from its `## Steps` list (inert when absent).
154
- */
155
- export function planBodyPath(cwd: string): string {
156
- return join(workflowDir(cwd), "plan.md");
289
+ export function hunkWatchDir(cwd: string): string {
290
+ return join(workflowDir(cwd), "hunk-watch");
157
291
  }
158
292
 
159
- export function readPlanBody(cwd: string): string | null {
160
- const path = planBodyPath(cwd);
161
- if (!existsSync(path)) return null;
162
- try {
163
- return readFileSync(path, "utf8");
164
- } catch (error) {
165
- console.error(`perk: unreadable plan body at ${path} — treating as absent (${error})`);
166
- return null;
167
- }
293
+ /** Append-only feedback records (the Hunk publisher writes; the Pi receiver reads). */
294
+ export function hunkOutboxPath(cwd: string): string {
295
+ return join(hunkWatchDir(cwd), "outbox.ndjson");
296
+ }
297
+
298
+ /** Append-only delivery acknowledgements (the Pi receiver writes). */
299
+ export function hunkDeliveredPath(cwd: string): string {
300
+ return join(hunkWatchDir(cwd), "delivered.ndjson");
301
+ }
302
+
303
+ /** The single-consumer lease dir (atomic mkdir is the acquisition primitive). */
304
+ export function hunkConsumerLockDir(cwd: string): string {
305
+ return join(hunkWatchDir(cwd), "consumer.lock");
306
+ }
307
+
308
+ export function hunkLeasePath(cwd: string): string {
309
+ return join(hunkConsumerLockDir(cwd), "lease.json");
168
310
  }
169
311
 
170
312
  // --- markers (existence-only) ------------------------------------------------------------
@@ -178,7 +320,8 @@ export function markerPath(cwd: string, name: string): string {
178
320
 
179
321
  export function setMarker(cwd: string, name: string): void {
180
322
  mkdirSync(join(workflowDir(cwd), "markers"), { recursive: true });
181
- writeFileSync(markerPath(cwd, name), "", "utf8");
323
+ // Routed through the atomic seam for uniformity (empty content is trivially safe either way).
324
+ atomicWriteFileSync(markerPath(cwd, name), "");
182
325
  }
183
326
 
184
327
  export function hasMarker(cwd: string, name: string): boolean {
@@ -14,10 +14,9 @@
14
14
  // });
15
15
  // if (!r.ok) return fail(r.message, r.errorType);
16
16
 
17
- import { writeFileSync } from "node:fs";
18
17
  import { join } from "node:path";
19
18
  import type { ExecOptions, ExecResult } from "@earendil-works/pi-coding-agent";
20
- import { ensureRunScratch } from "./cache.ts";
19
+ import { atomicWriteFileSync, ensureRunScratch } from "./cache.ts";
21
20
  import { type BranchSource, branchOf, rebuildWorkflowState } from "./workflowState.ts";
22
21
 
23
22
  /** Minimal exec surface — `ExtensionAPI` satisfies it (compile-checked in the test); tests fake it. */
@@ -134,7 +133,7 @@ export async function runColdDoor<T>(
134
133
  try {
135
134
  const dir = ensureRunScratch(ctx.cwd, activeRunId(ctx));
136
135
  const path = join(dir, opts.stdin.filename);
137
- writeFileSync(path, opts.stdin.content, "utf8");
136
+ atomicWriteFileSync(path, opts.stdin.content);
138
137
  fullArgv = [...argv, opts.stdin.flag, path];
139
138
  } catch (err) {
140
139
  return {
@@ -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
  },
@@ -48,11 +48,16 @@ export interface PerkConfig {
48
48
  /**
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
- * `learn-analyst`, `adversarial-reviewer`, `review-angle-selector`). Each configured
51
+ * `learn-analyst`, `adversarial-reviewer`, `review-angle-selector`, `draft-reviewer`,
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
54
+ * dev-only `session-auditor`, whose def is repo-local to perk's own repository
55
+ * (`.pi/agents/perk-dev/session-auditor.md`, never delivered by `perk init`), so the key is
56
+ * dormant in consumer repos). Each configured
52
57
  * value is injected as the top-level workflow-level `model` on that agent's one `subagent`
53
58
  * workflowScript call — a default flowing onto every lane, single-child runs included (as
54
- * /pr-review does); when
55
- * a key is absent the agent's frontmatter `model` (in `.pi/agents/<name>.md`) is the default.
59
+ * /pr-review does); when a key is absent the agent's frontmatter `model` (in
60
+ * `.pi/agents/perk/<name>.md`; the session-auditor's in its repo-local def) is the default.
56
61
  * (`subagents.agentOverrides` does NOT reach project agents — `pi-subagents`'
57
62
  * `applyBuiltinOverrides` applies only to builtins — so this inline injection is the mechanism.)
58
63
  * A value may carry a `:thinking` suffix (`"anthropic/claude-sonnet-4-5:high"`) or be the
@@ -69,6 +74,11 @@ export interface PerkConfig {
69
74
  "learn-analyst"?: string;
70
75
  "adversarial-reviewer"?: string;
71
76
  "review-angle-selector"?: string;
77
+ "draft-reviewer"?: string;
78
+ "harvest-analyst"?: string;
79
+ "dream-analyst"?: string;
80
+ "dream-reducer"?: string;
81
+ "session-auditor"?: string;
72
82
  };
73
83
  /**
74
84
  * Optional `[compaction] objective_threshold` — the context-usage fraction (0,1] that triggers
@@ -86,8 +96,6 @@ export interface PerkConfig {
86
96
  */
87
97
  providers: {
88
98
  plan?: string;
89
- todo?: string;
90
- askuser?: string;
91
99
  footer?: string;
92
100
  web?: string;
93
101
  };
@@ -268,6 +276,24 @@ export function loadPerkConfig(cwd: string): PerkConfig {
268
276
  };
269
277
  }
270
278
 
279
+ /**
280
+ * Resolve one `[models.subagents]` model at execute time — the flow-tool read for sessions that
281
+ * may run inside a linked worktree. Committed `.perk/config.toml` is read from `cwd` (the
282
+ * worktree's committed semantics), but the gitignored `.perk/local.toml` lives only in the MAIN
283
+ * checkout when `cwd` is a linked worktree (worktrees never materialize it), so the local
284
+ * overlay is additionally anchored via `mainCheckoutRoot` — a user's session-transient model
285
+ * override survives the cold worktree launch. A worktree-local `local.toml`, if one exists,
286
+ * still wins (most specific last); in the main checkout the two local reads are the same file
287
+ * (byte-identical behavior). Fail-open like everything here — missing/malformed files are empty.
288
+ */
289
+ export function subagentModel(cwd: string, agent: SubagentKey): string | undefined {
290
+ const merged = overlay(
291
+ overlay(readTomlFile(configFile(cwd)), readTomlFile(localConfigFile(mainCheckoutRoot(cwd)))),
292
+ readTomlFile(localConfigFile(cwd)),
293
+ );
294
+ return parseSubagentsSelection(merged.tables["models.subagents"])[agent];
295
+ }
296
+
271
297
  /**
272
298
  * Read the `[[ci.checks]]` array-of-tables into an ordered `CiCheck[]`. A row is kept only when
273
299
  * both `name` and `command` are non-blank strings; `glob` is kept only when a non-blank string.
@@ -289,6 +315,9 @@ export function parseCiChecks(rows: Array<Record<string, TomlScalar>>): CiCheck[
289
315
  return checks;
290
316
  }
291
317
 
318
+ /** One perk-owned project agent name configurable via the `[models.subagents]` table. */
319
+ export type SubagentKey = (typeof SUBAGENT_KEYS)[number];
320
+
292
321
  /** The perk-owned project agents configurable via the `[models.subagents]` table. */
293
322
  const SUBAGENT_KEYS = [
294
323
  "pr-reviewer",
@@ -298,6 +327,12 @@ const SUBAGENT_KEYS = [
298
327
  "learn-analyst",
299
328
  "adversarial-reviewer",
300
329
  "review-angle-selector",
330
+ "draft-reviewer",
331
+ "harvest-analyst",
332
+ "dream-analyst",
333
+ "dream-reducer",
334
+ // Dev-only: the perk-dev session-audit judgment wave's auditor (dormant in consumer repos).
335
+ "session-auditor",
301
336
  ] as const;
302
337
 
303
338
  /**
@@ -316,24 +351,18 @@ function parseSubagentsSelection(
316
351
  return selection;
317
352
  }
318
353
 
319
- /** Read the flat `[providers]` table into a `{plan?, todo?, askuser?, footer?, web?}` selection (string values only). A retired `review` key is silently ignored (the TS fail-safe posture; the Python plane's tripwire is the loud surface). */
354
+ /** Read the flat `[providers]` table into a `{plan?, footer?, web?}` selection (string values only). Retired keys (`review`, `askuser`, `todo`) are silently ignored (the TS fail-safe posture; the Python plane's tripwire is the loud surface). */
320
355
  function parseProvidersSelection(table: Record<string, TomlScalar> | undefined): {
321
356
  plan?: string;
322
- todo?: string;
323
- askuser?: string;
324
357
  footer?: string;
325
358
  web?: string;
326
359
  } {
327
360
  const selection: {
328
361
  plan?: string;
329
- todo?: string;
330
- askuser?: string;
331
362
  footer?: string;
332
363
  web?: string;
333
364
  } = {};
334
365
  if (typeof table?.plan === "string") selection.plan = table.plan;
335
- if (typeof table?.todo === "string") selection.todo = table.todo;
336
- if (typeof table?.askuser === "string") selection.askuser = table.askuser;
337
366
  if (typeof table?.footer === "string") selection.footer = table.footer;
338
367
  if (typeof table?.web === "string") selection.web = table.web;
339
368
  return selection;
@@ -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";
@@ -48,6 +50,16 @@ function git(cwd: string, args: string[], timeout?: number): string | null {
48
50
  }
49
51
  }
50
52
 
53
+ /**
54
+ * Tracked files under `pathspec` (repo-relative names), [] when none or on ANY failure (not a
55
+ * repo, git missing — the module's fail-open style). Callers deciding trust on the result must
56
+ * treat [] as "nothing PROVEN tracked", not proof of cleanliness.
57
+ */
58
+ export function lsFiles(cwd: string, pathspec: string): string[] {
59
+ const out = git(cwd, ["ls-files", "--", pathspec]);
60
+ return out === null ? [] : out.split("\n").filter((line) => line !== "");
61
+ }
62
+
51
63
  /** The bounded best-effort `git fetch` budget (ms) — see `sinceBaseSha` step 2. */
52
64
  const FETCH_TIMEOUT_MS = 15_000;
53
65
 
@@ -105,9 +117,90 @@ export function worktreeDirty(cwd: string): boolean | null {
105
117
  }
106
118
 
107
119
  /**
108
- * 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
109
201
  * commit (`git log --oneline HEAD`) when `fromSha` is null (HEAD was unborn at capture time).
110
- * **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.
111
204
  */
112
205
  export function commitsSince(cwd: string, fromSha: string | null): string | null {
113
206
  const range = fromSha === null ? "HEAD" : `${fromSha}..HEAD`;
@@ -2,13 +2,12 @@
2
2
  //
3
3
  // Twin of perk/substrate/providers.py: both planes parse the SAME bundled file (no codegen). This is the
4
4
  // THIRD parsed cross-plane contract (after registry.yaml and bindings.yaml). It is the SUPPORTED
5
- // SET — the catalog of plan/todo/askuser/footer/web providers perk knows how to wire —
5
+ // SET — the catalog of plan/footer/web providers perk knows how to wire —
6
6
  // distinct from the per-repo SELECTION (the flat `[providers]` table in .perk/config.toml).
7
7
  //
8
8
  // The Python CLI is the authoritative validator (perk/substrate/providers.py); this side does a thin
9
9
  // structural parse only — no deep content validation here. `resolveProviders` is CONSUMED by
10
- // `planMode` (plan-seam registration-time vacating when a foreign plan provider is selected); the
11
- // todo-seam runtime deferral is likewise live.
10
+ // `planMode` (plan-seam registration-time vacating when a foreign plan provider is selected).
12
11
 
13
12
  import { readFileSync } from "node:fs";
14
13
  import { join } from "node:path";
@@ -24,12 +23,10 @@ export interface Provider {
24
23
  packageFilter?: Record<string, unknown>;
25
24
  }
26
25
 
27
- export const PROVIDER_SEAMS = ["plan", "todo", "askuser", "footer", "web"] as const;
26
+ export const PROVIDER_SEAMS = ["plan", "footer", "web"] as const;
28
27
 
29
28
  /** The bundled reference provider ids (the behavior-preserving no-config defaults per seam). */
30
29
  export const PERK_PLAN_PROVIDER_ID = "perk-plan";
31
- export const PERK_CHECKPOINTS_PROVIDER_ID = "perk-checkpoints";
32
- export const PERK_ASK_USER_PROVIDER_ID = "perk-ask-user";
33
30
  export const PERK_FOOTER_PROVIDER_ID = "perk-footer";
34
31
 
35
32
  /** The foreign `@tombell/pi-plan` plan-provider id. */
@@ -38,12 +35,6 @@ export const TOMBELL_PLAN_PROVIDER_ID = "tombell-plan";
38
35
  /** The foreign `@plannotator/pi-extension` plan-provider id (augment-posture adapter selection check). */
39
36
  export const PLANNOTATOR_PLAN_PROVIDER_ID = "plannotator-plan";
40
37
 
41
- /** The foreign `@juicesharp/rpiv-todo` todo-provider id. */
42
- export const JUICESHARP_TODO_PROVIDER_ID = "juicesharp-todo";
43
-
44
- /** The foreign `@juicesharp/rpiv-ask-user-question` askuser-provider id (vacate-only interface seam). */
45
- export const JUICESHARP_ASK_USER_PROVIDER_ID = "juicesharp-ask-user";
46
-
47
38
  /** The foreign `pi-powerline-footer` footer-provider id (vacate-only interface seam). */
48
39
  export const POWERLINE_FOOTER_PROVIDER_ID = "powerline-footer";
49
40
 
@@ -106,8 +97,6 @@ export function loadProviders(): Provider[] {
106
97
  */
107
98
  export interface ResolvedProviders {
108
99
  plan: Provider;
109
- todo: Provider;
110
- askuser: Provider;
111
100
  footer: Provider;
112
101
  web: Provider;
113
102
  issues: string[];
@@ -124,20 +113,6 @@ export interface ResolvedProviders {
124
113
  */
125
114
  const REFERENCE_FALLBACKS: Record<(typeof PROVIDER_SEAMS)[number], Provider> = {
126
115
  plan: { id: PERK_PLAN_PROVIDER_ID, seam: "plan", package: null, adapter: null, default: true },
127
- todo: {
128
- id: PERK_CHECKPOINTS_PROVIDER_ID,
129
- seam: "todo",
130
- package: null,
131
- adapter: null,
132
- default: true,
133
- },
134
- askuser: {
135
- id: PERK_ASK_USER_PROVIDER_ID,
136
- seam: "askuser",
137
- package: null,
138
- adapter: null,
139
- default: true,
140
- },
141
116
  footer: {
142
117
  id: PERK_FOOTER_PROVIDER_ID,
143
118
  seam: "footer",
@@ -183,8 +158,6 @@ function byId(set: Provider[]): Map<string, Provider> {
183
158
  export function resolveProviders(
184
159
  selection: {
185
160
  plan?: string;
186
- todo?: string;
187
- askuser?: string;
188
161
  footer?: string;
189
162
  web?: string;
190
163
  },
@@ -205,7 +178,7 @@ export function resolveProviders(
205
178
  return def;
206
179
  };
207
180
 
208
- const resolveSeam = (seam: "plan" | "todo" | "askuser" | "footer" | "web"): Provider => {
181
+ const resolveSeam = (seam: "plan" | "footer" | "web"): Provider => {
209
182
  const selected = selection[seam];
210
183
  if (selected == null) return requireDefault(seam);
211
184
  const provider = ids.get(selected);
@@ -222,8 +195,6 @@ export function resolveProviders(
222
195
 
223
196
  return {
224
197
  plan: resolveSeam("plan"),
225
- todo: resolveSeam("todo"),
226
- askuser: resolveSeam("askuser"),
227
198
  footer: resolveSeam("footer"),
228
199
  web: resolveSeam("web"),
229
200
  issues,
@@ -39,3 +39,14 @@ export function perkVersion(): string {
39
39
  return "0.0.0";
40
40
  }
41
41
  }
42
+
43
+ /**
44
+ * The vintage-stamp filter over a perkVersion() value: the version, or undefined for the
45
+ * "0.0.0" failure sentinel. A `perk_version` stamp claims an EXACT session vintage
46
+ * (contracts §8.3 — the session-audit read side treats it as the exact basis), so the
47
+ * self-confessed-unknown sentinel must not masquerade as one; omitting the stamp leaves the
48
+ * session on the honest timestamp-estimate arm.
49
+ */
50
+ export function versionStamp(version: string): string | undefined {
51
+ return version === "0.0.0" ? undefined : version;
52
+ }