@mgiles/perk 3.2.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 (202) 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 +365 -380
  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 -311
  49. package/extension/{doors/prReviewBrowser.ts → pi/v1/codeReview/browser.ts} +53 -33
  50. package/extension/{doors/hunkHandoff.ts → pi/v1/codeReview/checkout.ts} +12 -8
  51. package/extension/{doors/reviewWaveTools.ts → pi/v1/codeReview/reviewWave.ts} +146 -114
  52. package/extension/{doors/stackReviewBrowser.ts → pi/v1/codeReview/stack.ts} +62 -29
  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 +154 -267
  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 +123 -61
  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/pi/v1/lifecycleGates.ts +127 -0
  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/{factories/objectivePlan.ts → pi/v1/objectivePlanning.ts} +328 -533
  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 +73 -27
  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 -21
  112. package/extension/substrate/git.ts +88 -6
  113. package/extension/substrate/modelVisible.ts +53 -0
  114. package/extension/substrate/prompts.ts +22 -0
  115. package/extension/substrate/registry.ts +2 -0
  116. package/extension/substrate/resolverLease.ts +5 -4
  117. package/extension/substrate/sessionData.ts +85 -152
  118. package/extension/substrate/toolGating.ts +263 -84
  119. package/extension/substrate/unifiedDiff.ts +1 -1
  120. package/extension/substrate/workflowState.ts +178 -163
  121. package/extension/substrate/worktreeResolverLock.ts +261 -0
  122. package/extension/surfaces/surfaces.ts +79 -27
  123. package/extension/waves/adversarialReviewWave.ts +87 -46
  124. package/extension/waves/blockedReports.ts +59 -0
  125. package/extension/waves/draftReviewWave.ts +42 -42
  126. package/extension/waves/laneIdentity.ts +77 -0
  127. package/extension/waves/objectiveExplorerWave.ts +24 -24
  128. package/extension/waves/prReviewWave.ts +89 -77
  129. package/extension/waves/reportWave.ts +438 -578
  130. package/extension/waves/reviewClassifierWave.ts +22 -22
  131. package/extension/waves/rpcAdapter.ts +100 -15
  132. package/extension/waves/scoutWave.ts +192 -0
  133. package/extension/waves/transport.ts +480 -0
  134. package/extension/worker/sdkAdapter.ts +494 -0
  135. package/extension/worker/stageExecution.ts +679 -0
  136. package/extension/workerMain.ts +18 -19
  137. package/package.json +6 -4
  138. package/prompts/_fixtures/live.yaml +43 -18
  139. package/prompts/contexts/adapters/plannotator-gist.md +6 -0
  140. package/prompts/contexts/adapters/plannotator-objective.md +6 -0
  141. package/prompts/contexts/adapters/plannotator-plan.md +8 -1
  142. package/prompts/contexts/adapters/plannotator-refinement.md +22 -0
  143. package/prompts/contexts/objective-refinement.md +17 -0
  144. package/prompts/contexts/read-only.md +1 -1
  145. package/prompts/stages/conflict-resolution-continuation.md +9 -6
  146. package/prompts/stages/conflict-resolution.md +4 -4
  147. package/prompts/stages/objective-plan/guidance.md +2 -2
  148. package/prompts/stages/objective-plan/seed.md +9 -1
  149. package/prompts/stages/objective-reconcile-ready.md +1 -1
  150. package/prompts/stages/objective-reconcile.md +1 -1
  151. package/prompts/stages/objective-refine/seed.md +18 -0
  152. package/prompts/stages/objective-review-browser.md +4 -4
  153. package/prompts/stages/objective-sync.md +1 -1
  154. package/prompts/stages/plan-review-browser.md +4 -4
  155. package/prompts/stages/pr-review-browser/active.md +3 -4
  156. package/prompts/stages/pr-review-browser/foreign.md +3 -4
  157. package/prompts/stages/pr-review-terminal/active.md +3 -3
  158. package/prompts/stages/pr-review-terminal/foreign.md +3 -3
  159. package/prompts/stages/pr-review.md +3 -3
  160. package/prompts/stages/stack-review-browser/stack.md +5 -6
  161. package/shared/README.md +8 -0
  162. package/shared/bindings.yaml +3 -3
  163. package/shared/contracts.md +2601 -506
  164. package/shared/fixtures/issues-table.json +130 -0
  165. package/shared/registry.yaml +13 -0
  166. package/shared/schemas/outputs/objective-node-engagement.schema.json +318 -0
  167. package/shared/schemas/outputs/objective-stack-status.schema.json +6 -1
  168. package/shared/schemas/outputs/pr-review-context.schema.json +54 -9
  169. package/shared/schemas/outputs/pr-review-stack-context.schema.json +196 -0
  170. package/extension/adapters/planAdapterPlannotator.ts +0 -362
  171. package/extension/doors/auditWaveTools.ts +0 -352
  172. package/extension/doors/ciExecutor.ts +0 -756
  173. package/extension/doors/commitCompact.ts +0 -251
  174. package/extension/doors/dreamWaveTools.ts +0 -489
  175. package/extension/doors/learn.ts +0 -668
  176. package/extension/doors/lifecycleGates.ts +0 -207
  177. package/extension/doors/objectiveStack.ts +0 -1543
  178. package/extension/doors/prReviewDynamic.ts +0 -276
  179. package/extension/doors/ready.ts +0 -279
  180. package/extension/doors/submit.ts +0 -373
  181. package/extension/doors/submitPrReview.ts +0 -505
  182. package/extension/factories/gistAuthor.ts +0 -94
  183. package/extension/factories/gistDraft.ts +0 -265
  184. package/extension/factories/gistSave.ts +0 -251
  185. package/extension/factories/implementHere.ts +0 -116
  186. package/extension/factories/objectiveAuthor.ts +0 -98
  187. package/extension/factories/objectiveDraft.ts +0 -466
  188. package/extension/factories/objectiveSave.ts +0 -366
  189. package/extension/factories/planDraft.ts +0 -140
  190. package/extension/factories/planMode.ts +0 -205
  191. package/extension/factories/planReview.ts +0 -1237
  192. package/extension/factories/planSave.ts +0 -604
  193. package/extension/factories/planTitle.ts +0 -141
  194. package/extension/substrate/structuredOutput.ts +0 -202
  195. package/extension/waves/auditWave.ts +0 -312
  196. package/extension/waves/harvestWave.ts +0 -399
  197. package/extension/waves/learnWave.ts +0 -155
  198. package/extension/waves/memoryAdapter.ts +0 -139
  199. package/extension/waves/prReviewDynamicWave.ts +0 -777
  200. package/extension/worker/readOnlySession.ts +0 -294
  201. package/extension/worker/worker.ts +0 -899
  202. package/prompts/stages/pr-review-dynamic.md +0 -7
