@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
@@ -4,26 +4,36 @@
4
4
  // `session_start` (verified-linkage), rebuild `perk:workflow-state` on `session_start` AND
5
5
  // `session_tree` (per-field LWW), and derive a child run_id on fork.
6
6
 
7
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
7
+ import { existsSync, mkdirSync } from "node:fs";
8
8
  import { basename, join } from "node:path";
9
9
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
10
  import { registerPlanAdapterPlannotator } from "./adapters/planAdapterPlannotator.ts";
11
11
  import { registerPlanAdapterTombell } from "./adapters/planAdapterTombell.ts";
12
- import { registerTodoAdapterJuicesharp } from "./adapters/todoAdapterJuicesharp.ts";
13
- import { registerCheckpoints } from "./checkpoints/checkpoints.ts";
14
12
  import { registerAddress } from "./doors/address.ts";
15
- import { registerAskUser } from "./doors/askUser.ts";
13
+ import { registerAnnotationPushTool } from "./doors/annotationPush.ts";
14
+ import { registerAuditWave } from "./doors/auditWaveTools.ts";
16
15
  import { registerCiExecutor } from "./doors/ciExecutor.ts";
17
16
  import { registerCommitAndCompact } from "./doors/commitCompact.ts";
17
+ import { registerDraftReviewWaveTools } from "./doors/draftReviewWaveTools.ts";
18
+ import { registerDreamWave } from "./doors/dreamWaveTools.ts";
19
+ import { registerHarvestWave } from "./doors/harvestWaveTools.ts";
18
20
  import { registerLand } from "./doors/land.ts";
19
21
  import { registerLearn } from "./doors/learn.ts";
20
22
  import { CODE_DOOR, DOCS_DOOR, registerLearnFactoryDoor } from "./doors/learnFactory.ts";
21
23
  import { registerLifecycleGates } from "./doors/lifecycleGates.ts";
24
+ import {
25
+ openObjectiveReviewSurface,
26
+ registerObjectiveReviewBrowser,
27
+ } from "./doors/objectiveReviewBrowser.ts";
28
+ import { registerObjectiveStack } from "./doors/objectiveStack.ts";
29
+ import { plannotatorPresent } from "./doors/plannotatorHandoff.ts";
30
+ import { openPlanReviewSurface, registerPlanReviewBrowser } from "./doors/planReviewBrowser.ts";
22
31
  import { registerPrReview } from "./doors/prReview.ts";
23
32
  import { registerPrReviewBrowser } from "./doors/prReviewBrowser.ts";
24
33
  import { registerPrReviewDynamic } from "./doors/prReviewDynamic.ts";
25
34
  import { registerPrReviewTerminal } from "./doors/prReviewTerminal.ts";
26
35
  import { registerReady } from "./doors/ready.ts";
36
+ import { registerReviewWaveTools } from "./doors/reviewWaveTools.ts";
27
37
  import { registerSelfcheck } from "./doors/selfcheck.ts";
28
38
  import { registerSubmit } from "./doors/submit.ts";
29
39
  import { registerSubmitPrReview } from "./doors/submitPrReview.ts";
@@ -40,8 +50,11 @@ import { registerPlanDraft } from "./factories/planDraft.ts";
40
50
  import { registerPlanMode } from "./factories/planMode.ts";
41
51
  import { registerPlanReview } from "./factories/planReview.ts";
42
52
  import { registerPlanSave } from "./factories/planSave.ts";
53
+ import { createHunkFeedbackReceiver } from "./hunkFeedback/receiver.ts";
54
+ import { createAgentScratchProvisioner, registerAgentScratch } from "./substrate/agentScratch.ts";
43
55
  import { registerBindingDelivery } from "./substrate/bindingDelivery.ts";
