@mgiles/perk 3.1.0 → 3.3.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 (211) hide show
  1. package/README.md +5 -0
  2. package/extension/authoring/gist/draft.ts +198 -0
  3. package/extension/authoring/gist/prose.ts +46 -0
  4. package/extension/authoring/gist/review.ts +133 -0
  5. package/extension/authoring/gist/save.ts +118 -0
  6. package/extension/authoring/objective/draft.ts +345 -0
  7. package/extension/{factories/objectiveDreamReport.ts → authoring/objective/dreamReportGate.ts} +74 -131
  8. package/extension/authoring/objective/planning.ts +124 -0
  9. package/extension/authoring/objective/prose.ts +103 -0
  10. package/extension/authoring/objective/review.ts +128 -0
  11. package/extension/authoring/objective/save.ts +224 -0
  12. package/extension/authoring/plan/draft.ts +84 -0
  13. package/extension/authoring/plan/prose.ts +41 -0
  14. package/extension/authoring/plan/review.ts +269 -0
  15. package/extension/authoring/plan/save.ts +256 -0
  16. package/extension/authoring/plan/source.ts +82 -0
  17. package/extension/authoring/refinement/context.ts +468 -0
  18. package/extension/authoring/refinement/draft.ts +261 -0
  19. package/extension/authoring/refinement/prose.ts +79 -0
  20. package/extension/authoring/refinement/review.ts +111 -0
  21. package/extension/authoring/refinement/save.ts +119 -0
  22. package/extension/authoring/review/approvalGate.ts +34 -0
  23. package/extension/authoring/review/draftContext.ts +68 -0
  24. package/extension/codeReview/automated.ts +352 -0
  25. package/extension/codeReview/submission.ts +229 -0
  26. package/extension/delivery/address.ts +295 -0
  27. package/extension/delivery/ci.ts +355 -0
  28. package/extension/delivery/commitCompact.ts +93 -0
  29. package/extension/delivery/conflictResolution.ts +247 -0
  30. package/extension/delivery/ready.ts +193 -0
  31. package/extension/delivery/stackConflict.ts +361 -0
  32. package/extension/delivery/stackObjective.ts +16 -0
  33. package/extension/delivery/stackReconcile.ts +165 -0
  34. package/extension/delivery/submit.ts +171 -0
  35. package/extension/index.ts +369 -359
  36. package/extension/learning/analystWave.ts +324 -0
  37. package/extension/learning/audit.ts +667 -0
  38. package/extension/learning/capture.ts +92 -0
  39. package/extension/learning/containment.ts +104 -0
  40. package/extension/{waves/dreamWave.ts → learning/dream.ts} +112 -94
  41. package/extension/learning/dreamAnalysis.ts +435 -0
  42. package/extension/{waves/dreamReducerWave.ts → learning/dreamReducer.ts} +46 -41
  43. package/extension/{waves → learning}/dreamReport.ts +35 -31
  44. package/extension/learning/harvest.ts +491 -0
  45. package/extension/learning/prose.ts +66 -0
  46. package/extension/learning/routing.ts +79 -0
  47. package/extension/pi/v1/bashScanTimeout.ts +64 -0
  48. package/extension/{doors/prReview.ts → pi/v1/codeReview/automated.ts} +215 -310
  49. package/extension/{doors/prReviewBrowser.ts → pi/v1/codeReview/browser.ts} +125 -57
  50. package/extension/{doors/hunkHandoff.ts → pi/v1/codeReview/checkout.ts} +12 -8
  51. package/extension/{doors/reviewWaveTools.ts → pi/v1/codeReview/reviewWave.ts} +169 -116
  52. package/extension/pi/v1/codeReview/stack.ts +606 -0
  53. package/extension/pi/v1/codeReview/submit.ts +354 -0
  54. package/extension/{doors/prReviewTerminal.ts → pi/v1/codeReview/terminal.ts} +32 -27
  55. package/extension/pi/v1/contextEvidence.ts +80 -0
  56. package/extension/pi/v1/contextInjection.ts +207 -0
  57. package/extension/{doors → pi/v1/delivery}/address.ts +163 -265
  58. package/extension/pi/v1/delivery/ci.ts +570 -0
  59. package/extension/pi/v1/delivery/commitCompact.ts +201 -0
  60. package/extension/pi/v1/delivery/conflictResolverEngine.ts +425 -0
  61. package/extension/{doors → pi/v1/delivery}/land.ts +128 -60
  62. package/extension/pi/v1/delivery/ready.ts +322 -0
  63. package/extension/pi/v1/delivery/stackConflictResolver.ts +172 -0
  64. package/extension/pi/v1/delivery/stackDrive.ts +120 -0
  65. package/extension/pi/v1/delivery/stackLand.ts +223 -0
  66. package/extension/pi/v1/delivery/stackRecover.ts +265 -0
  67. package/extension/pi/v1/delivery/stackStatus.ts +237 -0
  68. package/extension/pi/v1/delivery/stackSync.ts +658 -0
  69. package/extension/pi/v1/delivery/submit.ts +389 -0
  70. package/extension/pi/v1/delivery/submitConflict.ts +186 -0
  71. package/extension/pi/v1/draftReview.ts +431 -0
  72. package/extension/{doors → pi/v1}/draftReviewWaveTools.ts +141 -151
  73. package/extension/pi/v1/gist.ts +794 -0
  74. package/extension/pi/v1/learning/audit.ts +186 -0
  75. package/extension/pi/v1/learning/dream.ts +207 -0
  76. package/extension/{doors/learnFactory.ts → pi/v1/learning/factory.ts} +18 -65
  77. package/extension/{doors/harvestWaveTools.ts → pi/v1/learning/harvest.ts} +46 -100
  78. package/extension/pi/v1/learning/learn.ts +585 -0
  79. package/extension/{doors → pi/v1}/lifecycleGates.ts +14 -59
  80. package/extension/{factories → pi/v1}/objective.ts +53 -33
  81. package/extension/pi/v1/objectiveAuthoring.ts +672 -0
  82. package/extension/pi/v1/objectiveDreamGate.ts +160 -0
  83. package/extension/pi/v1/objectivePlanning.ts +776 -0
  84. package/extension/pi/v1/objectiveRefinement.ts +1320 -0
  85. package/extension/pi/v1/objectiveReview.ts +451 -0
  86. package/extension/{doors → pi/v1}/objectiveReviewBrowser.ts +259 -172
  87. package/extension/pi/v1/plan.ts +812 -0
  88. package/extension/pi/v1/planReview.ts +820 -0
  89. package/extension/{doors → pi/v1}/planReviewBrowser.ts +228 -152
  90. package/extension/{doors/annotationPush.ts → pi/v1/providers/annotations.ts} +158 -89
  91. package/extension/pi/v1/providers/plannotator.ts +487 -0
  92. package/extension/{doors → pi/v1/providers}/plannotatorHandoff.ts +152 -34
  93. package/extension/pi/v1/providers/selection.ts +43 -0
  94. package/extension/{adapters/planAdapterTombell.ts → pi/v1/providers/tombell.ts} +43 -72
  95. package/extension/pi/v1/review.ts +538 -0
  96. package/extension/pi/v1/reviewOutcome.ts +9 -0
  97. package/extension/pi/v1/scoutWave.ts +318 -0
  98. package/extension/{doors → pi/v1}/selfcheck.ts +4 -4
  99. package/extension/session/branchWorkflowSession.ts +60 -0
  100. package/extension/session/lifecycle.ts +644 -0
  101. package/extension/session/lifecycleGates.ts +64 -0
  102. package/extension/session/saveDestination.ts +87 -0
  103. package/extension/session/workflowSession.ts +971 -0
  104. package/extension/substrate/agentScratch.ts +27 -54
  105. package/extension/substrate/bashScanTimeout.ts +181 -0
  106. package/extension/substrate/bindingDelivery.ts +38 -30
  107. package/extension/substrate/bindings.ts +4 -5
  108. package/extension/substrate/cache.ts +64 -12
  109. package/extension/substrate/childRestrictions.ts +39 -0
  110. package/extension/substrate/coldDoor.ts +17 -1
  111. package/extension/substrate/config.ts +157 -19
  112. package/extension/substrate/git.ts +88 -6
  113. package/extension/substrate/modelVisible.ts +53 -0
  114. package/extension/substrate/paths.ts +2 -7
  115. package/extension/substrate/prompts.ts +22 -0
  116. package/extension/substrate/registry.ts +2 -0
  117. package/extension/substrate/resolverLease.ts +364 -0
  118. package/extension/substrate/sessionData.ts +85 -152
  119. package/extension/substrate/toolGating.ts +279 -84
  120. package/extension/substrate/unifiedDiff.ts +1 -1
  121. package/extension/substrate/workflowState.ts +191 -166
  122. package/extension/substrate/worktreeResolverLock.ts +261 -0
  123. package/extension/surfaces/surfaces.ts +79 -27
  124. package/extension/waves/adversarialReviewWave.ts +103 -48
  125. package/extension/waves/blockedReports.ts +59 -0
  126. package/extension/waves/draftReviewWave.ts +42 -42
  127. package/extension/waves/laneIdentity.ts +77 -0
  128. package/extension/waves/objectiveExplorerWave.ts +24 -24
  129. package/extension/waves/prReviewWave.ts +89 -77
  130. package/extension/waves/reportWave.ts +438 -578
  131. package/extension/waves/reviewClassifierWave.ts +22 -22
  132. package/extension/waves/rpcAdapter.ts +100 -15
  133. package/extension/waves/scoutWave.ts +192 -0
  134. package/extension/waves/transport.ts +480 -0
  135. package/extension/worker/sdkAdapter.ts +494 -0
  136. package/extension/worker/stageExecution.ts +679 -0
  137. package/extension/workerMain.ts +18 -19
  138. package/package.json +6 -4
  139. package/prompts/_fixtures/live.yaml +98 -10
  140. package/prompts/contexts/adapters/plannotator-gist.md +6 -0
  141. package/prompts/contexts/adapters/plannotator-objective.md +6 -0
  142. package/prompts/contexts/adapters/plannotator-plan.md +8 -1
  143. package/prompts/contexts/adapters/plannotator-refinement.md +22 -0
  144. package/prompts/contexts/adapters/tombell-plan.md +4 -0
  145. package/prompts/contexts/objective-refinement.md +17 -0
  146. package/prompts/contexts/plan-authoring.md +6 -5
  147. package/prompts/contexts/read-only.md +1 -1
  148. package/prompts/stages/conflict-resolution-continuation.md +9 -0
  149. package/prompts/stages/conflict-resolution.md +4 -4
  150. package/prompts/stages/objective-author/adopt.md +1 -1
  151. package/prompts/stages/objective-author/file.md +1 -1
  152. package/prompts/stages/objective-author/seed.md +1 -1
  153. package/prompts/stages/objective-plan/guidance.md +2 -2
  154. package/prompts/stages/objective-plan/seed.md +9 -1
  155. package/prompts/stages/objective-reconcile-ready.md +7 -0
  156. package/prompts/stages/objective-reconcile.md +1 -1
  157. package/prompts/stages/objective-refine/seed.md +18 -0
  158. package/prompts/stages/objective-review-browser.md +4 -4
  159. package/prompts/stages/objective-sync.md +1 -1
  160. package/prompts/stages/plan-review-browser.md +4 -4
  161. package/prompts/stages/pr-review-browser/active.md +3 -4
  162. package/prompts/stages/pr-review-browser/foreign.md +3 -4
  163. package/prompts/stages/pr-review-terminal/active.md +3 -3
  164. package/prompts/stages/pr-review-terminal/foreign.md +3 -3
  165. package/prompts/stages/pr-review.md +3 -3
  166. package/prompts/stages/stack-review/cold.md +1 -0
  167. package/prompts/stages/stack-review-browser/stack.md +22 -0
  168. package/shared/README.md +8 -3
  169. package/shared/bindings.yaml +6 -3
  170. package/shared/contracts.md +4499 -2147
  171. package/shared/fixtures/issues-table.json +130 -0
  172. package/shared/registry.yaml +29 -1
  173. package/shared/schemas/outputs/objective-node-engagement.schema.json +318 -0
  174. package/shared/schemas/outputs/objective-stack-status.schema.json +178 -2
  175. package/shared/schemas/outputs/pr-ready.schema.json +110 -2
  176. package/shared/schemas/outputs/pr-review-context.schema.json +54 -9
  177. package/shared/schemas/outputs/pr-review-stack-context.schema.json +196 -0
  178. package/extension/adapters/planAdapterPlannotator.ts +0 -362
  179. package/extension/doors/auditWaveTools.ts +0 -352
  180. package/extension/doors/ciExecutor.ts +0 -756
  181. package/extension/doors/commitCompact.ts +0 -251
  182. package/extension/doors/dreamWaveTools.ts +0 -475
  183. package/extension/doors/learn.ts +0 -655
  184. package/extension/doors/objectiveStack.ts +0 -1143
  185. package/extension/doors/prReviewDynamic.ts +0 -276
  186. package/extension/doors/ready.ts +0 -87
  187. package/extension/doors/submit.ts +0 -347
  188. package/extension/doors/submitPrReview.ts +0 -408
  189. package/extension/factories/gistAuthor.ts +0 -94
  190. package/extension/factories/gistDraft.ts +0 -265
  191. package/extension/factories/gistSave.ts +0 -251
  192. package/extension/factories/implementHere.ts +0 -116
  193. package/extension/factories/objectiveAuthor.ts +0 -98
  194. package/extension/factories/objectiveDraft.ts +0 -466
  195. package/extension/factories/objectivePlan.ts +0 -975
  196. package/extension/factories/objectiveSave.ts +0 -363
  197. package/extension/factories/planDraft.ts +0 -140
  198. package/extension/factories/planMode.ts +0 -205
  199. package/extension/factories/planReview.ts +0 -1237
  200. package/extension/factories/planSave.ts +0 -604
  201. package/extension/factories/planTitle.ts +0 -141
  202. package/extension/substrate/structuredOutput.ts +0 -202
  203. package/extension/waves/auditWave.ts +0 -312
  204. package/extension/waves/harvestWave.ts +0 -399
  205. package/extension/waves/learnWave.ts +0 -155
  206. package/extension/waves/memoryAdapter.ts +0 -139
  207. package/extension/waves/prReviewDynamicWave.ts +0 -777
  208. package/extension/worker/readOnlySession.ts +0 -294
  209. package/extension/worker/worker.ts +0 -899
  210. package/prompts/stages/pr-review-dynamic.md +0 -7
  211. package/shared/contracts-history.md +0 -605