@@ -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,10 +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
- * (Since pi-subagents 0.52, `subagents.agentOverrides` also reaches custom/project agents
62
- * but only as a frontmatter-sensitive fill that never displaces a field the def's own
63
- * frontmatter sets; every perk def pins `model:` in frontmatter, so this inline workflow-level
64
- * injection remains 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.)
65
69
  * A value may carry a `:thinking` suffix (`"anthropic/claude-sonnet-4-5:high"`) or be the
66
70
  * `"inherit"` sentinel (child inherits the parent session's model) — both resolved by
67
71
  * pi-subagents on the injected value (the last-colon segment counts as thinking only when it
@@ -75,11 +79,11 @@ export interface PerkConfig {
75
79
  "conflict-resolver"?: string;
76
80
  "learn-analyst"?: string;
77
81
  "adversarial-reviewer"?: string;
78
- "review-angle-selector"?: string;
79
82
  "draft-reviewer"?: string;
80
83
  "harvest-analyst"?: string;
81
84
  "dream-analyst"?: string;
82
85
  "dream-reducer"?: string;
86
+ scout?: string;
83
87
  "session-auditor"?: string;
84
88
  };
85
89
  /**
@@ -127,8 +131,8 @@ function unescapeBasic(raw: string): string {
127
131
  * Parse the narrow TOML subset perk consumes. Returns `{ tables, arrays }`: `tables` is a
128
132
  * `{ section: { key: scalar } }` map (top-level keys under the `""` section); `arrays` is a
129
133
  * `{ name: [{ key: scalar }, ...] }` map fed by `[[name]]` array-of-tables. Scalars are quoted
130
- * strings, native `true`/`false` booleans, and numeric literals; anything else is skipped —
131
- * 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.
132
136
  */
133
137
  export function parseTomlSubset(text: string): TomlSubset {
134
138
  const root: Record<string, TomlScalar> = {};
@@ -170,17 +174,19 @@ export function parseTomlSubset(text: string): TomlSubset {
170
174
  const value = line.slice(eq + 1).trim();
171
175
  if (key === "") continue;
172
176
 
173
- // Multi-line basic string: """ ... """ (possibly spanning lines).
174
- 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) {
175
181
  let body = value.slice(3);
176
- if (body.endsWith('"""') && body.length >= 3) {
182
+ if (body.endsWith(multi) && body.length >= 3) {
177
183
  body = body.slice(0, -3);
178
184
  } else {
179
185
  const parts: string[] = [body];
180
186
  i++;
181
187
  for (; i < lines.length; i++) {
182
188
  const raw = lines[i] ?? "";
183
- const end = raw.indexOf('"""');
189
+ const end = raw.indexOf(multi);
184
190
  if (end !== -1) {
185
191
  // A bare closing delimiter on its own line contributes no trailing content (so the
186
192
  // newline that precedes it is not appended as an empty segment).
@@ -193,7 +199,7 @@ export function parseTomlSubset(text: string): TomlSubset {
193
199
  // A leading newline immediately after the opening delimiter is trimmed (TOML rule).
194
200
  if (body.startsWith("\n")) body = body.slice(1);
195
201
  }
196
- dest[key] = unescapeBasic(body);
202
+ dest[key] = multi === '"""' ? unescapeBasic(body) : body;
197
203
  continue;
198
204
  }
199
205
 
@@ -204,6 +210,13 @@ export function parseTomlSubset(text: string): TomlSubset {
204
210
  continue;
205
211
  }
206
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
+
207
220
  // Unquoted scalar: strip an inline `#` comment, then read native booleans and numbers.
208
221
  const hash = value.indexOf("#");
209
222
  const bare = (hash === -1 ? value : value.slice(0, hash)).trim();
@@ -328,11 +341,11 @@ const SUBAGENT_KEYS = [
328
341
  "conflict-resolver",
329
342
  "learn-analyst",
330
343
  "adversarial-reviewer",
331
- "review-angle-selector",
332
344
  "draft-reviewer",
333
345
  "harvest-analyst",
334
346
  "dream-analyst",
335
347
  "dream-reducer",
348
+ "scout",
336
349
  // Dev-only: the perk-dev session-audit judgment wave's auditor (dormant in consumer repos).
337
350
  "session-auditor",
338
351
  ] as const;
@@ -389,12 +402,135 @@ export const GITHUB_ISSUE_BACKEND_ID: IssueBackendId = "github";
389
402
  * because the TS plane only renders prompts — it never writes canonical issues.
390
403
  */
391
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 };
392
510
  try {
393
- const committed = readTomlFile(configFile(mainCheckoutRoot(cwd)));
394
- const backend = committed.tables.issues?.backend;
395
- if (backend === "github" || backend === "linear") return backend;
396
- 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 };
397
522
  } catch {
398
- return GITHUB_ISSUE_BACKEND_ID;
523
+ return empty;
399
524
  }
400
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
+ }
@@ -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[];
@@ -1,8 +1,9 @@
1
1
  // The conflict-resolver claim (contracts.md §8.51): a machine-local SESSION CLAIM on a retained
2
- // sync-continuation operation, taken by the warm dispatcher right before it injects the resolver
3
- // dispatch. It is honestly NOT a child-lifecycle-bound lock `pi.sendUserMessage` is
4
- // fire-and-forget and the extension never observes the dispatched child's start or finish — so
5
- // there is deliberately NO explicit release on dispatch. The claim self-heals instead, via the
2
+ // sync-continuation operation, taken during preparation before native execution. It is NOT a
3
+ // child-lifecycle-bound execution lock: after a verified dispatch preparation this claim stays
4
+ // held across every resolver outcome, including confirmed completion. Only preparation failure
5
+ // releases this call's token. The separate worktree execution lock cannot be reclaimed here.
6
+ // The session claim self-heals instead, via the
6
7
  // reclaimability predicate: the holder pid is dead, the recorded operation was consumed (a fresh
7
8
  // conflict minted a new operation id), or the lease is missing/corrupt and the lock dir has aged
8
9
  // past `RECLAIM_GRACE_MS`. The accepted residual: a live session's claim on a still-pending SAME