@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
@@ -1,7 +1,7 @@
1
1
  // The minimal TS config port. Mirrors `perk/substrate/config.py`'s overlay: read
2
2
  // `.perk/config.toml` (committed) overlaid by `.perk/local.toml` (gitignored, local wins). The only
3
3
  // setting consumed today is an optional `[workflow]` plan-authoring addendum, appended into the
4
- // `perk:plan-context` injection (extension/factories/planMode.ts) when present.
4
+ // `perk:plan-context` injection (extension/pi/v1/plan.ts) when present.
5
5
  //
6
6
  // Deliberately dependency-free: rather than pull a runtime TOML dependency into the published
7
7
  // extension, this reads the narrow TOML subset perk actually uses — `[section]` headers +
@@ -48,9 +48,11 @@ export interface PerkConfig {
48
48
  /**
49
49
  * The agent-keyed `[models.subagents]` table: a per-agent model override for each perk-owned
50
50
  * project agent (`pr-reviewer`, `review-classifier`, `objective-explorer`, `conflict-resolver`,
51
- * `learn-analyst`, `adversarial-reviewer`, `review-angle-selector`, `draft-reviewer`,
51
+ * `learn-analyst`, `adversarial-reviewer`, `draft-reviewer`,
52
52
  * `harvest-analyst` — consumed by `run_harvest_wave` at execute time — `dream-analyst` and
53
- * `dream-reducer` — consumed by `run_dream_wave` at execute time — and the
53
+ * `dream-reducer` — consumed by `run_dream_wave` at execute time — `scout` the
54
+ * general-purpose read-only analysis lane (task-defined scope), consumed by `run_scout_wave`
55
+ * at execute time — and the
54
56
  * dev-only `session-auditor`, whose def is repo-local to perk's own repository
55
57
  * (`.pi/agents/perk-dev/session-auditor.md`, never delivered by `perk init`), so the key is
56
58
  * dormant in consumer repos). Each configured
@@ -58,8 +60,12 @@ export interface PerkConfig {
58
60
  * workflowScript call — a default flowing onto every lane, single-child runs included (as
59
61
  * /pr-review does); when a key is absent the agent's frontmatter `model` (in
60
62
  * `.pi/agents/perk/<name>.md`; the session-auditor's in its repo-local def) is the default.
61
- * (`subagents.agentOverrides` does NOT reach project agents — `pi-subagents`'
62
- * `applyBuiltinOverrides` applies only to builtins so this inline injection is the mechanism.)
63
+ * (Since pi-subagents 0.64.0, `subagents.agentOverrides` applies its FULL override set to
64
+ * custom/project agents too a settings override CAN displace a def's frontmatter-pinned
65
+ * `model:` (the pre-0.64 frontmatter-sensitive fill is gone; verified in the installed
66
+ * package source). The inline workflow-level injection remains perk's mechanism on its own
67
+ * merits: the config table is perk-owned (no settings churn), and the spawn-time
68
+ * workflow-level `model` wins over the def-level model however that def model was set.)
63
69
  * A value may carry a `:thinking` suffix (`"anthropic/claude-sonnet-4-5:high"`) or be the
64
70
  * `"inherit"` sentinel (child inherits the parent session's model) — both resolved by
65
71
  * pi-subagents on the injected value (the last-colon segment counts as thinking only when it
@@ -73,11 +79,11 @@ export interface PerkConfig {
73
79
  "conflict-resolver"?: string;
74
80
  "learn-analyst"?: string;
75
81
  "adversarial-reviewer"?: string;
76
- "review-angle-selector"?: string;
77
82
  "draft-reviewer"?: string;
78
83
  "harvest-analyst"?: string;
79
84
  "dream-analyst"?: string;
80
85
  "dream-reducer"?: string;
86
+ scout?: string;
81
87
  "session-auditor"?: string;
82
88
  };
83
89
  /**
@@ -125,8 +131,8 @@ function unescapeBasic(raw: string): string {
125
131
  * Parse the narrow TOML subset perk consumes. Returns `{ tables, arrays }`: `tables` is a
126
132
  * `{ section: { key: scalar } }` map (top-level keys under the `""` section); `arrays` is a
127
133
  * `{ name: [{ key: scalar }, ...] }` map fed by `[[name]]` array-of-tables. Scalars are quoted
128
- * strings, native `true`/`false` booleans, and numeric literals; anything else is skipped —
129
- * this is intentionally NOT a full TOML parser.
134
+ * strings (basic `"…"`/`"""…"""` and literal `'…'`/`'''…'''`), native `true`/`false` booleans,
135
+ * and numeric literals; anything else is skipped — this is intentionally NOT a full TOML parser.
130
136
  */
131
137
  export function parseTomlSubset(text: string): TomlSubset {
132
138
  const root: Record<string, TomlScalar> = {};
@@ -168,17 +174,19 @@ export function parseTomlSubset(text: string): TomlSubset {
168
174
  const value = line.slice(eq + 1).trim();
169
175
  if (key === "") continue;
170
176
 
171
- // Multi-line basic string: """ ... """ (possibly spanning lines).
172
- if (value.startsWith('"""')) {
177
+ // Multi-line strings: basic `""" ... """` (escapes honoured) or literal `''' ... '''`
178
+ // (bytes verbatim), possibly spanning lines.
179
+ const multi = value.startsWith('"""') ? '"""' : value.startsWith("'''") ? "'''" : null;
180
+ if (multi) {
173
181
  let body = value.slice(3);
174
- if (body.endsWith('"""') && body.length >= 3) {
182
+ if (body.endsWith(multi) && body.length >= 3) {
175
183
  body = body.slice(0, -3);
176
184
  } else {
177
185
  const parts: string[] = [body];
178
186
  i++;
179
187
  for (; i < lines.length; i++) {
180
188
  const raw = lines[i] ?? "";
181
- const end = raw.indexOf('"""');
189
+ const end = raw.indexOf(multi);
182
190
  if (end !== -1) {
183
191
  // A bare closing delimiter on its own line contributes no trailing content (so the
184
192
  // newline that precedes it is not appended as an empty segment).
@@ -191,7 +199,7 @@ export function parseTomlSubset(text: string): TomlSubset {
191
199
  // A leading newline immediately after the opening delimiter is trimmed (TOML rule).
192
200
  if (body.startsWith("\n")) body = body.slice(1);
193
201
  }
194
- dest[key] = unescapeBasic(body);
202
+ dest[key] = multi === '"""' ? unescapeBasic(body) : body;
195
203
  continue;
196
204
  }
197
205
 
@@ -202,6 +210,13 @@ export function parseTomlSubset(text: string): TomlSubset {
202
210
  continue;
203
211
  }
204
212
 
213
+ // Single-line literal string: '...' (no escapes; a trailing inline comment is dropped).
214
+ const literal = value.match(/^'([^']*)'/);
215
+ if (literal) {
216
+ dest[key] = literal[1] ?? "";
217
+ continue;
218
+ }
219
+
205
220
  // Unquoted scalar: strip an inline `#` comment, then read native booleans and numbers.
206
221
  const hash = value.indexOf("#");
207
222
  const bare = (hash === -1 ? value : value.slice(0, hash)).trim();
@@ -326,11 +341,11 @@ const SUBAGENT_KEYS = [
326
341
  "conflict-resolver",
327
342
  "learn-analyst",
328
343
  "adversarial-reviewer",
329
- "review-angle-selector",
330
344
  "draft-reviewer",
331
345
  "harvest-analyst",
332
346
  "dream-analyst",
333
347
  "dream-reducer",
348
+ "scout",
334
349
  // Dev-only: the perk-dev session-audit judgment wave's auditor (dormant in consumer repos).
335
350
  "session-auditor",
336
351
  ] as const;
@@ -387,12 +402,135 @@ export const GITHUB_ISSUE_BACKEND_ID: IssueBackendId = "github";
387
402
  * because the TS plane only renders prompts — it never writes canonical issues.
388
403
  */
389
404
  export function resolveIssueBackendId(cwd: string): IssueBackendId {
405
+ const backend = resolveIssueDestination(cwd).backend;
406
+ if (backend === "github" || backend === "linear") return backend;
407
+ return GITHUB_ISSUE_BACKEND_ID;
408
+ }
409
+
410
+ /** The committed `[issues]` routing keys — where a save is written and (Linear) which team owns it. */
411
+ export interface IssueDestination {
412
+ backend: string | null;
413
+ team: string | null;
414
+ }
415
+
416
+ /**
417
+ * The committed `[issues]` routing as the save-destination fence reads it. `keys` when the
418
+ * document provably spells the table the way the subset reader parses it — `backend`/`team`
419
+ * are then exactly what Python's `tomllib` reads; `document` when parity cannot be proven (a
420
+ * dotted-key / inline-table / quoted-key spelling, an escape, a duplicate header …) — the
421
+ * verbatim text travels alongside the best-effort keys so the fence can widen to every byte that
422
+ * might route the save instead of trusting a read the authoritative parser may not share.
423
+ */
424
+ export type IssueRouting =
425
+ | ({ kind: "keys" } & IssueDestination)
426
+ | ({ kind: "document"; text: string } & IssueDestination);
427
+
428
+ /** `StrippedStr`'s boundary rule: a string is stripped and a blank one reads as absent. */
429
+ function strippedString(value: TomlScalar | undefined): string | null {
430
+ if (typeof value !== "string") return null;
431
+ const stripped = value.trim();
432
+ return stripped === "" ? null : stripped;
433
+ }
434
+
435
+ /**
436
+ * Can the fence trust `parseTomlSubset(text).tables.issues` as exactly what Python's `tomllib`
437
+ * reads for `[issues] backend`/`team`? Proven only when the document spells the table in the one
438
+ * shape the subset reader handles at parity: a single bare `[issues]` header; `backend`/`team`
439
+ * (each at most once, undotted) as plain single-line strings with no backslash (the subset's
440
+ * escape handling is not `tomllib`'s); and no other header or key segment spelling `issues` —
441
+ * dotted keys, inline tables, quoted keys, `[[issues]]` and super-/sub-tables all reach `tomllib`
442
+ * unseen here. Every line the scanner cannot classify answers `false` (widen, never guess).
443
+ */
444
+ function issueKeysProvable(text: string): boolean {
445
+ const lines = text.split(/\r?\n/);
446
+ let inIssues = false;
447
+ let issuesHeaders = 0;
448
+ const seen = new Set<string>();
449
+ for (let i = 0; i < lines.length; i++) {
450
+ const line = (lines[i] ?? "").trim();
451
+ if (line === "" || line.startsWith("#")) continue;
452
+
453
+ if (line.startsWith("[")) {
454
+ const array = line.match(/^\[\[([^\]]+)\]\]$/);
455
+ const table = array ? null : line.match(/^\[([^\]]+)\]$/);
456
+ const inner = (array?.[1] ?? table?.[1])?.trim();
457
+ // A header shape the subset reader skips (e.g. a trailing comment) would misplace the keys
458
+ // that follow it; quoted segments can spell any key at all.
459
+ if (inner === undefined || /["']/.test(inner)) return false;
460
+ if (table && inner === "issues") {
461
+ issuesHeaders++;
462
+ if (issuesHeaders > 1) return false;
463
+ inIssues = true;
464
+ continue;
465
+ }
466
+ const segments = inner.split(".").map((segment) => segment.trim());
467
+ if (segments.includes("issues")) return false;
468
+ inIssues = false;
469
+ continue;
470
+ }
471
+
472
+ const eq = line.indexOf("=");
473
+ if (eq === -1) return false;
474
+ const key = line.slice(0, eq).trim();
475
+ const value = line.slice(eq + 1).trim();
476
+ if (key === "" || /["']/.test(key)) return false;
477
+ const segments = key.split(".").map((segment) => segment.trim());
478
+ if (segments.includes("issues")) return false;
479
+ if (inIssues && (segments[0] === "backend" || segments[0] === "team")) {
480
+ if (segments.length > 1 || seen.has(key)) return false;
481
+ seen.add(key);
482
+ if (!/^("[^"\\]*"|'[^']*')\s*(#.*)?$/.test(value)) return false;
483
+ continue;
484
+ }
485
+ // Skip a multi-line string body (the subset reader's rule) so its lines are never mistaken
486
+ // for headers or keys.
487
+ const multi = value.startsWith('"""') ? '"""' : value.startsWith("'''") ? "'''" : null;
488
+ if (multi) {
489
+ const body = value.slice(3);
490
+ if (body.endsWith(multi) && body.length >= 3) continue;
491
+ for (i++; i < lines.length; i++) {
492
+ if ((lines[i] ?? "").includes(multi)) break;
493
+ }
494
+ }
495
+ }
496
+ return true;
497
+ }
498
+
499
+ /**
500
+ * Read the committed main-checkout `[issues]` routing for the save-destination fence: the
501
+ * `backend`/`team` keys as Python's boundary reads them (stripped, blank → `null`, non-string →
502
+ * `null`) plus whether that read is provably `tomllib`'s (`kind: "keys"`) or the whole document
503
+ * must stand in for it (`kind: "document"`, carrying the verbatim text). Committed
504
+ * `.perk/config.toml` only (never the `local.toml` overlay), anchored to the MAIN checkout like
505
+ * `resolveIssueBackendId`. A missing file or a read failure is the empty table (`keys`,
506
+ * both `null`) — Python reads the same absence.
507
+ */
508
+ export function resolveIssueRouting(cwd: string): IssueRouting {
509
+ const empty: IssueRouting = { kind: "keys", backend: null, team: null };
390
510
  try {
391
- const committed = readTomlFile(configFile(mainCheckoutRoot(cwd)));
392
- const backend = committed.tables.issues?.backend;
393
- if (backend === "github" || backend === "linear") return backend;
394
- return GITHUB_ISSUE_BACKEND_ID;
511
+ const path = configFile(mainCheckoutRoot(cwd));
512
+ if (!existsSync(path)) return empty;
513
+ const text = readFileSync(path, "utf8");
514
+ const issues = parseTomlSubset(text).tables.issues;
515
+ const keys: IssueDestination = {
516
+ backend: strippedString(issues?.backend),
517
+ team: strippedString(issues?.team),
518
+ };
519
+ return issueKeysProvable(text)
520
+ ? { kind: "keys", ...keys }
521
+ : { kind: "document", text, ...keys };
395
522
  } catch {
396
- return GITHUB_ISSUE_BACKEND_ID;
523
+ return empty;
397
524
  }
398
525
  }
526
+
527
+ /**
528
+ * Read the committed main-checkout `[issues] backend`/`team` values (no validation, no default):
529
+ * the two keys that decide where the Python save lands (`perk/backends/resolve.py`). The
530
+ * `resolveIssueRouting` read without its parity verdict — callers wanting the fail-safe backend
531
+ * id use `resolveIssueBackendId`.
532
+ */
533
+ export function resolveIssueDestination(cwd: string): IssueDestination {
534
+ const { backend, team } = resolveIssueRouting(cwd);
535
+ return { backend, team };
536
+ }
@@ -3,10 +3,11 @@
3
3
  // Node builtins only (so it loads cleanly under `node --test`); shells `git` via `execFileSync`,
4
4
  // never with a shell. Fail-open by design: every failure degrades to the caller's `cwd` (or null
5
5
  // where stated) rather than throwing — the carriers that use this must never wedge a session.
6
- // The ONE deliberate fail-closed composition is `revalidationBracket` (documented there): a
7
- // snapshot proof must treat an unprovable probe as drift, never as "unchanged".
6
+ // `revalidationBracket` and `worktreeGitDir` deliberately fail closed: snapshot proofs and
7
+ // writer coordination must never invent identity from a failed probe.
8
8
 
9
9
  import { execFileSync } from "node:child_process";
10
+ import { realpathSync, statSync } from "node:fs";
10
11
  import { isAbsolute, resolve } from "node:path";
11
12
 
12
13
  /**
@@ -35,6 +36,64 @@ export function mainCheckoutRoot(cwd: string): string {
35
36
  return resolve(common, "..");
36
37
  }
37
38
 
39
+ /** Canonical PER-WORKTREE Git directory for execution exclusion, never a cwd fallback. */
40
+ export function worktreeGitDir(cwd: string): string | null {
41
+ try {
42
+ const out = execFileSync("git", ["rev-parse", "--absolute-git-dir"], {
43
+ cwd,
44
+ encoding: "utf8",
45
+ timeout: 5_000,
46
+ stdio: ["ignore", "pipe", "ignore"],
47
+ }).replace(/\r?\n$/, "");
48
+ if (!isAbsolute(out) || /[\0\r\n]/.test(out) || !statSync(out).isDirectory()) return null;
49
+ return realpathSync(out);
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * The git-config entries that decide WHERE a GitHub-backed save lands: every `remote.*.url` and
57
+ * `remote.*.gh-resolved` key (`gh` resolves the target repo from exactly these). Returns the
58
+ * NUL-separated `key\nvalue` entries sorted lexicographically and re-joined with `\0` (a
59
+ * canonical form fit for digesting), `""` when the repo has no remotes at all (git exits 1 with
60
+ * empty stdout), or `null` when the destination cannot be verified (not a repo, git missing,
61
+ * any other failure). **Fails closed** on purpose: a caller fencing a save must treat `null` as
62
+ * "unverifiable", never as "unchanged". No other git config participates — `branch.*`,
63
+ * `user.*`, and friends never route a save, so they must never invalidate a review.
64
+ *
65
+ * Repo membership is probed first (`worktreeGitDir`): outside a repo `git config` still reads
66
+ * the global/system files and reports "no match" — which would masquerade as a verified empty
67
+ * destination.
68
+ */
69
+ export function remoteConfig(cwd: string): string | null {
70
+ if (worktreeGitDir(cwd) === null) return null;
71
+ try {
72
+ const out = execFileSync(
73
+ "git",
74
+ ["config", "--null", "--get-regexp", "^remote\\..*\\.(url|gh-resolved)$"],
75
+ {
76
+ cwd,
77
+ encoding: "utf8",
78
+ timeout: 5_000,
79
+ maxBuffer: 1024 * 1024,
80
+ stdio: ["ignore", "pipe", "ignore"],
81
+ },
82
+ );
83
+ return out
84
+ .split("\0")
85
+ .filter((entry) => entry !== "")
86
+ .sort()
87
+ .join("\0");
88
+ } catch (error) {
89
+ // `git config --get-regexp` exits 1 with empty stdout when NO key matches — a repo with no
90
+ // remotes is a verified (empty) destination, not a failure.
91
+ const failure = error as { status?: unknown; stdout?: unknown };
92
+ if (failure.status === 1 && String(failure.stdout ?? "") === "") return "";
93
+ return null;
94
+ }
95
+ }
96
+
38
97
  /** Run one git command; trimmed stdout, or null on any failure (the module's fail-open style). */
39
98
  function git(cwd: string, args: string[], timeout?: number): string | null {
40
99
  try {
@@ -96,6 +155,27 @@ export function headSha(cwd: string): string | null {
96
155
  return git(cwd, ["rev-parse", "HEAD"]);
97
156
  }
98
157
 
158
+ /** Whether HEAD is a positively-PROVEN unborn branch pointer: `symbolic-ref -q HEAD` resolves
159
+ * AND `for-each-ref` proves the pointed-to ref ABSENT (an exit-0 run, empty output). `false` =
160
+ * the ref EXISTS (a failing `headSha` read was transient, not unborn); **fail-open to null**
161
+ * when either probe fails outright — callers must never read null as unborn. Own `execFileSync`
162
+ * for the second probe: `git()` conflates empty output (absence — meaningful here) with
163
+ * failure. */
164
+ export function unbornHead(cwd: string): boolean | null {
165
+ const pointer = git(cwd, ["symbolic-ref", "-q", "HEAD"]);
166
+ if (pointer === null) return null;
167
+ try {
168
+ const out = execFileSync("git", ["for-each-ref", "--format=%(refname)", pointer], {
169
+ cwd,
170
+ encoding: "utf8",
171
+ stdio: ["ignore", "pipe", "ignore"],
172
+ });
173
+ return out.trim() === "";
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
178
+
99
179
  /**
100
180
  * Whether the working tree has anything uncommitted (`git status --porcelain`). Untracked files
101
181
  * count as dirty — deliberate: the model decides whether they belong in a commit. **Fail-open to
@@ -122,9 +202,11 @@ export function worktreeDirty(cwd: string): boolean | null {
122
202
  * `git status --porcelain`, so a status-based cleanliness proof over a flagged index is not a
123
203
  * proof. **Fail-open to null** on any failure (not a repo, git missing) — callers must NOT
124
204
  * conflate null with "no flags". Own `execFileSync` rather than the `git()` helper: `git()`
125
- * conflates empty output (an empty index — meaningful here) with failure.
205
+ * conflates empty output (an empty index — meaningful here) with failure. Module-private:
206
+ * `revalidationBracket`'s default flags probe is the one consumer (tests reach the arms through
207
+ * the bracket — real-repo flag arms — and its `probes` seam for the null arm).
126
208
  */
127
- export function indexHidesChanges(cwd: string): boolean | null {
209
+ function indexHidesChanges(cwd: string): boolean | null {
128
210
  try {
129
211
  const out = execFileSync("git", ["ls-files", "-v"], {
130
212
  cwd,
@@ -141,8 +223,8 @@ export function indexHidesChanges(cwd: string): boolean | null {
141
223
  }
142
224
 
143
225
  /**
144
- * The dream-snapshot revalidation bracket (contracts.md §8.65) — the module's ONE deliberately
145
- * **fail-closed** composition (a documented exception to the fail-open charter above): it exists
226
+ * The dream-snapshot revalidation bracket (contracts.md §8.65) — a deliberately
227
+ * **fail-closed** snapshot composition (an exception to the fail-open probe defaults): it exists
146
228
  * to PROVE the repository still matches a stamped snapshot, so an unprovable probe must read as
147
229
  * drift, never as "unchanged". The claim is END-STATE equality only — HEAD unchanged, the
148
230
  * working tree clean, and no assume-unchanged/skip-worktree index flags (which would hide edits
@@ -0,0 +1,53 @@
1
+ // Model-visible output capping (route-don't-relay). The byte-cap helper the stage-execution
2
+ // seam and the CI executor bound their model-facing text with: full results stay in scratch
3
+ // files; only a capped, notice-carrying slice reaches the model.
4
+
5
+ /** The model-visible byte cap (matches subagent's PER_TASK_OUTPUT_CAP); overridable per call. */
6
+ export const DEFAULT_MODEL_VISIBLE_CAP = 50 * 1024;
7
+
8
+ export interface CapResult {
9
+ /** The (possibly truncated) text safe to show the model. */
10
+ shown: string;
11
+ /** Total UTF-8 byte length of the original text. */
12
+ bytesTotal: number;
13
+ /** UTF-8 byte length of `shown` (before the truncation notice). */
14
+ bytesShown: number;
15
+ /** Whether truncation occurred. */
16
+ truncated: boolean;
17
+ }
18
+
19
+ /**
20
+ * UTF-8-byte-safe truncation (subagent's byte-trim loop). Under cap ⇒ unchanged, truncated:false.
21
+ * When truncated, a notice points at the scratch file holding the full result; the notice sits at
22
+ * the cut edge (appended in head mode, prepended in tail mode) so a top-down reader immediately
23
+ * knows which side is missing.
24
+ *
25
+ * `keep` mirrors the SDK's truncateHead/truncateTail guidance: "head" (default) for
26
+ * model-authored summaries/handoffs where the beginning matters; "tail" for command/CI logs where
27
+ * failure summaries live at the end. Deliberately perk's own byte-only util (not the SDK's
28
+ * line-count-aware `truncateTail`): `CapResult`'s byte fields and the scratch-pointing notice are
29
+ * load-bearing in `CiCheckResult`. Pure.
30
+ */
31
+ export function capForModel(
32
+ text: string,
33
+ cap: number = DEFAULT_MODEL_VISIBLE_CAP,
34
+ scratchPath: string | null = null,
35
+ keep: "head" | "tail" = "head",
36
+ ): CapResult {
37
+ const bytesTotal = Buffer.byteLength(text, "utf8");
38
+ if (bytesTotal <= cap) {
39
+ return { shown: text, bytesTotal, bytesShown: bytesTotal, truncated: false };
40
+ }
41
+ let trimmed = keep === "head" ? text.slice(0, cap) : text.slice(-cap);
42
+ while (Buffer.byteLength(trimmed, "utf8") > cap) {
43
+ trimmed = keep === "head" ? trimmed.slice(0, -1) : trimmed.slice(1);
44
+ }
45
+ const bytesShown = Buffer.byteLength(trimmed, "utf8");
46
+ const omitted = bytesTotal - bytesShown;
47
+ const where = scratchPath ? ` Full output preserved at ${scratchPath}.` : "";
48
+ const shown =
49
+ keep === "head"
50
+ ? `${trimmed}\n\n[Output truncated: ${omitted} bytes omitted.${where}]`
51
+ : `[Output truncated: ${omitted} bytes omitted.${where}]\n\n${trimmed}`;
52
+ return { shown, bytesTotal, bytesShown, truncated: true };
53
+ }
@@ -1,8 +1,8 @@
1
1
  // perk-owned dot-directory path construction — the TS twin of perk/substrate/paths.py
2
2
  // (contracts.md §8.1).
3
3
  //
4
- // The sole construction site for the perk-owned config family (`config.toml`/`local.toml`) and
5
- // the perk dir. The config family now lives at `.perk/` (TS reads the target only — the legacy
4
+ // The sole construction site for the perk-owned config family (`config.toml`/`local.toml`).
5
+ // The config family now lives at `.perk/` (TS reads the target only — the legacy
6
6
  // `.pi/perk.toml` migration is Python-side). The workflow family lives in the established cache
7
7
  // seam (substrate/cache.ts's `workflowDir`); together these two modules own every perk-owned
8
8
  // dot-path on this plane.
@@ -17,11 +17,6 @@ import { join } from "node:path";
17
17
  export const CONFIG_FILENAME = "config.toml";
18
18
  export const LOCAL_CONFIG_FILENAME = "local.toml";
19
19
 
20
- /** The perk-owned dot-dir root (shared with Pi today). */
21
- export function perkDir(cwd: string): string {
22
- return join(cwd, ".pi");
23
- }
24
-
25
20
  /** The single config-family redirection point (the file helpers derive from it). */
26
21
  export function configDir(cwd: string): string {
27
22
  return join(cwd, ".perk");
@@ -17,6 +17,10 @@
17
17
  //
18
18
  // This seam is LIVE in production: render is imported by the worker, the warm doors, the
19
19
  // factories, and the mode/adapter context modules (the `prompts/contexts/` injections).
20
+ //
21
+ // Besides the render seam itself, the module carries ONE cross-plane selector wrapping it:
22
+ // `planReadInstruction`, the per-backend plan-read arm shared by the doors and the
23
+ // stage-execution seam.
20
24
 
21
25
  import { render as miniJinjaRender } from "./miniJinja.ts";
22
26
 
@@ -24,3 +28,21 @@ import { render as miniJinjaRender } from "./miniJinja.ts";
24
28
  export function render(name: string, vars: Record<string, unknown>): string {
25
29
  return miniJinjaRender(name, vars);
26
30
  }
31
+
32
+ /**
33
+ * The per-backend plan-read instruction — the prompt SSOT for "how do I read the saved
34
+ * plan". Byte-identical to `src/perk/run/launch/prompts.py::_plan_read_instruction` (the Python
35
+ * twin — likewise a prompts module); drift in either plane fails the paired parity suites.
36
+ * `github` reads via `gh`; `linear` points at the pi-mono-linear tools with an `open <url>`
37
+ * fallback; any other provider falls back to opening the url.
38
+ *
39
+ * The wording lives in the canonical templates `prompts/common/plan-read/*.md`, rendered
40
+ * identically by both planes via the shared render seam (contracts.md §8.31); branching stays in
41
+ * code — only the arm chosen and the vars passed differ. Golden-fixture parity (the three
42
+ * `plan-read-*` cases) plus a thin per-arm selection test replace the dedicated substring parity.
43
+ */
44
+ export function planReadInstruction(provider: string, prId: string, url: string): string {
45
+ if (provider === "github") return render("common/plan-read/github.md", { pr_id: prId, url });
46
+ if (provider === "linear") return render("common/plan-read/linear.md", { pr_id: prId, url });
47
+ return render("common/plan-read/other.md", { pr_id: prId, url });
48
+ }
@@ -12,6 +12,8 @@ import { sharedDir } from "./resources.ts";
12
12
  export interface RegistryStage {
13
13
  id: string;
14
14
  command: string;
15
+ /** `read-only` | `read-write` — the gate the cold claim persists as `perk:workflow-state.mode`. */
16
+ mode: string;
15
17
  doors: Record<string, boolean>;
16
18
  predecessors: string[];
17
19
  successors: string[];