@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
@@ -0,0 +1,364 @@
1
+ // The perk feedback publisher — the Hunk extension `perk plan watch` loads with `--extension`
2
+ // (contracts.md §8.58). Saving a human note in the watched diff appends ONE immutable feedback
3
+ // record to the worktree-local outbox (`.perk/workflow/hunk-watch/outbox.ndjson`); the perk Pi
4
+ // extension's receiver drains it into the live implement session.
5
+ //
6
+ // SELF-CONTAINED on purpose: node builtins only, no relative imports — this file is bundled
7
+ // alone into the wheel (`perk/_hunk/perkFeedback.ts`) and must load standalone under Hunk's
8
+ // runtime. It therefore carries local structural mirrors of the narrow Hunk API slice it uses
9
+ // (`hunkdiff` is deliberately NOT a devDependency — its dep tree includes the bun runtime; the
10
+ // mirror-a-narrow-slice pattern is the established one for deep-only types) and is the
11
+ // hunk-plane's single `.perk/workflow` path construction site (it cannot import the cache
12
+ // seam; the two sites are pinned together by a path-parity test).
13
+ //
14
+ // Publication is synchronous and durable: one appendFileSync per record (O_APPEND — the §8.58
15
+ // append-only stream discipline), nothing flushed on shutdown, no daemon queries, no network,
16
+ // no shell, no writes to reviewed files.
17
+
18
+ import { appendFileSync, lstatSync, mkdirSync, realpathSync } from "node:fs";
19
+ import { join } from "node:path";
20
+
21
+ // --- local structural mirrors of the Hunk extension API slice (verified generations) --------
22
+
23
+ /** The event-context slice every handler receives (`ExtensionEventContext` ⊃ this). */
24
+ export interface HunkEventContextSlice {
25
+ cwd: string;
26
+ notify(message: string, type?: "info" | "warning" | "error"): void;
27
+ }
28
+
29
+ /** `ExtensionReviewNote` as both verified generations (v2 types, v4 docs) report it. */
30
+ export interface HunkReviewNote {
31
+ id: string;
32
+ fileId: string;
33
+ filePath: string;
34
+ hunkIndex: number;
35
+ side: "old" | "new";
36
+ line: number;
37
+ body: string;
38
+ /** True while the note is still being composed rather than saved. */
39
+ draft: boolean;
40
+ }
41
+
42
+ interface HunkEventPayloadSlices {
43
+ startup: { cwd: string };
44
+ changeset_loaded: { changeset: { id: string } };
45
+ note_created: { note: HunkReviewNote };
46
+ session_reload: { changeset: { id: string } };
47
+ }
48
+
49
+ /** The `HunkExtensionAPI` slice the publisher touches: `apiVersion` + `on` + `log`. */
50
+ export interface HunkApiSlice {
51
+ readonly apiVersion: number;
52
+ on<E extends keyof HunkEventPayloadSlices>(
53
+ event: E,
54
+ handler: (payload: HunkEventPayloadSlices[E], ctx: HunkEventContextSlice) => void,
55
+ ): void;
56
+ log(message: string): void;
57
+ }
58
+
59
+ // --- the publisher contract (§8.58 feedback record v1) ---------------------------------------
60
+
61
+ /**
62
+ * The verified-generation gate: note handlers register only under an `apiVersion` with an
63
+ * examined artifact — v2 (the installed 0.18.1 `.d.ts`) and v4 (the vendored current docs'
64
+ * event table). v3 has NO examined artifact and is deliberately excluded; add a generation
65
+ * only after verifying one. Runtime payload validation guards drift WITHIN a generation.
66
+ * v4 is accepted on documentation evidence ONLY — the resolved binary speaks v2, so no v4
67
+ * payload has ever been observed live; runtime payload validation is the containment. Prove
68
+ * v4 against a real v4 binary on the next hunk upgrade that ships one.
69
+ */
70
+ export const SUPPORTED_HUNK_API_VERSIONS: ReadonlySet<number> = new Set([2, 4]);
71
+
72
+ /** §8.58 bounds: refuse visibly, never truncate or claim queued. */
73
+ export const MAX_BODY_BYTES = 16_384;
74
+ export const MAX_RECORD_BYTES = 32_768;
75
+
76
+ /** Feedback record v1 — a local mirror of the receiver's shape (the contract is the FILE). */
77
+ export interface FeedbackRecordV1 {
78
+ schema: 1;
79
+ feedback_id: string;
80
+ watch_instance_id: string;
81
+ plan_id: string;
82
+ created_at: string;
83
+ changeset_id: string | null;
84
+ anchor: { file_path: string; hunk_index: number; side: "old" | "new"; line: number };
85
+ body: string;
86
+ }
87
+
88
+ /**
89
+ * This plane's single `.perk/workflow/hunk-watch` construction site (path-parity-tested
90
+ * against the interior cache seam's `hunkWatchDir`/`hunkOutboxPath`).
91
+ */
92
+ export function hunkWatchPaths(root: string): { dir: string; outbox: string } {
93
+ const dir = join(root, ".perk", "workflow", "hunk-watch");
94
+ return { dir, outbox: join(dir, "outbox.ndjson") };
95
+ }
96
+
97
+ /** Newline-normalize (`\r\n`→`\n`) and outer-trim a note body. */
98
+ export function normalizeBody(body: string): string {
99
+ return body.replaceAll("\r\n", "\n").trim();
100
+ }
101
+
102
+ export type NoteValidation =
103
+ | { ok: true; note: HunkReviewNote }
104
+ /** `draft: true` is an unsaved draft — skipped silently (saving fires another event). */
105
+ | { ok: false; kind: "draft" }
106
+ /** A non-boolean `draft` is anomalous — skipped with a log diagnostic, never published. */
107
+ | { ok: false; kind: "anomalous-draft"; detail: string }
108
+ | { ok: false; kind: "malformed"; detail: string };
109
+
110
+ /**
111
+ * Structurally validate a `note_created` payload (applied on EVERY event regardless of
112
+ * generation — the guard against shape drift within a verified generation).
113
+ */
114
+ export function validateNotePayload(payload: unknown): NoteValidation {
115
+ if (typeof payload !== "object" || payload === null) {
116
+ return { ok: false, kind: "malformed", detail: "payload is not an object" };
117
+ }
118
+ const note = (payload as { note?: unknown }).note;
119
+ if (typeof note !== "object" || note === null) {
120
+ return { ok: false, kind: "malformed", detail: "payload carries no note object" };
121
+ }
122
+ const n = note as Record<string, unknown>;
123
+ if (n.draft !== false) {
124
+ if (n.draft === true) return { ok: false, kind: "draft" };
125
+ return {
126
+ ok: false,
127
+ kind: "anomalous-draft",
128
+ detail: `note.draft is ${JSON.stringify(n.draft)} (expected the boolean false)`,
129
+ };
130
+ }
131
+ const problems: string[] = [];
132
+ if (typeof n.id !== "string" || n.id === "") problems.push("id");
133
+ if (typeof n.filePath !== "string" || n.filePath === "") problems.push("filePath");
134
+ if (typeof n.body !== "string") problems.push("body");
135
+ if (typeof n.hunkIndex !== "number" || !Number.isInteger(n.hunkIndex) || n.hunkIndex < 0) {
136
+ problems.push("hunkIndex");
137
+ }
138
+ if (n.side !== "old" && n.side !== "new") problems.push("side");
139
+ if (typeof n.line !== "number" || !Number.isInteger(n.line) || n.line < 1) problems.push("line");
140
+ if (problems.length > 0) {
141
+ return {
142
+ ok: false,
143
+ kind: "malformed",
144
+ detail: `invalid note field(s): ${problems.join(", ")}`,
145
+ };
146
+ }
147
+ return { ok: true, note: note as HunkReviewNote };
148
+ }
149
+
150
+ /** Build feedback record v1 from a validated note (`feedback_id = <watchId>:<note.id>`). */
151
+ export function buildFeedbackRecord(
152
+ note: HunkReviewNote,
153
+ opts: { watchId: string; planId: string; changesetId: string | null; createdAt: string },
154
+ ): FeedbackRecordV1 {
155
+ return {
156
+ schema: 1,
157
+ feedback_id: `${opts.watchId}:${note.id}`,
158
+ watch_instance_id: opts.watchId,
159
+ plan_id: opts.planId,
160
+ created_at: opts.createdAt,
161
+ changeset_id: opts.changesetId,
162
+ anchor: {
163
+ file_path: note.filePath,
164
+ hunk_index: note.hunkIndex,
165
+ side: note.side,
166
+ line: note.line,
167
+ },
168
+ body: note.body,
169
+ };
170
+ }
171
+
172
+ /** True when `path` exists AND is itself a symlink (a missing path is fine — fresh outbox). */
173
+ function isSymlink(path: string): boolean {
174
+ try {
175
+ return lstatSync(path).isSymbolicLink();
176
+ } catch {
177
+ return false;
178
+ }
179
+ }
180
+
181
+ export type PublishResult =
182
+ | { status: "published"; record: FeedbackRecordV1 }
183
+ /** Nothing written, nothing to say aloud (drafts); `log` carries the anomaly diagnostic. */
184
+ | { status: "skipped"; log?: string }
185
+ /** Nothing written; `warning` is shown to the reviewer (never claims queued). */
186
+ | { status: "refused"; warning: string };
187
+
188
+ export interface PublisherDeps {
189
+ watchId: string;
190
+ planId: string;
191
+ /** The declared worktree root (`PERK_HUNK_WORKTREE_ROOT`) — the only tree we may write. */
192
+ worktreeRoot: string;
193
+ /** One append per record — the injected side-effect seam (production: appendFileSync). */
194
+ append(path: string, line: string): void;
195
+ /** Publisher-assigned `created_at` (ISO-8601). */
196
+ now(): string;
197
+ }
198
+
199
+ export interface Publisher {
200
+ publish(payload: unknown, context: { cwd: string; changesetId: string | null }): PublishResult;
201
+ }
202
+
203
+ export function createPublisher(deps: PublisherDeps): Publisher {
204
+ return {
205
+ publish(payload, context) {
206
+ const validation = validateNotePayload(payload);
207
+ if (!validation.ok) {
208
+ if (validation.kind === "draft") return { status: "skipped" };
209
+ if (validation.kind === "anomalous-draft") {
210
+ return { status: "skipped", log: `perk feedback: skipped a note — ${validation.detail}` };
211
+ }
212
+ return { status: "refused", warning: validation.detail };
213
+ }
214
+
215
+ // The event cwd must realpath-equal the DECLARED worktree root before any store path is
216
+ // derived — never write into whatever tree an unexpected event points at.
217
+ let eventRoot: string;
218
+ let declaredRoot: string;
219
+ try {
220
+ eventRoot = realpathSync(context.cwd);
221
+ declaredRoot = realpathSync(deps.worktreeRoot);
222
+ } catch (error) {
223
+ return { status: "refused", warning: `could not resolve the worktree root (${error})` };
224
+ }
225
+ if (eventRoot !== declaredRoot) {
226
+ return {
227
+ status: "refused",
228
+ warning: `the review's cwd (${context.cwd}) is not the watched worktree (${deps.worktreeRoot})`,
229
+ };
230
+ }
231
+
232
+ const body = normalizeBody(validation.note.body);
233
+ if (body === "") {
234
+ return { status: "refused", warning: "the note body is empty after trimming" };
235
+ }
236
+ if (Buffer.byteLength(body, "utf8") > MAX_BODY_BYTES) {
237
+ return {
238
+ status: "refused",
239
+ warning: `the note body exceeds ${MAX_BODY_BYTES} bytes — shorten it (it was NOT queued)`,
240
+ };
241
+ }
242
+ const record = buildFeedbackRecord(
243
+ { ...validation.note, body },
244
+ {
245
+ watchId: deps.watchId,
246
+ planId: deps.planId,
247
+ changesetId: context.changesetId,
248
+ createdAt: deps.now(),
249
+ },
250
+ );
251
+ const line = `${JSON.stringify(record)}\n`;
252
+ if (Buffer.byteLength(line, "utf8") > MAX_RECORD_BYTES) {
253
+ return {
254
+ status: "refused",
255
+ warning: `the serialized feedback record exceeds ${MAX_RECORD_BYTES} bytes (it was NOT queued)`,
256
+ };
257
+ }
258
+ const paths = hunkWatchPaths(declaredRoot);
259
+ try {
260
+ mkdirSync(paths.dir, { recursive: true });
261
+ // Symlink fence (§8.58): `declaredRoot` is already canonical, so every component of the
262
+ // store path must resolve to ITSELF — a force-tracked symlink at `.perk`/`workflow`/
263
+ // `hunk-watch` (or a symlinked outbox file) would otherwise redirect this append
264
+ // outside the worktree. Check-then-append TOCTOU is accepted: the threat is static
265
+ // checkout content, not a live same-uid attacker.
266
+ if (realpathSync(paths.dir) !== paths.dir) {
267
+ return {
268
+ status: "refused",
269
+ warning: `the hunk-watch dir is symlinked (${paths.dir} resolves elsewhere) — refusing to write`,
270
+ };
271
+ }
272
+ if (isSymlink(paths.outbox)) {
273
+ return {
274
+ status: "refused",
275
+ warning: `the feedback outbox is a symlink (${paths.outbox}) — refusing to write`,
276
+ };
277
+ }
278
+ deps.append(paths.outbox, line); // the full record in ONE append call
279
+ } catch (error) {
280
+ return { status: "refused", warning: `could not write the feedback outbox: ${error}` };
281
+ }
282
+ return { status: "published", record };
283
+ },
284
+ };
285
+ }
286
+
287
+ // --- the Hunk extension factory (the default export hunk loads) ------------------------------
288
+
289
+ function readLaunchEnv(
290
+ env: Record<string, string | undefined>,
291
+ ): { watchId: string; planId: string; worktreeRoot: string } | { missing: string[] } {
292
+ const watchId = (env.PERK_HUNK_WATCH_ID ?? "").trim();
293
+ const planId = (env.PERK_HUNK_PLAN_ID ?? "").trim();
294
+ const worktreeRoot = (env.PERK_HUNK_WORKTREE_ROOT ?? "").trim();
295
+ const missing = [
296
+ ...(watchId === "" ? ["PERK_HUNK_WATCH_ID"] : []),
297
+ ...(planId === "" ? ["PERK_HUNK_PLAN_ID"] : []),
298
+ ...(worktreeRoot === "" ? ["PERK_HUNK_WORKTREE_ROOT"] : []),
299
+ ];
300
+ if (missing.length > 0) return { missing };
301
+ return { watchId, planId, worktreeRoot };
302
+ }
303
+
304
+ /**
305
+ * Wire the publisher into a Hunk session. Feedback fails CLOSED, reviewing stays OPEN: an
306
+ * unsupported API generation or missing launch metadata registers no note handler and says so
307
+ * loudly once at startup — the watched diff stays fully usable either way.
308
+ */
309
+ export default function perkFeedback(hunk: HunkApiSlice): void {
310
+ if (!SUPPORTED_HUNK_API_VERSIONS.has(hunk.apiVersion)) {
311
+ const supported = [...SUPPORTED_HUNK_API_VERSIONS].join(", ");
312
+ const message =
313
+ `perk feedback disabled — this hunk speaks extension API v${hunk.apiVersion}, but perk ` +
314
+ `has verified only v{${supported}}; reviewing works normally, saved notes will NOT reach ` +
315
+ "the implementation session (update perk or hunk)";
316
+ hunk.log(message);
317
+ hunk.on("startup", (_payload, ctx) => ctx.notify(message, "warning"));
318
+ return;
319
+ }
320
+
321
+ const env = readLaunchEnv(process.env);
322
+ if ("missing" in env) {
323
+ const message =
324
+ `perk feedback disabled — missing launch metadata (${env.missing.join(", ")}); launch ` +
325
+ "the watch via `perk plan watch` to enable feedback (reviewing works normally)";
326
+ hunk.log(message);
327
+ hunk.on("startup", (_payload, ctx) => ctx.notify(message, "warning"));
328
+ return;
329
+ }
330
+
331
+ let changesetId: string | null = null;
332
+ const publisher = createPublisher({
333
+ watchId: env.watchId,
334
+ planId: env.planId,
335
+ worktreeRoot: env.worktreeRoot,
336
+ append: (path, line) => appendFileSync(path, line, "utf8"),
337
+ now: () => new Date().toISOString(),
338
+ });
339
+
340
+ hunk.on("startup", (_payload, ctx) => {
341
+ ctx.notify(
342
+ "perk feedback active — saving a human note sends it to the implementation session",
343
+ "info",
344
+ );
345
+ });
346
+ hunk.on("changeset_loaded", (payload) => {
347
+ changesetId = typeof payload?.changeset?.id === "string" ? payload.changeset.id : null;
348
+ });
349
+ hunk.on("session_reload", (payload) => {
350
+ changesetId = typeof payload?.changeset?.id === "string" ? payload.changeset.id : null;
351
+ });
352
+ // `note_created` ONLY: `note_edited` fires during composition and agent comments emit
353
+ // neither, so no other event can publish.
354
+ hunk.on("note_created", (payload, ctx) => {
355
+ const result = publisher.publish(payload, { cwd: ctx.cwd, changesetId });
356
+ if (result.status === "published") {
357
+ ctx.notify("Feedback queued for the implementation session", "info");
358
+ } else if (result.status === "refused") {
359
+ ctx.notify(`perk feedback not queued — ${result.warning}`, "warning");
360
+ } else if (result.log !== undefined) {
361
+ hunk.log(result.log);
362
+ }
363
+ });
364
+ }
@@ -0,0 +1,296 @@
1
+ // The Pi adapter for the hunk watch feedback bridge (contracts.md §8.58): eligibility, message
2
+ // rendering, the narrow persisted-user-message acceptance scan, and the factory-scoped
3
+ // receiver controller index.ts wires into session_start/session_tree.
4
+ //
5
+ // Eligibility is deliberately strict — only the ONE interactive TUI implement session whose
6
+ // reconciled plan-ref matches the worktree's cache.plan-ref ever inspects the stream
7
+ // (`ctx.mode === "tui"`: `hasUI` also admits RPC and is NOT the gate). Acceptance is transcript
8
+ // OBSERVATION: `branchHasFeedbackMessage` matches only persisted user-role message entries —
9
+ // NOT the generic `branchCarries` serialize-everything scan, whose documented
10
+ // tool-result-quoting false positive would be too weak for this bridge's central guarantee.
11
+ // The residual false positive — a USER message quoting the exact `[feedback <id>]` literal —
12
+ // is accepted and documented (§8.58).
13
+
14
+ import { watch as fsWatch } from "node:fs";
15
+ import { type PlanRef, readPlanRef } from "../substrate/cache.ts";
16
+ import { planRefsEqual } from "../substrate/workflowState.ts";
17
+ import { type ReportTarget, report } from "../surfaces/report.ts";
18
+ import {
19
+ createHunkFeedbackInbox,
20
+ type FeedbackInboxHandle,
21
+ type FeedbackTransport,
22
+ type InboxTimers,
23
+ type WatchFactory,
24
+ } from "./inbox.ts";
25
+ import type { FeedbackRecord } from "./store.ts";
26
+
27
+ // --- eligibility (pure) -----------------------------------------------------------------------
28
+
29
+ export interface EligibilityArgs {
30
+ /** Pi's run mode (`ctx.mode`) — only `"tui"` is eligible. */
31
+ mode: string | null;
32
+ /** The effective stage: claim-recorded, reload-rebuilt, or fork-inherited. */
33
+ stage: string | null;
34
+ /** An env-adopted subagent child never receives feedback. */
35
+ adopted: boolean;
36
+ runId: string | null | undefined;
37
+ piSessionId: string | null | undefined;
38
+ /** The session's reconciled `active_plan_ref`. */
39
+ activePlanRef: PlanRef | null;
40
+ /** ONE fresh `readPlanRef` result — the same read supplies the consumer's plan id. */
41
+ cachedRef: PlanRef | null;
42
+ }
43
+
44
+ export function feedbackEligibility(args: EligibilityArgs): boolean {
45
+ return (
46
+ args.mode === "tui" &&
47
+ args.stage === "implement" &&
48
+ !args.adopted &&
49
+ typeof args.runId === "string" &&
50
+ args.runId !== "" &&
51
+ typeof args.piSessionId === "string" &&
52
+ args.piSessionId !== "" &&
53
+ args.cachedRef !== null &&
54
+ planRefsEqual(args.activePlanRef, args.cachedRef)
55
+ );
56
+ }
57
+
58
+ // --- rendering (pure) ---------------------------------------------------------------------------
59
+
60
+ /**
61
+ * Flatten NON-HUMAN metadata (paths, ids) to one inert line before it is interpolated into the
62
+ * rendered message: git filenames and note ids may carry newlines/control characters that
63
+ * would otherwise break out of the descriptive bullet line and forge structure alongside the
64
+ * trusted human body. Control characters become U+FFFD — never removed silently to "fix" the
65
+ * string into a different valid value.
66
+ */
67
+ export function sanitizeInline(value: string): string {
68
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping controls is the point
69
+ return value.replace(/[\u0000-\u001f\u007f]/g, "\ufffd");
70
+ }
71
+
72
+ /**
73
+ * Render one batch as one real user message. The note bodies keep user-message authority (no
74
+ * untrusted-data fencing — this is the human's own feedback); the bridge metadata around them
75
+ * stays descriptive and is sanitized to inert single-line text. Anchors are evidence, not
76
+ * authority — the trailer says so.
77
+ */
78
+ export function renderFeedbackMessage(planId: string, batch: readonly FeedbackRecord[]): string {
79
+ const safePlanId = sanitizeInline(planId);
80
+ const displayId = /^\d+$/.test(safePlanId) ? `#${safePlanId}` : safePlanId;
81
+ const bullets = batch.map((record) => {
82
+ const body = record.body
83
+ .split("\n")
84
+ .map((line) => ` ${line}`)
85
+ .join("\n");
86
+ return (
87
+ `- [feedback ${sanitizeInline(record.feedback_id)}] ${sanitizeInline(record.anchor.file_path)}, ` +
88
+ `${record.anchor.side} line ${record.anchor.line}, hunk ${record.anchor.hunk_index + 1}:\n` +
89
+ body
90
+ );
91
+ });
92
+ return [
93
+ `Human feedback from the live Hunk review of plan ${displayId}:`,
94
+ "",
95
+ bullets.join("\n\n"),
96
+ "",
97
+ "The anchors describe where each note sat in the reviewed diff when it was saved — they " +
98
+ "are evidence, not authority. Inspect the current diff/files before acting on any note; " +
99
+ "the code may have moved since.",
100
+ ].join("\n");
101
+ }
102
+
103
+ /**
104
+ * The observation needles: EVERY record's rendered `[feedback <id>]` literal (sanitized exactly
105
+ * as rendered). Exact batch membership — not just the first record — must be proven in one
106
+ * persisted message before a batch is acknowledged (§8.58): a reconstructed larger batch that
107
+ * merely shares its first record with an older message must NOT be acked off that message.
108
+ */
109
+ export function batchMarkers(batch: readonly FeedbackRecord[]): string[] {
110
+ return batch.map((record) => `[feedback ${sanitizeInline(record.feedback_id)}]`);
111
+ }
112
+
113
+ // --- the acceptance scan (pure) ----------------------------------------------------------------
114
+
115
+ /** The structural slice of pi's `SessionMessageEntry` this scan narrows to. */
116
+ interface MessageEntrySlice {
117
+ type?: unknown;
118
+ message?: { role?: unknown; content?: unknown };
119
+ }
120
+
121
+ /** The text of one persisted USER-role message entry, or null for every other entry shape. */
122
+ function userMessageText(entry: unknown): string | null {
123
+ if (typeof entry !== "object" || entry === null) return null;
124
+ const slice = entry as MessageEntrySlice;
125
+ if (slice.type !== "message") return null;
126
+ const message = slice.message;
127
+ if (typeof message !== "object" || message === null || message.role !== "user") return null;
128
+ const content = message.content;
129
+ if (typeof content === "string") return content;
130
+ if (!Array.isArray(content)) return null;
131
+ const texts: string[] = [];
132
+ for (const part of content) {
133
+ if (typeof part !== "object" || part === null) continue;
134
+ const text = (part as { text?: unknown }).text;
135
+ if (typeof text === "string") texts.push(text);
136
+ }
137
+ return texts.join("\n");
138
+ }
139
+
140
+ /**
141
+ * True when ONE persisted USER-role message entry on the branch carries EVERY marker — the
142
+ * §8.58 acceptance evidence (exact batch membership, not first-record overlap). Tool results,
143
+ * custom entries, and assistant messages can never satisfy it: only `type === "message"`
144
+ * entries whose `message.role === "user"` are searched, and only their text content (a string
145
+ * body, or the `text` parts of an array body). Residual false positive (accepted, §8.58): a
146
+ * human user message quoting every exact `[feedback <id>]` literal of the batch.
147
+ */
148
+ export function branchHasFeedbackMessage(
149
+ entries: readonly unknown[],
150
+ markers: readonly string[],
151
+ ): boolean {
152
+ if (markers.length === 0 || markers.some((marker) => marker === "")) return false;
153
+ for (const entry of entries) {
154
+ const text = userMessageText(entry);
155
+ if (text === null) continue;
156
+ if (markers.every((marker) => text.includes(marker))) return true;
157
+ }
158
+ return false;
159
+ }
160
+
161
+ // --- the controller ------------------------------------------------------------------------------
162
+
163
+ /** The `pi` slice the receiver injects through. `ExtensionAPI` satisfies it. */
164
+ export interface UserMessageSink {
165
+ sendUserMessage(content: string, options?: { deliverAs?: "steer" | "followUp" }): void;
166
+ }
167
+
168
+ /** The `ctx` slice sync needs. `ExtensionContext` satisfies it. */
169
+ export interface ReceiverContext extends ReportTarget {
170
+ cwd: string;
171
+ isIdle(): boolean;
172
+ sessionManager: { getBranch(): unknown[] };
173
+ }
174
+
175
+ export interface ReceiverSyncArgs {
176
+ stage: string | null;
177
+ adopted: boolean;
178
+ runId: string | null | undefined;
179
+ piSessionId: string | null | undefined;
180
+ activePlanRef: PlanRef | null;
181
+ mode: string | null;
182
+ }
183
+
184
+ export interface HunkFeedbackReceiver {
185
+ /** Evaluate eligibility and open/close/re-key the inbox accordingly. Never throws. */
186
+ sync(ctx: ReceiverContext, args: ReceiverSyncArgs): void;
187
+ close(): void;
188
+ }
189
+
190
+ // Production timer/watcher deps — unref'd so a live inbox never pins the process open past
191
+ // session teardown (session_shutdown still closes cleanly and releases the lease).
192
+ const productionTimers: InboxTimers = {
193
+ setTimeout: (fn, ms) => {
194
+ const handle = setTimeout(fn, ms);
195
+ handle.unref?.();
196
+ return handle;
197
+ },
198
+ clearTimeout: (handle) => clearTimeout(handle as NodeJS.Timeout),
199
+ setInterval: (fn, ms) => {
200
+ const handle = setInterval(fn, ms);
201
+ handle.unref?.();
202
+ return handle;
203
+ },
204
+ clearInterval: (handle) => clearInterval(handle as NodeJS.Timeout),
205
+ };
206
+
207
+ const productionWatch: WatchFactory = (dir, onChange, onError) => {
208
+ const watcher = fsWatch(dir, () => onChange());
209
+ watcher.on("error", (error) => onError(error));
210
+ watcher.unref?.();
211
+ return { close: () => watcher.close() };
212
+ };
213
+
214
+ /**
215
+ * The factory-scoped receiver controller (the `registerToolGating`/`createPerkStatus`
216
+ * convention — no module globals). A `/reload` re-runs the extension factory and creates a
217
+ * fresh controller; the lease's same-identity reacquire mints a FRESH token, so the stale
218
+ * predecessor instance fails its next verifyLease and closes itself fail-closed.
219
+ */
220
+ export function createHunkFeedbackReceiver(
221
+ pi: UserMessageSink,
222
+ deps: { timers?: InboxTimers; watch?: WatchFactory; now?: () => number } = {},
223
+ ): HunkFeedbackReceiver {
224
+ let active: { key: string; handle: FeedbackInboxHandle } | null = null;
225
+ let passiveReportedKey: string | null = null;
226
+
227
+ const close = (): void => {
228
+ if (active === null) return;
229
+ try {
230
+ active.handle.close();
231
+ } catch {
232
+ // disposal is best-effort
233
+ }
234
+ active = null;
235
+ };
236
+
237
+ return {
238
+ sync(ctx, args) {
239
+ try {
240
+ // ONE fresh read per sync: the same value feeds the eligibility match AND (on open)
241
+ // the consumer identity's plan id.
242
+ const cachedRef = readPlanRef(ctx.cwd);
243
+ if (
244
+ !feedbackEligibility({ ...args, cachedRef }) ||
245
+ cachedRef === null ||
246
+ typeof args.runId !== "string" ||
247
+ typeof args.piSessionId !== "string"
248
+ ) {
249
+ close();
250
+ return;
251
+ }
252
+ const planId = cachedRef.pr_id;
253
+ const key = [ctx.cwd, args.runId, args.piSessionId, planId].join("\u0000");
254
+ if (active !== null && active.key === key) return; // same-identity re-sync: no-op
255
+ close();
256
+
257
+ const transport: FeedbackTransport = {
258
+ inject(batch: readonly FeedbackRecord[]) {
259
+ const message = renderFeedbackMessage(planId, batch);
260
+ // Idle → an ordinary next turn; busy → steer (never followUp — feedback should
261
+ // reach the agent mid-flight, not queue behind the whole turn).
262
+ if (ctx.isIdle()) pi.sendUserMessage(message);
263
+ else pi.sendUserMessage(message, { deliverAs: "steer" });
264
+ },
265
+ isInjected(batch: readonly FeedbackRecord[]) {
266
+ return branchHasFeedbackMessage(ctx.sessionManager.getBranch(), batchMarkers(batch));
267
+ },
268
+ isIdle: () => ctx.isIdle(),
269
+ };
270
+ const inbox = createHunkFeedbackInbox({
271
+ now: deps.now ?? Date.now,
272
+ timers: deps.timers ?? productionTimers,
273
+ watch: deps.watch ?? productionWatch,
274
+ report: (severity, message) => report(ctx, "hunk feedback", severity, message),
275
+ });
276
+ const result = inbox.open(
277
+ { cwd: ctx.cwd, runId: args.runId, piSessionId: args.piSessionId, planId },
278
+ transport,
279
+ );
280
+ if ("passive" in result) {
281
+ if (passiveReportedKey !== key) {
282
+ passiveReportedKey = key;
283
+ report(ctx, "hunk feedback", "warning", `staying passive — ${result.reason}`);
284
+ }
285
+ return;
286
+ }
287
+ active = { key, handle: result };
288
+ } catch (error) {
289
+ report(ctx, "hunk feedback", "error", `receiver sync failed — ${error}`, {
290
+ alsoLog: true,
291
+ });
292
+ }
293
+ },
294
+ close,
295
+ };
296
+ }