@@ -5,38 +5,23 @@
5
5
  // path after the confined directory has been established. The context filter removes inherited or
6
6
  // stale direct scratch custom blocks. A compaction summary may quote old prose/path text; that is
7
7
  // not a live guidance delivery or authoritative provenance, and is deliberately left intact.
8
+ //
9
+ // Delivery dedup reads Pi's OWN live context projection (`pi/v1/contextEvidence.ts`) and requires
10
+ // EXACT identity: a `custom` message of this customType whose string content equals the current
11
+ // run's rendered block byte-for-byte. Nothing looser counts — not a text-part array, a user quote,
12
+ // a marker-only match, changed bytes, a parent run's block, or plain `custom` state (`data.content`
13
+ // is state, never model delivery). A projection read failure escapes the hook to Pi's hook-error
14
+ // reporting; no guessed copy is injected.
8
15
 
9
16
  import { relative, sep } from "node:path";
10
17
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
+ import { activeContextMessages, type ContextMessage } from "../pi/v1/contextEvidence.ts";
11
19
  import { type ReportTarget, report } from "../surfaces/report.ts";
12
20
  import { agentScratchDir, ensureAgentScratch } from "./cache.ts";
13
21
  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
 
22
23
  export const AGENT_SCRATCH_CONTEXT_TYPE = "perk:agent-scratch";