44
56
  import {
57
+ atomicWriteFileSync,
45
58
  ensureRunScratch,
46
59
  markHandoffConsumed,
47
60
  readHandoff,
@@ -50,7 +63,7 @@ import {
50
63
  workflowDir,
51
64
  } from "./substrate/cache.ts";
52
65
  import { loadRegistry, type Registry, stageConsumesPlanRef } from "./substrate/registry.ts";
53
- import { perkVersion, sharedDir } from "./substrate/resources.ts";
66
+ import { perkVersion, sharedDir, versionStamp } from "./substrate/resources.ts";
54
67
  import { mintRunId } from "./substrate/runId.ts";
55
68
  import { captureSessionPointer } from "./substrate/sessionPointers.ts";
56
69
  import { registerToolGating } from "./substrate/toolGating.ts";
@@ -70,7 +83,9 @@ import {
70
83
  createPerkStatus,
71
84
  installPerkFooter,
72
85
  latestCacheHitRate,
86
+ REPORT_DETAIL_TYPE,
73
87
  registerTranscriptRenderer,
88
+ reportDetailEntryRenderer,
74
89
  workflowStateEntryRenderer,
75
90
  } from "./surfaces/surfaces.ts";
76
91
  import { registerBtw } from "./vendor/btw/btw.ts";
@@ -88,7 +103,7 @@ function writeT3Sentinel(
88
103
  try {
89
104
  const dir = workflowDir(cwd);
90
105
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
91
- writeFileSync(
106
+ atomicWriteFileSync(
92
107
  join(dir, ".perk-t3.json"),
93
108
  `${JSON.stringify({
94
109
  source,
@@ -102,7 +117,6 @@ function writeT3Sentinel(
102
117
  pi_session_id: state.pi_session_id ?? null,
103
118
  active_plan_ref: state.active_plan_ref ?? null,
104
119
  })}\n`,
105
- "utf8",
106
120
  );
107
121
  } catch {
108
122
  // never throw from a probe
@@ -116,12 +130,18 @@ export default function (pi: ExtensionAPI) {
116
130
  // both session_start AND session_tree below. enter/exit are the surface the gated stages consume.
117
131
  const gating = registerToolGating(pi);
118
132
 
133
+ // Run-owned disposable scratch guidance for every eligible write-capable model turn. One
134
+ // activation-scoped provisioner shares retry/warning suppression with the isolated /btw side
135
+ // session; no model tool or process-global temp environment is introduced.
136
+ const agentScratch = createAgentScratchProvisioner();
137
+ registerAgentScratch(pi, agentScratch);
138
+
119
139
  // Vendored `btw`: a `/btw` human-only side-chat popover backed by an isolated in-memory
120
140
  // AgentSession. Takes `gating` for the gate-mirror — its side-session toolset + cache key follow
121
141
  // perk's read-only gate (`sideSessionTools`), so the isolated session never bypasses the read-only
122
142
  // guarantee. Its `ctx.ui.custom` overlay is the ONE sanctioned charter exception (§6 D6): human-
123
143
  // invoked only, `hasUI`-gated, no model tool, not a stage/door — never machine-reachable.
124
- registerBtw(pi, gating);
144
+ registerBtw(pi, gating, agentScratch);
125
145
 
126
146
  // Vendored `whimsical`: flavors pi's default working-message label with a random phrase per
127
147
  // turn, via the headless-no-op `setWorkingMessage` surfaces seam. Always on, no config toggle.
@@ -150,8 +170,15 @@ export default function (pi: ExtensionAPI) {
150
170
  // `plan_review`, perk's UNIVERSAL review door: plannotator-selected → the event-bus
151
171
  // bridge; ANY other selection → the first-party in-TUI editor review. It takes `gating` only to
152
172
  // COMPOSE the approvalSave seam on an APPROVED review (auto-save → D1a gate exit) — Invariant 1
153
- // holds: the door composes the gate through the seam, never owns it.
154
- registerPlanReview(pi, gating);
173
+ // holds: the door composes the gate through the seam, never owns it. The injected wave-launch
174
+ // deps power the plannotator launch chooser (§8.23): the presence probe + the two door open
175
+ // cores are composed HERE so planReview.ts imports nothing from door modules (the value-import
176
+ // cycle break — planReviewBrowser.ts already value-imports planReview.ts).
177
+ registerPlanReview(pi, gating, {
178
+ present: () => plannotatorPresent(pi),
179
+ plan: (ctx, opts) => openPlanReviewSurface(pi, ctx, gating, opts),
180
+ objective: (ctx, opts) => openObjectiveReviewSurface(pi, ctx, gating, opts),
181
+ });
155
182
 
156
183
  // Objective-author context injection (the objective mirror of plan mode's authoring
157
184
  // half). Keyed off (read-only gate AND stage === objective-author); planMode defers to it.
@@ -179,27 +206,33 @@ export default function (pi: ExtensionAPI) {
179
206
  }
180
207
  const registryOk = registryStages > 0;
181
208
 
182
- // The composed `perk` status handle (charter D2): one slot, ordered objective
183
- // checkpoints segments. Created once here (no hidden module state) and threaded into the two
184
- // segment publishers below; the footer reads it back via get/subscribe.
209
+ // The single-value `perk` status handle (charter D2): one slot carrying the objective
210
+ // segment. Created once here (no hidden module state) and threaded into the objective
211
+ // publisher below; the footer reads it back via get/subscribe.
185
212
  const perkStatus = createPerkStatus();
186
213
 
187
- // Install the perk-owned footer once per session (charter D2/D7). Once-only: pi's
188
- // dispose contract for a REPLACED footer factory is unverified, so re-installing on every
189
- // session_start (reload) could leak the previous handle subscription.
190
- let footerInstalled = false;
191
-
192
- // Transcript marker for `perk:workflow-state` deltas (audit §2.3): the renderer body lives in
193
- // surfaces.ts, this registration is wiring, and the seam carries the typeof feature-detect
194
- // (pre-0.80.4 hosts stay inert). One registration covers every workflow-state appender.
214
+ // The generic full report-detail entry and the `perk:workflow-state` transition marker. Renderer
215
+ // bodies live in surfaces.ts; registration is wiring through the pre-0.80.4-safe seam. The report
216
+ // family is appended by command-attached sinks; one workflow registration covers every appender.
217
+ registerTranscriptRenderer(pi, REPORT_DETAIL_TYPE, reportDetailEntryRenderer);
195
218
  registerTranscriptRenderer(pi, WORKFLOW_STATE_TYPE, workflowStateEntryRenderer);
196
219
 
220
+ // The hunk watch feedback receiver controller (contracts §8.58) — factory-scoped (no module
221
+ // globals). Synced from session_start/session_tree below; closed on session_shutdown so the
222
+ // consumer lease releases with the session. A stale /reload predecessor instance is retired
223
+ // by the lease fencing (fresh token per same-identity reacquire + verify-before-inject).
224
+ const feedbackReceiver = createHunkFeedbackReceiver(pi);
225
+ pi.on("session_shutdown", async () => {
226
+ feedbackReceiver.close();
227
+ });
228
+
197
229
  pi.on("session_start", async (_event, ctx) => {
198
230
  const branchEntries = () => branchOf(ctx);
199
231
  const sessionFile = ctx.sessionManager.getSessionFile();
200
232
  const currentSessionId = sessionFile ? basename(sessionFile) : null;
201
233
 
202
- // Headless-safe linkage failure: loud (notify if UI + stderr), non-fatal, leaves unclaimed.
234
+ // Terminal-safe linkage failure: managed headline when headful, complete stderr when headless
235
+ // (plus the explicit RPC mirror); non-fatal and leaves the run unclaimed.
203
236
  const reportError = (message: string) => {
204
237
  report(ctx, "workflow-state linkage error", "error", message, { alsoLog: true });
205
238
  };
@@ -211,6 +244,11 @@ export default function (pi: ExtensionAPI) {
211
244
  cwd: ctx.cwd,
212
245
  });
213
246
 
247
+ // The session-audit exact-vintage stamp (§8.3), recorded by every run-identity arm below
248
+ // (claim/fork/adopt/mint); undefined on the perkVersion() failure sentinel, which drops the
249
+ // key on serialize and leaves the session on the timestamp-estimate arm.
250
+ const stamp = versionStamp(version);
251
+
214
252
  // `claim`/`adopt` carry no prior branch state (adopt's is written by its arm below).
215
253
  let resolved: WorkflowState =
216
254
  decision.action === "claim" || decision.action === "adopt" ? {} : decision.state;
@@ -226,6 +264,7 @@ export default function (pi: ExtensionAPI) {
226
264
  run_id: decision.runId,
227
265
  pi_session_id: currentSessionId ?? undefined,
228
266
  mode: handoff.mode,
267
+ perk_version: stamp,
229
268
  // Record the launched stage so the interior can tell e.g. objective-author from plan
230
269
  // (both are read-only) and inject the right authoring context (planMode vs objectiveAuthor).
231
270
  stage: handoff.stage,
@@ -247,13 +286,26 @@ export default function (pi: ExtensionAPI) {
247
286
  }
248
287
  }
249
288
  } else if (decision.action === "fork") {
250
- // Inherited a run_id from a different session file → isolate the child's scratch.
251
- ensureRunScratch(ctx.cwd, decision.childRunId);
289
+ // Inherited a run_id from a different session file → isolate the child's scratch. A static
290
+ // redirect or filesystem failure is loud but does not prevent the derived workflow identity
291
+ // from settling; later eligible turns retry through the agent-scratch resolver.
292
+ try {
293
+ ensureRunScratch(ctx.cwd, decision.childRunId);
294
+ } catch (error) {
295
+ report(
296
+ ctx,
297
+ "run scratch",
298
+ "warning",
299
+ `could not create fork run root for ${decision.childRunId}: ${String(error)}`,
300
+ { alsoLog: true },
301
+ );
302
+ }
252
303
  const data: WorkflowState = {
253
304
  run_id: decision.childRunId,
254
305
  pi_session_id: currentSessionId ?? undefined,
255
306
  predecessor: decision.parentRunId,
256
307
  mode: decision.state.mode,
308
+ perk_version: stamp,
257
309
  };
258
310
  pi.appendEntry(WORKFLOW_STATE_TYPE, data);
259
311
  resolved = data;
@@ -264,12 +316,23 @@ export default function (pi: ExtensionAPI) {
264
316
  // the launched session: never re-consume the handoff (its pi_session_id keeps the true
265
317
  // claimer), no `stage` (no stage impersonation / stage-binding injection), and no
266
318
  // implementation/main pointer capture (resolveRunStage stays null for adopt).
267
- ensureRunScratch(ctx.cwd, decision.childRunId);
319
+ try {
320
+ ensureRunScratch(ctx.cwd, decision.childRunId);
321
+ } catch (error) {
322
+ report(
323
+ ctx,
324
+ "run scratch",
325
+ "warning",
326
+ `could not create adopted run root for ${decision.childRunId}: ${String(error)}`,
327
+ { alsoLog: true },
328
+ );
329
+ }
268
330
  const data: WorkflowState = {
269
331
  run_id: decision.childRunId,
270
332
  pi_session_id: currentSessionId ?? undefined,
271
333
  predecessor: decision.parentRunId,
272
334
  mode: decision.mode,
335
+ perk_version: stamp,
273
336
  };
274
337
  pi.appendEntry(WORKFLOW_STATE_TYPE, data);
275
338
  resolved = data;
@@ -279,7 +342,11 @@ export default function (pi: ExtensionAPI) {
279
342
  // dirs are the accessor's job; provenance is recorded separately. A failed cold claim above never
280
343
  // falls here (claim stays a loud unclaimed error).
281
344
  const runId = mintRunId();
282
- const data: WorkflowState = { run_id: runId, pi_session_id: currentSessionId ?? undefined };
345
+ const data: WorkflowState = {
346
+ run_id: runId,
347
+ pi_session_id: currentSessionId ?? undefined,
348
+ perk_version: stamp,
349
+ };
283
350
  const okAppend = appendWorkflowState(pi, ctx, {
284
351
  data,
285
352
  field: "run_id",
@@ -377,6 +444,20 @@ export default function (pi: ExtensionAPI) {
377
444
  });
378
445
  }
379
446
 
447
+ // The hunk watch feedback receiver (§8.58): sync strictly AFTER the run-identity claim and
448
+ // the plan-ref reconciliation above, so an unclaimed or mislinked session never touches the
449
+ // outbox. Eligibility (interactive TUI + implement stage + non-adopted + settled identity +
450
+ // plan-ref match against one fresh cache read) is evaluated inside sync; every ineligible
451
+ // shape closes any open inbox. Never throws (the controller contains its own failures).
452
+ feedbackReceiver.sync(ctx, {
453
+ stage: implStage,
454
+ adopted: decision.action === "adopt",
455
+ runId: resolved.run_id ?? null,
456
+ piSessionId: currentSessionId,
457
+ activePlanRef: resolved.active_plan_ref ?? null,
458
+ mode: ctx.mode ?? null,
459
+ });
460
+
380
461
  // Soft version-parity drift signal: pi can lazy-install / load a stale `npm:@mgiles/perk`, so the
381
462
  // extension actually running may differ from the `perk` CLI that launched it. The local launch
382
463
  // seam injects PERK_CLI_VERSION; compare it against this extension's own `perkVersion()`. Soft +
@@ -397,11 +478,16 @@ export default function (pi: ExtensionAPI) {
397
478
  // Charter D7: perk identity is standing footer state, not a transition — the
398
479
  // `v<version> loaded` toast (and its headless stderr mirror) is retired. D5 is rescinded:
399
480
  // perk keeps pi's default working indicator (no setWorkingIndicator call anywhere).
481
+ // Install on EVERY headful session_start: pi ≥ 0.84's `setExtensionFooter` explicitly
482
+ // disposes a replaced footer factory (verified at 0.84.1), and `resetExtensionUI` restores
483
+ // the built-in footer on /reload and before session replacement — both paths also re-run
484
+ // this extension factory, so repeated installs leak nothing and each install's deps
485
+ // closures capture the current event's ctx.
400
486
  // Footer-seam install-site vacating: under a foreign `[providers] footer` selection perk does
401
487
  // NOT install its own footer, leaving the foreign footer (`pi-powerline-footer` / `pi-bar`) as
402
- // the sole footer surface. perk's objective/checkpoints progress still reaches it via the
403
- // composed `perk` setStatus slot. Fail-safe: any config-read error resolves to install.
404
- if (ctx.hasUI && !footerInstalled && isPerkFooterReferenceSelected(ctx.cwd)) {
488
+ // the sole footer surface. perk's objective progress still reaches it via the
489
+ // single-value `perk` setStatus slot. Fail-safe: any config-read error resolves to install.
490
+ if (ctx.hasUI && isPerkFooterReferenceSelected(ctx.cwd)) {
405
491
  installPerkFooter(ctx, {
406
492
  identity: `perk v${version}`,
407
493
  status: perkStatus,
@@ -413,7 +499,6 @@ export default function (pi: ExtensionAPI) {
413
499
  return usage ? { percent: usage.percent, contextWindow: usage.contextWindow } : null;
414
500
  },
415
501
  });
416
- footerInstalled = true;
417
502
  }
418
503
 
419
504
  if (process.env.PERK_SELFCHECK) {
@@ -421,7 +506,7 @@ export default function (pi: ExtensionAPI) {
421
506
  const dir = workflowDir(ctx.cwd);
422
507
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
423
508
  // The gate sentinel (unchanged — those gates parse this line).
424
- writeFileSync(
509
+ atomicWriteFileSync(
425
510
  join(dir, ".perk-loaded"),
426
511
  `perk ${version} loaded; shared=${sharedOk ? "ok" : "miss"}; ` +
427
512
  `registry=${registryOk ? "ok" : "miss"} stages=${registryStages}; hasUI=${ctx.hasUI}\n`,
@@ -444,6 +529,17 @@ export default function (pi: ExtensionAPI) {
444
529
  } catch (error) {
445
530
  console.error(`perk: tool-gating sync failed on session_tree — ${error}`);
446
531
  }
532
+ // Re-sync the feedback receiver from the LWW-rebuilt state (§8.58). `adopted: false` is
533
+ // right here: an env-adopted child's fresh branch carries no stage, so the stage gate
534
+ // alone keeps it inert on tree navigation.
535
+ feedbackReceiver.sync(ctx, {
536
+ stage: state.stage ?? null,
537
+ adopted: false,
538
+ runId: state.run_id ?? null,
539
+ piSessionId: state.pi_session_id ?? null,
540
+ activePlanRef: state.active_plan_ref ?? null,
541
+ mode: ctx.mode ?? null,
542
+ });
447
543
  if (process.env.PERK_SELFCHECK) {
448
544
  writeT3Sentinel(ctx.cwd, "tree", state, ctx.mode ?? null);
449
545
  }
@@ -469,11 +565,6 @@ export default function (pi: ExtensionAPI) {
469
565
  // The `gist_draft` working-gist file tool (the third draft carve-out).
470
566
  registerGistDraft(pi);
471
567
 
472
- // The universal `ask_user_question` tool: lets a model interactively ask the human a
473
- // clarifying question (free-text or multiple-choice). Registered in the factory so it exists
474
- // before the gate snapshots tools; its name is in READ_ONLY_TOOLS so it survives plan mode.
475
- registerAskUser(pi);
476
-
477
568
  // Lifecycle gates: the dirty-repo switch/fork guard + the guard-only `/implement`.
478
569
  registerLifecycleGates(pi);
479
570
 
@@ -487,9 +578,15 @@ export default function (pi: ExtensionAPI) {
487
578
  registerLand(pi);
488
579
  registerLearn(pi);
489
580
 
490
- // The warm `/address` review loop: the `resolve_review_threads` tool + `/address`
581
+ // The warm stacked-delivery surface (§8.51): `/objective-stack` (read) +
582
+ // `/objective-sync`/`/objective-recover` (drives) + the four typed stack tools. Takes
583
+ // `gating` for the driving commands' gate-on soft refusal (stack sync/recovery mutates
584
+ // published branches; the stack tools never join READ_ONLY_TOOLS).
585
+ registerObjectiveStack(pi, gating);
586
+
587
+ // The warm `/address` review loop: the submit-then-resolve `finalize_address` tool + `/address`
491
588
  // command. Classify-then-act (the verbose feedback fetch + classification runs in an isolated
492
- // spawned child; the parent fixes actionable items and batch-resolves the threads).
589
+ // spawned child; the parent fixes actionable items and finalizes the committed repairs).
493
590
  registerAddress(pi);
494
591
 
495
592
  // The warm `/pr-review` door: automated code review in a FRESH, isolated subagent that
@@ -506,6 +603,24 @@ export default function (pi: ExtensionAPI) {
506
603
  // doors ride (contracts §8.4) — neither door registers tools of its own.
507
604
  registerSubmitPrReview(pi);
508
605
 
606
+ // The flow-scoped review-wave pair (`start_review_wave`/`collect_review_wave`) both human
607
+ // review doors drive: non-blocking adversarial-review launch + the typed collect, flow-scoped
608
+ // via the session's pending-wave guard.
609
+ registerReviewWaveTools(pi);
610
+ registerAuditWave(pi);
611
+ registerHarvestWave(pi);
612
+ registerDreamWave(pi);
613
+
614
+ // The flow-scoped draft-review-wave pair (`start_draft_review_wave`/
615
+ // `collect_draft_review_wave`) the draft-review door drives: non-blocking draft-review
616
+ // launch over the door-primed context + the typed collect.
617
+ registerDraftReviewWaveTools(pi);
618
+
619
+ // The door-primed browser annotation tool (`push_annotations`): the browser door primes the
620
+ // surface handle on open and clears it on settle/degrade — the tool refuses outside a
621
+ // door-opened flow.
622
+ registerAnnotationPushTool(pi);
623
+
509
624
  // The warm `/pr-review-terminal` door: the terminal review entry — hunk always, no provider
510
625
  // dispatch (the command IS the selection); posting rides `submit_pr_review` above.
511
626
  registerPrReviewTerminal(pi);
@@ -515,34 +630,31 @@ export default function (pi: ExtensionAPI) {
515
630
  // human's own platform-post from the UI, with `submit_pr_review` for request-changes only.
516
631
  registerPrReviewBrowser(pi);
517
632
 
633
+ // The warm `/plan-review-browser` door: the summonable streaming draft review — the
634
+ // plannotator plan-review browser on the working plan draft, draft reviewers streaming
635
+ // phrase-anchored findings in; APPROVE auto-saves via the approvalSave seam, DENY returns a
636
+ // model-mediated revision round.
637
+ registerPlanReviewBrowser(pi, gating);
638
+
639
+ // The warm `/objective-review-browser` door: the summonable streaming objective-draft review
640
+ // — the plannotator plan-review browser on the RENDERED working objective draft, draft
641
+ // reviewers streaming phrase-anchored findings in; APPROVE auto-saves via the
642
+ // objectiveApprovalSave seam, Direct Edits = a model-mediated revise round (never auto-saved).
643
+ registerObjectiveReviewBrowser(pi, gating);
644
+
518
645
  // The read-only CI executor: the `run_ci` tool + `/ci` command + `--allow-project-ci`
519
646
  // flag. Runs the project's `[ci]` named checks deterministically and reports (never fixes/loops).
520
647
  registerCiExecutor(pi);
521
648
 
522
- // perk-owned checkpoints: seed from the plan body's `## Steps`, advance on `[DONE:n]`.
523
- // Inert when no step list is present (perk plans are prose). Own `session_start`/`session_tree`/
524
- // `turn_end` handlers (coexist with the others; pi.on supports multiple handlers per event).
525
- // Todo-seam deferral: perk is the reference todo provider (`perk-checkpoints`); these
526
- // runtime surfaces step aside when a foreign `[providers] todo` is selected (the todo-seam mirror
527
- // of planMode's plan-seam deferral) — silent on the event handlers, announced on `/checkpoints`.
528
- registerCheckpoints(pi, perkStatus);
529
-
530
- // The FIRST 3rd-party todo adapter (the todo-seam mirror of registerPlanAdapterTombell).
531
- // Injection-only: inert unless `[providers] todo = "juicesharp-todo"` is selected AND the session
532
- // is an active workflow. It carries perk's implement-progress discipline onto `@juicesharp/rpiv-
533
- // todo`'s checklist overlay (perk's own checkpoints deferred). No `gating` argument —
534
- // the shim NEVER arbitrates tools (Invariant 1); no registration-time vacating (no command-name
535
- // collision on the todo seam, unlike the plan seam); never writes `perk:checkpoint`.
536
- registerTodoAdapterJuicesharp(pi);
537
-
538
649
  // The objective substrate: `/objective` set/clear, budget accounting, threshold
539
650
  // compaction, all keyed off the now-live `active_objective`. Inert when no objective is active.
540
651
  // (The deterministic objective mechanics live in the Python plane: `perk objective …`.)
541
652
  registerObjective(pi, perkStatus);
542
653
 
543
- // The warm `/commit-and-compact` utility door: drive a commit of the work so far, then
544
- // compact the session once HEAD has actually advanced (clean/read-only trees compact
545
- // immediately; no commit → compaction skipped, loudly). Human-only — no tool twin.
654
+ // The warm `/commit-and-compact` utility door: drive a commit of the work so far, compact once
655
+ // a successful outcome is known, then completion-gate an automatic evidence-first continuation
656
+ // (clean/read-only trees compact immediately; no commit → no compaction or continuation).
657
+ // Human-only — no tool twin.
546
658
  registerCommitAndCompact(pi, gating);
547
659
 
548
660
  // The warm `objective_save` door: the `objective_save` tool + `/objective-save` command
@@ -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).