23
24
 
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
25
  export interface AgentScratchBlock {
41
26
  runId: string;
42
27
  /** Repository-relative POSIX-style path carried in model context. */
@@ -59,22 +44,6 @@ export function renderAgentScratchBlock(cwd: string, runId: string): AgentScratc
59
44
  return { runId, path, marker, content };
60
45
  }
61
46
 
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
47
  export interface AgentScratchProvisioner {
79
48
  resolve(ctx: AgentScratchContext): AgentScratchBlock | null;
80
49
  }
@@ -122,29 +91,34 @@ export function createAgentScratchProvisioner(
122
91
  };
123
92
  }
124
93
 
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)),
94
+ /** Whether this exact current-run block is still directly delivered in Pi's live projection. */
95
+ function contextHasBlock(messages: readonly ContextMessage[], block: AgentScratchBlock): boolean {
96
+ return messages.some(
97
+ (message) =>
98
+ message.role === "custom" &&
99
+ message.customType === AGENT_SCRATCH_CONTEXT_TYPE &&
100
+ message.content === block.content,
132
101
  );
133
102
  }
134
103
 
135
- /** Register eligible-turn delivery and direct scratch-custom context hygiene. */
104
+ /**
105
+ * Register eligible-turn delivery and direct scratch-custom context hygiene. `eligible` is the
106
+ * composition root's `!gate && !runner` — a runner child (every perk report child) never
107
+ * provisions scratch; the module knows nothing about agent names.
108
+ */
136
109
  export function registerAgentScratch(
137
110
  pi: ExtensionAPI,
138
- provisioner: AgentScratchProvisioner = createAgentScratchProvisioner(),
111
+ provisioner: AgentScratchProvisioner,
112
+ eligible: () => boolean,
139
113
  ): void {
140
114
  pi.on("before_agent_start", async (_event, ctx) => {
141
- if (!isAgentScratchEligible(ctx)) return;
115
+ if (!eligible()) return;
142
116
 
143
- // Provision before dedup: an externally deleted directory is repaired even while the live
144
- // branch still carries this run's exact guidance block.
117
+ // Provision before dedup: an externally deleted directory is repaired even while live
118
+ // context still carries this run's exact guidance block (and before any projection read).
145
119
  const block = provisioner.resolve(ctx);
146
120
  if (block === null) return;
147
- if (branchHasBlock(branchOf(ctx), block)) return;
121
+ if (contextHasBlock(activeContextMessages(ctx), block)) return;
148
122
  return {
149
123
  message: {
150
124
  customType: AGENT_SCRATCH_CONTEXT_TYPE,
@@ -155,8 +129,7 @@ export function registerAgentScratch(
155
129
  });
156
130
 
157
131
  pi.on("context", async (event, ctx) => {
158
- const eligible = isAgentScratchEligible(ctx);
159
- const block = eligible ? provisioner.resolve(ctx) : null;
132
+ const block = eligible() ? provisioner.resolve(ctx) : null;
160
133
  let keptCurrent = false;
161
134
  return {
162
135
  messages: event.messages.filter((message) => {
@@ -0,0 +1,181 @@
1
+ // The bash scan-timeout guard's pure policy: the default `timeout` for gitignore-blind scans issued
2
+ // through Pi's `bash` tool, the command classifier, and the expiry steer. Recursive `grep -r…` and
3
+ // `find` without `-maxdepth` ignore `.gitignore`, so from a checkout carrying `node_modules/`,
4
+ // `.venv/`, `.worktrees/` and the like they walk everything — observed running for minutes to the
5
+ // better part of an hour with no `timeout` on the call, so nothing stopped them. Every
6
+ // legitimately scoped scan finished in single-digit seconds. The hooks that apply this policy
7
+ // (the `tool_call` injection + the `tool_result` note) live in the Pi adapter home,
8
+ // `pi/v1/bashScanTimeout.ts`; the contract is contracts.md §8.69.
9
+ //
10
+ // The override contract: an explicit `timeout` of ANY value on the call is the model's override —
11
+ // never rewritten or capped. Expiry is detected only from Pi's own terminal `Command timed out
12
+ // after N seconds` status line, never from a line the command printed.
13
+ //
14
+ // Classifier design: regex-only over each physical line's quote-aware top-level segments (the
15
+ // gate's splitter, reused so both hooks agree on what a segment is and a flag in a LATER pipeline
16
+ // stage — `grep -n foo f | sort -r` — is never attributed to the grep). The command word is
17
+ // matched at ANY command boundary inside a segment rather than as the segment's leading word, so
18
+ // wrapper prefixes (`env`/`nice`/`time`/`nohup`/`xargs`), nested shells (`sh -c '…'`) and `$(…)`
19
+ // substitutions are covered without enumerating wrappers. A grep's recursion flag is searched over
20
+ // its full tail (over-matches only); a find's `-maxdepth` exemption is searched only in the find's
21
+ // OWN window — up to the next command word or a quoted-in sequencing operator, quoted spans blanked
22
+ // — so neither a later bounded find nor a quoted argument ever exempts an unbounded one.
23
+ // Over-matching is tolerated by design — a spurious 30s cap on a fast command is harmless, a
24
+ // missed scan is the bug. The accepted over-matches (pinned as such in the tests, so any future
25
+ // tightening is deliberate): `echo grep -r`, `git grep -rn foo`, `rg -n 'grep -rn foo' src/`,
26
+ // `grep -n "x -r y" f`.
27
+ // Incidental precision, not a goal: a quote-adjacent cluster (`"-r"`, `'grep -r'`) is not a flag
28
+ // position, so quoted flags do not match.
29
+
30
+ import { splitTopLevelSegments } from "./toolGating.ts";
31
+
32
+ /**
33
+ * The injected default, in seconds — the ONE source of truth for the number. The managed
34
+ * `AGENTS.md` bullet the Python plane renders (`_agents_inner()`) mirrors it verbatim and a Python
35
+ * parity test pins the mirror; change both in the same turn.
36
+ */
37
+ export const SCAN_TIMEOUT_SECONDS = 30;
38
+
39
+ export type ScanKind = "recursive-grep" | "unbounded-find";
40
+
41
+ /**
42
+ * A grep-family or `find` command word at a command boundary: segment start, whitespace, a quote,
43
+ * a backtick, `(` (as in `$(`) or a `\` alias-bypass, with an optional `dir/` path prefix, followed
44
+ * by whitespace or end. `ast-grep` never matches (`-` precedes `grep`); `docs/find.md` and
45
+ * `findings/` never match (`.`/`i` follow `find`).
46
+ */
47
+ const COMMAND_WORD =
48
+ /(?:^|[\s'"`(\\])(?:[\w./-]*\/)?(grep|egrep|fgrep|zgrep|zegrep|zfgrep|bzgrep|xzgrep|rgrep|find)(?=\s|$)/g;
49
+
50
+ /**
51
+ * A recursion flag in a grep's tail: a whitespace-delimited short cluster containing `r`/`R`
52
+ * (`-r`, `-rn`, `-rniE`, `-Rl`, `-nr`, `-rnA3`) or one of the explicit long forms. Other long
53
+ * options (`--color=never`, `--exclude-dir=…`) never match — the cluster arm cannot consume the
54
+ * second `-`.
55
+ */
56
+ const RECURSIVE_FLAG =
57
+ /(?:^|\s)(?:--recursive|--dereference-recursive|--directories=recurse|(?:-d|--directories)\s+recurse|-[A-Za-z0-9]*[rR][A-Za-z0-9]*)(?=\s|$)/;
58
+
59
+ /** A depth bound in a find's own window — the only thing that exempts a `find` (`-prune`/`-not -path` still walk). */
60
+ const MAXDEPTH_FLAG = /(?:^|\s)-maxdepth(?=\s|$)/;
61
+
62
+ /**
63
+ * A sequencing operator that survived the top-level split — i.e. one inside a quoted nested shell
64
+ * (`sh -c 'find . -type f; find . -maxdepth 1'`) or a lone `&`. It ends a `find`'s exemption
65
+ * window (see `findWindow`).
66
+ */
67
+ const INNER_OPERATOR = /[;|&]/;
68
+
69
+ /**
70
+ * Pi's terminal status line for an expired bash call. No `m` flag — `$` is end-of-string, so the
71
+ * line must be the LAST thing in the text (Pi's `appendStatus` always places the status last), never
72
+ * a line the command itself printed mid-output.
73
+ */
74
+ const TIMEOUT_STATUS = /(?:^|\n)Command timed out after (\S+) seconds$/;
75
+
76
+ /**
77
+ * Classify a bash command as a gitignore-blind scan, or `null`. Pure and offline-testable. Per
78
+ * physical line, per quote-aware top-level segment, in order: the first segment that classifies
79
+ * decides. Fast non-scans (`rg`, `fd`, `ast-grep`, `grep -n foo file`) are `null`.
80
+ */
81
+ export function classifyScanCommand(command: string): ScanKind | null {
82
+ for (const line of command.split("\n")) {
83
+ for (const segment of splitTopLevelSegments(line)) {
84
+ const kind = classifySegment(segment);
85
+ if (kind !== null) return kind;
86
+ }
87
+ }
88
+ return null;
89
+ }
90
+
91
+ /** One command-word occurrence: the word, where its tail starts, and where the next occurrence begins. */
92
+ type Occurrence = { word: string; tailStart: number; nextStart: number };
93
+
94
+ function occurrences(segment: string): Occurrence[] {
95
+ // A fresh matcher per segment: the shared regex is global (stateful `lastIndex`).
96
+ const words = new RegExp(COMMAND_WORD.source, "g");
97
+ const found: Occurrence[] = [];
98
+ for (const match of segment.matchAll(words)) {
99
+ const word = match[1];
100
+ if (word === undefined) continue;
101
+ const previous = found.at(-1);
102
+ if (previous !== undefined) previous.nextStart = match.index;
103
+ found.push({ word, tailStart: match.index + match[0].length, nextStart: segment.length });
104
+ }
105
+ return found;
106
+ }
107
+
108
+ /**
109
+ * `text` with every single-/double-quoted span replaced by ONE space (token boundaries kept,
110
+ * quoted content gone). The same quote model as the segment splitter (no backslash-escape
111
+ * handling; an unterminated quote runs to the end).
112
+ */
113
+ function blankQuoted(text: string): string {
114
+ let out = "";
115
+ let quote: '"' | "'" | null = null;
116
+ for (const ch of text) {
117
+ if (quote !== null) {
118
+ if (ch === quote) quote = null;
119
+ continue;
120
+ }
121
+ if (ch === '"' || ch === "'") {
122
+ quote = ch;
123
+ out += " ";
124
+ continue;
125
+ }
126
+ out += ch;
127
+ }
128
+ return out;
129
+ }
130
+
131
+ /**
132
+ * The text in which THIS `find`'s `-maxdepth` may appear: its tail up to the next command word,
133
+ * with quoted spans blanked, then cut at the first sequencing operator that survived the
134
+ * top-level split (a quoted nested shell's `;`/`|`/`&`). Blanking first means only an UNQUOTED
135
+ * standalone `-maxdepth` option counts (a `-printf 'x -maxdepth y'` format or a `-name
136
+ * "-maxdepth"` operand never exempts) and a quoted `;` inside a `-name` pattern never ends the
137
+ * window. The exemption is the inverse of a match — it REMOVES a cap — so its window must be
138
+ * tight: a later bounded find (`sh -c 'find . -type f; find . -maxdepth 1'`) or another command
139
+ * after a quoted-in operator must never exempt an earlier unbounded one; shrinking the window can
140
+ * only add caps. Residual: an UNQUOTED `-maxdepth` operand value (`-name -maxdepth`) still
141
+ * exempts — not a real-world shape.
142
+ */
143
+ function findWindow(segment: string, occurrence: Occurrence): string {
144
+ const tail = blankQuoted(segment.slice(occurrence.tailStart, occurrence.nextStart));
145
+ const operator = INNER_OPERATOR.exec(tail);
146
+ return operator === null ? tail : tail.slice(0, operator.index);
147
+ }
148
+
149
+ function classifySegment(segment: string): ScanKind | null {
150
+ for (const occurrence of occurrences(segment)) {
151
+ if (occurrence.word === "rgrep") return "recursive-grep";
152
+ if (occurrence.word === "find") {
153
+ if (!MAXDEPTH_FLAG.test(findWindow(segment, occurrence))) return "unbounded-find";
154
+ continue;
155
+ }
156
+ // A grep's recursion flag is searched over its FULL tail (not a window): a permuted `-r` may
157
+ // follow a quoted pattern that itself contains a command word (`grep -n "find . -maxdepth 1"
158
+ // -r .`), and for a grep the full tail can only over-match — never miss.
159
+ if (RECURSIVE_FLAG.test(segment.slice(occurrence.tailStart))) return "recursive-grep";
160
+ }
161
+ return null;
162
+ }
163
+
164
+ /**
165
+ * The seconds from Pi's terminal timeout status when `text` (a bash error result's last text
166
+ * block) ENDS with it; `null` otherwise — including when the literal appears mid-output.
167
+ */
168
+ export function expiredAfterSeconds(text: string): string | null {
169
+ return TIMEOUT_STATUS.exec(text)?.[1] ?? null;
170
+ }
171
+
172
+ /** The steer appended to an expired scan's result (`seconds` as parsed from Pi's status line). */
173
+ export function scanTimeoutNote(kind: ScanKind, seconds: string): string {
174
+ const what = kind === "recursive-grep" ? "recursive grep" : "unbounded find";
175
+ return (
176
+ `perk: this ${what} hit the ${seconds}s timeout. Recursive \`grep -r…\` and \`find\` without ` +
177
+ "`-maxdepth` ignore `.gitignore` (they walk `node_modules`, `.venv`, `.worktrees`, …). Prefer " +
178
+ "the `grep`/`find` tools or `rg`/`fd`, which honor `.gitignore`; to run this command anyway, " +
179
+ "pass a larger explicit `timeout` (seconds) on the bash call."
180
+ );
181
+ }
@@ -14,29 +14,29 @@
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 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
21
- // `before_agent_start` the just-submitted prompt is NOT yet on the branch, so the branch scan
22
- // alone would miss a cold seed's binding suffix and double-deliver.
17
+ // injection carry it, so Mechanism A injects ONLY when neither the submitting turn's prompt NOR
18
+ // Pi's live context projection (`pi/v1/contextEvidence.ts` the header as persisted USER content
19
+ // or as an owned `perk:binding-context` custom) already carries the header (idempotent across
20
+ // turns/reloads; after compaction drops the original from model context it re-delivers, and a
21
+ // summary quoting the header never suppresses). The prompt scan is load-bearing on the launch
22
+ // turn: at `before_agent_start` the just-submitted prompt is NOT yet persisted, so the projection
23
+ // alone would miss a cold seed's binding suffix and double-deliver. The full branch is read only
24
+ // for the stage (`activeStageRender`) — eligibility survives compaction; delivery evidence is
25
+ // Pi's. A projection read failure escapes the hook to Pi's hook-error reporting rather than
26
+ // injecting a guessed copy.
23
27
  //
24
- // LBYL throughout: a missing/unreadable transclude target degrades to the nudge pointer with a
25
- // loud-but-non-fatal warning, never throws, never blocks a turn. Resolver shape `issues` are NOT
26
- // surfaced warm (the cold launch + doctor own them); only the transclude `warnings` are.
28
+ // LBYL on the render path: a missing/unreadable transclude target degrades to the nudge pointer
29
+ // with a loud-but-non-fatal warning, never throws, never blocks a turn (only a session read
30
+ // failure branch or projection escapes the hooks). Resolver shape `issues` are NOT surfaced
31
+ // warm (the cold launch + doctor own them); only the transclude `warnings` are.
27
32
 
28
33
  import { existsSync, readFileSync } from "node:fs";
29
34
  import { join } from "node:path";
30
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
35
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
36
+ import { activeContextMessages, contextCarriesMarker } from "../pi/v1/contextEvidence.ts";
31
37
  import { loadDefaultBindings, resolveBindings, type SkillBinding } from "./bindings.ts";
32
38
  import { loadPerkConfig } from "./config.ts";
33
- import {
34
- activeContextWindow,
35
- type BranchEntry,
36
- branchCarries,
37
- branchOf,
38
- rebuildWorkflowState,
39
- } from "./workflowState.ts";
39
+ import { type BranchEntry, branchOf, rebuildWorkflowState } from "./workflowState.ts";
40
40
 
41
41
  /**
42
42
  * The cross-plane dedup marker AND render header. MUST stay byte-identical to the Python cold
@@ -156,9 +156,16 @@ function stripFrontmatter(text: string): string {
156
156
  return text; // no closing delimiter — leave the text unchanged
157
157
  }
158
158
 
159
- /** Whether a cold prompt or warm injection still active in model context carries the marker. */
160
- function branchHasHeader(branch: readonly BranchEntry[]): boolean {
161
- return branchCarries(activeContextWindow(branch), BINDING_HEADER);
159
+ /**
160
+ * Whether a persisted cold prompt (user content) or a prior warm injection (the owned custom)
161
+ * still live in Pi's context projection carries the header. Throws when the projection read
162
+ * fails — the hook boundary owns that.
163
+ */
164
+ function contextHasHeader(ctx: ExtensionContext): boolean {
165
+ return contextCarriesMarker(activeContextMessages(ctx), {
166
+ customType: BINDING_CONTEXT_TYPE,
167
+ marker: BINDING_HEADER,
168
+ });
162
169
  }
163
170
 
164
171
  /** The launched stage's `stage:<id>` render, or `null` when there is no stage / nothing matches. */
@@ -171,22 +178,22 @@ function activeStageRender(cwd: string, branch: readonly BranchEntry[]): Binding
171
178
  /**
172
179
  * Register warm-door binding delivery: Mechanism A's dedup-guarded `before_agent_start` injection
173
180
  * plus a `context` strip mirroring planMode.ts / objectiveAuthor.ts (keep while the stage's
174
- * bindings are live; strip the stale custom otherwise). Inert when nothing matches the stage;
175
- * never throws. Mechanism B (`bindingSuffix`) is wired by the command modules themselves.
181
+ * bindings are live; strip the stale custom otherwise). Inert when nothing matches the stage; the
182
+ * render path never throws (only a failed session read escapes to Pi's hook-error reporting).
183
+ * Mechanism B (`bindingSuffix`) is wired by the command modules themselves.
176
184
  */
177
185
  export function registerBindingDelivery(pi: ExtensionAPI): void {
178
186
  // Mechanism A — inject the launched stage's resolved bindings as a hidden context message,
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
187
+ // but ONLY when neither the submitting turn's prompt NOR Pi's live context projection already
188
+ // carries BINDING_HEADER (the cold door's initial prompt or a prior warm inject) — the cold↔warm
181
189
  // idempotency guard. The `event.prompt` scan covers the launch turn, where the just-submitted
182
- // prompt is not yet on the branch; a worker prompt carries no header, so Mechanism A still
183
- // fires there (contracts.md §8.38).
190
+ // prompt is not yet persisted; a worker prompt carries no header, so Mechanism A still fires
191
+ // there (contracts.md §8.38). Render-before-dedup: an inert stage never reads the projection.
184
192
  pi.on("before_agent_start", async (event, ctx) => {
185
- const branch = branchOf(ctx);
186
- const rendered = activeStageRender(ctx.cwd, branch);
193
+ const rendered = activeStageRender(ctx.cwd, branchOf(ctx));
187
194
  if (rendered === null || rendered.text === null) return;
188
- if (branchHasHeader(branch)) return;
189
195
  if (event.prompt.includes(BINDING_HEADER)) return;
196
+ if (contextHasHeader(ctx)) return;
190
197
  for (const warning of rendered.warnings) console.error(`perk: ${warning}`);
191
198
  return {
192
199
  message: {
@@ -204,7 +211,8 @@ export function registerBindingDelivery(pi: ExtensionAPI): void {
204
211
  //
205
212
  // Deliberately NARROWER than planMode: it strips ONLY the BINDING_CONTEXT_TYPE custom, never a
206
213
  // user message carrying the header — a cold launch's initial prompt legitimately carries
207
- // BINDING_HEADER and must survive in context.
214
+ // BINDING_HEADER and must survive in context, even after the stage stops binding. The strip
215
+ // never reads the projection (it filters the messages Pi hands it).
208
216
  pi.on("context", async (event, ctx) => {
209
217
  const branch = branchOf(ctx);
210
218
  const rendered = activeStageRender(ctx.cwd, branch);
@@ -12,8 +12,6 @@
12
12
 
13
13
  import { readFileSync } from "node:fs";
14
14
  import { join } from "node:path";
15
- // Type-only import (config.ts value-imports this module; a value import here would cycle).
16
- import type { TomlScalar } from "./config.ts";
17
15
  import { parse } from "./miniYaml.ts";
18
16
  import { sharedDir } from "./resources.ts";
19
17
 
@@ -76,10 +74,11 @@ export function loadDefaultBindings(): SkillBinding[] {
76
74
  }
77
75
 
78
76
  /**
79
- * Parse `.perk/config.toml` `[[bindings]]` rows (scalar tables) into `SkillBinding`s. Tolerant like
80
- * the YAML reader: absent/ill-typed fields become empty strings so the *resolver* reports them.
77
+ * Parse `.perk/config.toml` `[[bindings]]` rows into `SkillBinding`s. Rows are tolerant
78
+ * unknown-valued records (like the YAML reader's entries): absent/ill-typed fields become empty
79
+ * strings so the *resolver* reports them.
81
80
  */
82
- export function parseUserBindings(rows: Array<Record<string, TomlScalar>>): SkillBinding[] {
81
+ export function parseUserBindings(rows: Array<Record<string, unknown>>): SkillBinding[] {
83
82
  return rows.map((row) => {
84
83
  const trigger = typeof row.trigger === "string" ? row.trigger : "";
85
84
  const [kind, targetId] = splitTrigger(trigger);
@@ -15,11 +15,13 @@
15
15
  // `atomicWriteFileSync` (temp file in the same directory + atomic rename) so a concurrent
16
16
  // writer can never tear a file — a reader sees either the old bytes or the new bytes, never a
17
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` /
18
+ // — the worker's `events.ndjson` (worker/stageExecution.ts) and the §8.58 hunk-watch `outbox.ndjson` /
19
19
  // `delivered.ndjson` (hunkFeedback/perkFeedback.ts / hunkFeedback/store.ts) — where O_APPEND
20
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.
21
+ // between independent processes. The §8.3 worktree resolver lock (`worktreeResolverLock.ts`)
22
+ // separately writes/fsyncs only freshly wx-created descriptors: atomic replacement would destroy
23
+ // exclusion. Atomicity alone is not mutual exclusion — ordinary artifact writes remain
24
+ // whole-file last-writer-wins.
23
25
 
24
26
  import { randomBytes } from "node:crypto";
25
27
  import {
@@ -34,7 +36,7 @@ import {
34
36
  rmSync,
35
37
  writeFileSync,
36
38
  } from "node:fs";
37
- import { join, relative } from "node:path";
39
+ import { join, relative, sep } from "node:path";
38
40
 
39
41
  /**
40
42
  * Atomically replace `path` with `content` (the interior atomic-write seam).
@@ -147,16 +149,28 @@ export function sessionDataDir(cwd: string, runId: string): string {
147
149
  return join(runScratchDir(cwd, runId), "data");
148
150
  }
149
151
 
152
+ /**
153
+ * Whether a run id can only select one child of the shared runs directory: non-empty, not
154
+ * `.`/`..`, and free of `/`, `\`, and NUL. The one run-id trust predicate — the write path
155
+ * throws through it (`assertSafeRunId`); the read-path identity seam
156
+ * (`sessionData.activeSessionRunId`) narrows through it so a hostile rebuilt run_id degrades to
157
+ * no-identity before any path derivation. Legitimate ids (ULID mints, `<parent>.<n>` fork
158
+ * derivations) all pass.
159
+ */
160
+ export function isSafeRunId(runId: string): boolean {
161
+ return (
162
+ runId.length > 0 &&
163
+ runId !== "." &&
164
+ runId !== ".." &&
165
+ !runId.includes("/") &&
166
+ !runId.includes("\\") &&
167
+ !runId.includes("\0")
168
+ );
169
+ }
170
+
150
171
  /** Reject run ids that could select anything except one child of the shared runs directory. */
151
172
  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
- ) {
173
+ if (!isSafeRunId(runId)) {
160
174
  throw new Error(`refusing unsafe run id ${JSON.stringify(runId)}`);
161
175
  }
162
176
  }
@@ -215,6 +229,44 @@ export function ensureRunScratch(cwd: string, runId: string): string {
215
229
  return dir;
216
230
  }
217
231
 
232
+ /**
233
+ * Strict current-run data namespace for exclusion and provenance reads. Aliased checkout roots
234
+ * canonicalize together; redirects within checkout-owned components refuse. Reads never create
235
+ * directories. A missing component is absent, not an I/O-error sentinel.
236
+ */
237
+ export function canonicalSessionDataDir(
238
+ cwd: string,
239
+ runId: string,
240
+ opts: { create: boolean },
241
+ ): string | null {
242
+ assertSafeRunId(runId);
243
+ const root = realpathSync(cwd);
244
+ const dir = sessionDataDir(root, runId);
245
+ let component = root;
246
+ for (const segment of relative(root, dir).split(sep)) {
247
+ component = join(component, segment);
248
+ if (opts.create) {
249
+ ensureUnredirectedDirectory(component, {
250
+ createMode: 0o755,
251
+ rejectGroupWorldWrite: true,
252
+ });
253
+ } else {
254
+ let stat: ReturnType<typeof lstatSync>;
255
+ try {
256
+ stat = lstatSync(component);
257
+ } catch (error) {
258
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
259
+ throw error;
260
+ }
261
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o022) !== 0) {
262
+ throw new Error(`refusing an unsafe session-data namespace: ${component}`);
263
+ }
264
+ }
265
+ }
266
+ if (realpathSync(dir) !== dir) throw new Error("refusing redirected session data");
267
+ return dir;
268
+ }
269
+
218
270
  /**
219
271
  * Create the private run-owned agent directory as 0700 from the outset, then re-apply that mode on
220
272
  * reuse. The mode protects against other OS users; it is not a sandbox from another process running
@@ -0,0 +1,39 @@
1
+ // The two native-child booleans (contracts.md §8.3): the pi-subagents runner bit and perk's
2
+ // report restriction packet. Neither is identity; the floor is the only thing that restricts.
3
+
4
+ const FAMILY = "perk.parent-restrictions/";
5
+ const NAMESPACE = `${FAMILY}1`;
6
+
7
+ function isRecord(value: unknown): value is Record<string, unknown> {
8
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9
+ }
10
+
11
+ /** The runner stamp pi-subagents sets on every background (runner-hosted) child. */
12
+ export function isRunnerChild(env: NodeJS.ProcessEnv): boolean {
13
+ return env.PI_SUBAGENT_CHILD === "1";
14
+ }
15
+
16
+ /**
17
+ * Runner bit AND perk's packet ⇒ floor. No packet (the delegation-dispatched writer) ⇒ no floor.
18
+ * A present packet must be exactly `{readOnly: boolean}` under perk's v1 namespace; any other
19
+ * `perk.parent-restrictions/…` key (an unsupported version — producer/consumer skew) or any
20
+ * other shape fails closed. Unrelated namespaces beside it are opaque.
21
+ */
22
+ export function decodeReadOnlyFloor(runner: boolean, raw: string | undefined): boolean {
23
+ if (!runner || raw === undefined) return false;
24
+ let envelope: unknown;
25
+ try {
26
+ envelope = JSON.parse(raw);
27
+ } catch {
28
+ return true;
29
+ }
30
+ if (!isRecord(envelope)) return true;
31
+ if (Object.keys(envelope).some((key) => key.startsWith(FAMILY) && key !== NAMESPACE)) return true;
32
+ if (!Object.hasOwn(envelope, NAMESPACE)) return false;
33
+ const value = envelope[NAMESPACE];
34
+ // Own-key check: a polluted `Object.prototype.readOnly` must never un-floor a malformed value.
35
+ if (!isRecord(value) || !Object.hasOwn(value, "readOnly") || Object.keys(value).length !== 1) {
36
+ return true;
37
+ }
38
+ return typeof value.readOnly === "boolean" ? value.readOnly : true;
39
+ }
@@ -8,7 +8,7 @@
8
8
  //
9
9
  // A door consumes it as:
10
10
  //
11
- // const r = await runColdDoor<SubmitOk>(pi, ctx, ["pr", "submit", "--json"], {
11
+ // const r = await runColdDoor<PublishedChange>(pi, ctx, ["pr", "submit", "--json"], {
12
12
  // label: "perk pr submit",
13
13
  // decode,
14
14
  // });
@@ -99,6 +99,22 @@ export function objectField(payload: ColdJson, key: string): ColdJson | undefine
99
99
  return value as ColdJson;
100
100
  }
101
101
 
102
+ /** Lenient object-list field: a non-array (or any non-object element) contributes nothing. */
103
+ export function objectListField(payload: ColdJson, key: string): ColdJson[] {
104
+ const value = payload[key];
105
+ if (!Array.isArray(value)) return [];
106
+ return value.filter(
107
+ (item): item is ColdJson => typeof item === "object" && item !== null && !Array.isArray(item),
108
+ );
109
+ }
110
+
111
+ /** Lenient string-list field: non-string elements are dropped. */
112
+ export function stringListField(payload: ColdJson, key: string): string[] {
113
+ const value = payload[key];
114
+ if (!Array.isArray(value)) return [];
115
+ return value.filter((item): item is string => typeof item === "string");
116
+ }
117
+
102
118
  /** Best-effort parse of stdout into a plain object; null on anything else. */
103
119
  function parseObject(stdout: string): ColdJson | null {
104
120
  let parsed: unknown;