@attalabs/vinaya 0.12.0 → 0.17.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 (128) hide show
  1. package/README.md +33 -3
  2. package/aeg-root/contracts/planner-brief.md +1 -1
  3. package/aeg-root/enforcement.md +5 -5
  4. package/aeg-root/roles/developer.md +1 -0
  5. package/aeg-root/roles/planner.md +2 -2
  6. package/aeg-root/roles/reviewer.md +17 -4
  7. package/aeg-root/roles/security.md +16 -5
  8. package/aeg-root/skills/aeg/SKILL.md +1 -1
  9. package/aeg-root/state-machine.md +2 -2
  10. package/aeg-root/templates/pr-report-template.md +2 -0
  11. package/aeg-root/tranche-model.md +1 -1
  12. package/dist/checks/bin/check-body-bare-digits.js +3545 -0
  13. package/dist/checks/bin/check-branch-topology.js +99 -10
  14. package/dist/checks/bin/check-brief-shape.js +99 -10
  15. package/dist/checks/bin/check-closes-n.js +99 -10
  16. package/dist/checks/bin/check-coherence.js +106 -12
  17. package/dist/checks/bin/check-dead-branch-push.js +99 -10
  18. package/dist/checks/bin/check-dispatch-readiness.js +106 -12
  19. package/dist/checks/bin/check-doc-coverage-push.js +106 -12
  20. package/dist/checks/bin/check-doc-coverage.js +109 -16
  21. package/dist/checks/bin/check-evidence-fresh.js +99 -10
  22. package/dist/checks/bin/check-first-push-dispatch.js +106 -12
  23. package/dist/checks/bin/check-issue-assignment.js +99 -10
  24. package/dist/checks/bin/check-no-disk-state.js +99 -10
  25. package/dist/checks/bin/check-reader-resolvable-prose.js +99 -10
  26. package/dist/checks/bin/check-registry-gates.js +99 -10
  27. package/dist/checks/bin/check-review-gate.js +109 -25
  28. package/dist/checks/bin/check-single-plan-pr.js +99 -10
  29. package/dist/checks/bin/check-test-plan.js +99 -10
  30. package/dist/index.js +1325 -236
  31. package/package.json +1 -1
  32. package/studio-standalone/apps/vinaya-studio/web/.next/BUILD_ID +1 -1
  33. package/studio-standalone/apps/vinaya-studio/web/.next/build-manifest.json +3 -3
  34. package/studio-standalone/apps/vinaya-studio/web/.next/prerender-manifest.json +3 -3
  35. package/studio-standalone/apps/vinaya-studio/web/.next/required-server-files.json +1 -1
  36. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.html +1 -1
  37. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.rsc +1 -1
  38. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  39. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  40. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  41. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page/server-reference-manifest.json +2 -2
  42. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  43. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  44. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/api/coherence/route.js.nft.json +1 -1
  45. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page/server-reference-manifest.json +2 -2
  46. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page.js.nft.json +1 -1
  47. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page_client-reference-manifest.js +1 -1
  48. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page/server-reference-manifest.json +2 -2
  49. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page.js +1 -1
  50. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page.js.nft.json +1 -1
  51. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page_client-reference-manifest.js +1 -1
  52. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page/server-reference-manifest.json +2 -2
  53. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page.js +1 -1
  54. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page.js.nft.json +1 -1
  55. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page_client-reference-manifest.js +1 -1
  56. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page/server-reference-manifest.json +2 -2
  57. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page.js.nft.json +1 -1
  58. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page_client-reference-manifest.js +1 -1
  59. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page/server-reference-manifest.json +2 -2
  60. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page.js +1 -1
  61. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page.js.nft.json +1 -1
  62. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page_client-reference-manifest.js +1 -1
  63. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page/server-reference-manifest.json +2 -2
  64. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page.js +1 -1
  65. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page.js.nft.json +1 -1
  66. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page_client-reference-manifest.js +1 -1
  67. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page/server-reference-manifest.json +2 -2
  68. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page.js +1 -1
  69. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page.js.nft.json +1 -1
  70. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page_client-reference-manifest.js +1 -1
  71. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page/server-reference-manifest.json +2 -2
  72. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page.js.nft.json +1 -1
  73. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page_client-reference-manifest.js +1 -1
  74. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/[root-of-the-server]__0e95dv5._.js +1 -1
  75. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/1q96_modules_@clerk_nextjs_dist_esm_app-router_client_keyless-creator-reader_0lom2js.js +1 -1
  76. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__029-1ch._.js +1 -1
  77. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0gw1_g2._.js +1 -1
  78. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0q56ohl._.js +1 -1
  79. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0rr_lq7._.js +1 -1
  80. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0zfjc2o._.js +1 -1
  81. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__19jyp3m._.js +1 -1
  82. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1_ojzp1._.js +1 -1
  83. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/{[root-of-the-server]__1wsl6ro._.js → [root-of-the-server]__1ccgndq._.js} +1 -1
  84. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1d_8nm4._.js +1 -1
  85. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1dfzjrd._.js +1 -1
  86. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1zkwtx8._.js +1 -1
  87. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__210ehg9._.js +1 -1
  88. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0-n77dx._.js +1 -1
  89. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_00yjcv1._.js +1 -1
  90. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_01605g7._.js +1 -1
  91. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0o54suy._.js +1 -1
  92. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0rix02i._.js +1 -1
  93. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/{_1u3abhy._.js → _0x999pb._.js} +2 -2
  94. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/{_0ndy563._.js → _1-6rzip._.js} +2 -2
  95. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_18-d2be._.js +1 -1
  96. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/{_087xa25._.js → _1_talrs._.js} +2 -2
  97. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/{_11vnfnm._.js → _1ar5uqe._.js} +2 -2
  98. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/{_02drwa_._.js → _1c9b9j-._.js} +2 -2
  99. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1fqw88f._.js +1 -1
  100. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1gw6vc9._.js +1 -1
  101. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/apps_vinaya-studio_web_src_app_studio_18kqbvs._.js +1 -1
  102. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/apps_vinaya-studio_web_src_app_studio_projects_[name]_tranches_[slug]_20yap_f._.js +1 -1
  103. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/node_modules_1vo08dj._.js +2 -2
  104. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/packages_ui_libraries_animate_components_index_ts_186fje1._.js +1 -1
  105. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/packages_ui_libraries_basic_components_index_ts_0xk1vda._.js +1 -1
  106. package/studio-standalone/apps/vinaya-studio/web/.next/server/middleware-build-manifest.js +3 -3
  107. package/studio-standalone/apps/vinaya-studio/web/.next/server/pages/500.html +1 -1
  108. package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.js +1 -1
  109. package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.json +3 -3
  110. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{1mexqnauhtbtp.js → 0_jnwbarv-cqk.js} +1 -1
  111. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{2ieu4oi-pp_bz.js → 0l_vubs1ephe5.js} +1 -1
  112. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/0ojfbn3yvy-8_.js +1 -0
  113. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{1jx5_q1g9l-cm.js → 0phqfqwbnt_ey.js} +1 -1
  114. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{0qcu17x3h94a9.js → 17r0szvqeeyhx.js} +1 -1
  115. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{0ve1-nz-mu_lr.js → 18x3rbqzeutpz.js} +1 -1
  116. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{22y4d5yrovcv-.js → 1bqpfvromtwn1.js} +1 -1
  117. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{39w6jbw3rleez.js → 24ojyzmt_fkg3.js} +1 -1
  118. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/27sqffqm2wbxx.css +1 -0
  119. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{0tmcnl-z8yt2_.js → 30lyvn0yzwdiz.js} +1 -1
  120. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{1khy4oilrab6g.js → 3m0_gk59s-rbx.js} +1 -1
  121. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{1eo32v1tbjy7c.js → 40x7f5hm7a5tk.js} +1 -1
  122. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{0-08g_jjalnd9.js → 453zhecht_m1q.js} +1 -1
  123. package/studio-standalone/apps/vinaya-studio/web/server.js +1 -1
  124. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/0qu0xgvifbacu.css +0 -1
  125. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/1t7yy_s34022p.js +0 -1
  126. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{ZHVE9Y9G8XKR91g27SgVX → p5sWGp4ANwORiaX4Kk6U2}/_buildManifest.js +0 -0
  127. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{ZHVE9Y9G8XKR91g27SgVX → p5sWGp4ANwORiaX4Kk6U2}/_clientMiddlewareManifest.js +0 -0
  128. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{ZHVE9Y9G8XKR91g27SgVX → p5sWGp4ANwORiaX4Kk6U2}/_ssgManifest.js +0 -0
package/dist/index.js CHANGED
@@ -1,16 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { readFileSync as readFileSync19 } from "node:fs";
5
- import { dirname as dirname8, join as join21 } from "node:path";
4
+ import { readFileSync as readFileSync21 } from "node:fs";
5
+ import { dirname as dirname8, join as join22 } from "node:path";
6
6
  import { fileURLToPath as fileURLToPath2 } from "node:url";
7
7
 
8
8
  // src/commands/archive.ts
9
- import { execFileSync as execFileSync3 } from "node:child_process";
9
+ import { execFileSync as execFileSync4 } from "node:child_process";
10
10
  // ../../packages/aeg-forge-state/src/strip-code.ts
11
11
  function maskCode(body) {
12
12
  const fill = (line) => " ".repeat(line.length);
13
- return maskIndentedCode(maskFencedCode(body, fill), fill).replace(/(`+)[^\n]*?\1/g, (m) => " ".repeat(m.length));
13
+ const blocksMasked = maskIndentedCode(maskFencedCode(body, fill), fill);
14
+ return blocksMasked.split(`
15
+ `).map((line) => replaceInlineSpans(line, (span) => " ".repeat(span.length))).join(`
16
+ `);
14
17
  }
15
18
  function stripCode(body, options = {}) {
16
19
  const normalised = body.replace(/\r\n?/g, `
@@ -19,7 +22,50 @@ function stripCode(body, options = {}) {
19
22
  const blocksStripped = maskIndentedCode(maskFencedCode(normalised, blank), blank);
20
23
  if (options.inlineSpans === "keep")
21
24
  return blocksStripped;
22
- return blocksStripped.replace(/(`+)[^\n]*?\1/g, "");
25
+ return blocksStripped.split(`
26
+ `).map((line) => replaceInlineSpans(line, () => "")).join(`
27
+ `);
28
+ }
29
+ function replaceInlineSpans(line, replace) {
30
+ let result = "";
31
+ let i = 0;
32
+ const runLengthAt = (at) => {
33
+ let j = at;
34
+ while (j < line.length && line[j] === "`")
35
+ j++;
36
+ return j - at;
37
+ };
38
+ while (i < line.length) {
39
+ if (line[i] !== "`") {
40
+ result += line[i];
41
+ i++;
42
+ continue;
43
+ }
44
+ const openLen = runLengthAt(i);
45
+ const openEnd = i + openLen;
46
+ let j = openEnd;
47
+ let closeEnd = -1;
48
+ while (j < line.length) {
49
+ if (line[j] !== "`") {
50
+ j++;
51
+ continue;
52
+ }
53
+ const runLen = runLengthAt(j);
54
+ if (runLen === openLen) {
55
+ closeEnd = j + runLen;
56
+ break;
57
+ }
58
+ j += runLen;
59
+ }
60
+ if (closeEnd === -1) {
61
+ result += line.slice(i, openEnd);
62
+ i = openEnd;
63
+ } else {
64
+ result += replace(line.slice(i, closeEnd));
65
+ i = closeEnd;
66
+ }
67
+ }
68
+ return result;
23
69
  }
24
70
  var FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})([^\r\n]*)\r?$/;
25
71
  var FENCE_CLOSE = /^ {0,3}(`+|~+)[ \t]*\r?$/;
@@ -53,6 +99,10 @@ function scanFencedCode(body) {
53
99
  });
54
100
  return { lines, unterminated: fenceChar !== null };
55
101
  }
102
+ function hasUnterminatedFence(body) {
103
+ return scanFencedCode(body.replace(/\r\n?/g, `
104
+ `)).unterminated;
105
+ }
56
106
  var LIST_MARKER = /^ {0,3}(?:[-*+]|\d{1,9}[.)])(?:\s|$)/;
57
107
  function indentWidth(line) {
58
108
  let width = 0;
@@ -240,6 +290,42 @@ function matchesLabel(key, name) {
240
290
  function hasLabel(key, names) {
241
291
  return names.some((n) => matchesLabel(key, n));
242
292
  }
293
+
294
+ // ../../packages/aeg-forge-state/src/list-tasks.ts
295
+ var PROJECT_SLUG = /^[a-z0-9][a-z0-9-]*$/i;
296
+ var PROJECT_FIELD = /^\s*(?:\*\*)?Project(?:\(s\))?(?:\*\*)?\s*:\s*(?:\*\*)?\s*(.+)$/im;
297
+ var VALUE_EDGE_MARKUP = /^[`.;\s]+|[`.;\s]+$/g;
298
+ function unwrapValue(raw) {
299
+ return raw.replace(/\*\*/g, "").replace(VALUE_EDGE_MARKUP, "");
300
+ }
301
+ function projectFieldFromBody(body) {
302
+ const m = stripCode(body, { inlineSpans: "keep" }).match(PROJECT_FIELD);
303
+ if (m)
304
+ return parseFieldValue(m[1] ?? "");
305
+ if (hasUnterminatedFence(body)) {
306
+ const raw = body.match(PROJECT_FIELD);
307
+ if (raw)
308
+ return { declared: true, names: [], unparsed: [(raw[1] ?? "").trim()], unreadable: true };
309
+ }
310
+ return { declared: false, names: [], unparsed: [], unreadable: false };
311
+ }
312
+ function parseFieldValue(value) {
313
+ const names = [];
314
+ const unparsed = [];
315
+ for (const raw of value.split(",")) {
316
+ const name = unwrapValue(raw);
317
+ if (name.length === 0)
318
+ continue;
319
+ if (PROJECT_SLUG.test(name))
320
+ names.push(name);
321
+ else
322
+ unparsed.push(raw.trim());
323
+ }
324
+ return { declared: true, names: [...new Set(names)], unparsed, unreadable: false };
325
+ }
326
+ function projectsFromBody(body) {
327
+ return projectFieldFromBody(body).names;
328
+ }
243
329
  // ../../packages/aeg-forge-state/src/github-token.ts
244
330
  import { execFile as execFile2 } from "node:child_process";
245
331
  import { promisify as promisify2 } from "node:util";
@@ -392,6 +478,85 @@ function splitTableRow(row) {
392
478
  parts.pop();
393
479
  return parts.map((p) => p.split(PLACEHOLDER).join("|"));
394
480
  }
481
+ // ../../packages/aeg-core/src/blast-radius-domains.ts
482
+ function resolveWorkspaceEntry(entry2, listDirs) {
483
+ if (entry2.endsWith("/*")) {
484
+ const baseDir = entry2.slice(0, -2);
485
+ return listDirs(baseDir).map((name) => `${baseDir}/${name}`);
486
+ }
487
+ if (entry2.includes("*"))
488
+ return [];
489
+ return [entry2];
490
+ }
491
+ function deriveWorkspacePackageDomains(workspaces, listDirs) {
492
+ const positive = workspaces.filter((entry2) => !entry2.startsWith("!"));
493
+ const negative = workspaces.filter((entry2) => entry2.startsWith("!")).map((entry2) => entry2.slice(1));
494
+ const excluded = new Set;
495
+ for (const entry2 of negative) {
496
+ for (const member of resolveWorkspaceEntry(entry2, listDirs))
497
+ excluded.add(member);
498
+ }
499
+ const domains = new Set;
500
+ for (const entry2 of positive) {
501
+ for (const member of resolveWorkspaceEntry(entry2, listDirs)) {
502
+ if (excluded.has(member))
503
+ continue;
504
+ const segments = member.split("/");
505
+ if (segments[0] !== "packages" || segments.length < 2)
506
+ continue;
507
+ domains.add(`packages/${segments[1]}`);
508
+ }
509
+ }
510
+ return [...domains].sort();
511
+ }
512
+ function parsePnpmWorkspaceYaml(content) {
513
+ const unquote = (s) => {
514
+ const t = s.trim();
515
+ if (t.length >= 2 && (t[0] === "'" && t.at(-1) === "'" || t[0] === '"' && t.at(-1) === '"')) {
516
+ return t.slice(1, -1);
517
+ }
518
+ return t;
519
+ };
520
+ const inlineMatch = content.match(/^packages:\s*\[(.*)\]\s*$/m);
521
+ if (inlineMatch) {
522
+ return (inlineMatch[1] ?? "").split(",").map(unquote).filter((s) => s.length > 0);
523
+ }
524
+ const entries = [];
525
+ let inBlock = false;
526
+ for (const rawLine of content.split(`
527
+ `)) {
528
+ const line = rawLine.replace(/#.*$/, "");
529
+ if (/^packages:\s*$/.test(line)) {
530
+ inBlock = true;
531
+ continue;
532
+ }
533
+ if (!inBlock)
534
+ continue;
535
+ const itemMatch = line.match(/^\s+-\s*(.+?)\s*$/);
536
+ if (itemMatch?.[1]) {
537
+ entries.push(unquote(itemMatch[1]));
538
+ continue;
539
+ }
540
+ if (line.trim().length === 0)
541
+ continue;
542
+ break;
543
+ }
544
+ return entries;
545
+ }
546
+ var CROSS_CUTTING_CANDIDATES = [
547
+ "bun.lock",
548
+ "package-lock.json",
549
+ "pnpm-lock.yaml",
550
+ "yarn.lock",
551
+ "turbo.json",
552
+ "biome.json",
553
+ "tsconfig.json",
554
+ ".github/workflows",
555
+ ".husky"
556
+ ];
557
+ function deriveBuiltinCrossCuttingDefaults(exists) {
558
+ return CROSS_CUTTING_CANDIDATES.filter((path) => exists(path));
559
+ }
395
560
  // ../../packages/aeg-core/src/state-machine-model.ts
396
561
  var FORGE_FACT_INPUTS = [
397
562
  {
@@ -457,12 +622,30 @@ var DERIVED_STATUSES = [
457
622
  "incoherent"
458
623
  ];
459
624
  var DERIVABLE_STATUSES = DERIVED_STATUSES.filter((s) => s !== "backlog");
625
+ // ../../packages/aeg-core/src/file-classify.ts
626
+ function isCodeFile(p) {
627
+ if (p.endsWith(".md"))
628
+ return false;
629
+ return /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|sql|css)$/.test(p);
630
+ }
460
631
  // ../../packages/aeg-core/src/waiver-label.ts
461
632
  var WAIVER_LABEL = label("waiver-docs");
462
633
  var WAIVER_LABEL_REVIEW = label("waiver-review");
634
+ var PRINCIPAL_ALLOWLIST = ["daniboomerang"];
635
+ function isPrincipal(login, principalAllowlist) {
636
+ if (login === null)
637
+ return false;
638
+ const normalized = login.toLowerCase();
639
+ return principalAllowlist.some((p) => p.toLowerCase() === normalized);
640
+ }
463
641
 
464
642
  // ../../packages/aeg-core/src/doc-owners.ts
465
643
  var DOC_OWNERS_PATH = ".vinaya/doc-owners";
644
+ function classifyDocOwnersManifest(content) {
645
+ if (content === null)
646
+ return "absent";
647
+ return content.trim() === "" ? "empty" : "present";
648
+ }
466
649
  function parseDocOwners(content) {
467
650
  const bindings = [];
468
651
  const errors = [];
@@ -507,11 +690,19 @@ function globToRegex(pat) {
507
690
  re += "$";
508
691
  return new RegExp(re);
509
692
  }
693
+ function isUrlPointer(p) {
694
+ return /^https?:\/\//.test(p);
695
+ }
696
+ function pointerToPath(p) {
697
+ const idx = p.indexOf("#");
698
+ return idx === -1 ? p : p.slice(0, idx);
699
+ }
510
700
  var SEPARATOR = /(?:[ \t]*[—–][ \t]*|[ \t]+-[ \t]+)/.source;
511
701
  // ../../packages/aeg-core/src/pr-tier.ts
702
+ var TIER_FIELD = /(\*\*)?\s*Tier\s*(\*\*)?\s*:\s*(\*\*)?\s*([013])\b/i;
512
703
  function readTierFromPrBody(prBody) {
513
704
  const searchIn = anchoredRegion(prBody, "TIER") ?? prBody;
514
- const m = searchIn.match(/(\*\*)?\s*Tier\s*(\*\*)?\s*:\s*(\*\*)?\s*([013])\b/i);
705
+ const m = searchIn.match(TIER_FIELD);
515
706
  if (!m)
516
707
  return null;
517
708
  const t = Number(m[4]);
@@ -789,19 +980,105 @@ function checkIssueRationale(body) {
789
980
  function isTaskIssueLabelSet(labels) {
790
981
  return hasLabel("tranche", labels);
791
982
  }
983
+ var PATH_TEXT = (body) => stripCode(body, { inlineSpans: "keep" });
984
+ var DOC_EXTENSION_RE = /\.(?:md|mdx|txt)$/i;
985
+ function namesPath(text, path) {
986
+ const escaped = path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
987
+ const occurrences = new RegExp(`(?:^|[^\\w./-])(${escaped}(?:/[\\w.@-]+)*)(?![\\w-])`, "gm");
988
+ for (const m of text.matchAll(occurrences)) {
989
+ if (!DOC_EXTENSION_RE.test(m[1]))
990
+ return true;
991
+ }
992
+ return false;
993
+ }
994
+ var BLAST_RADIUS_ACK_RE = /(?:\*\*)?blast-radius-ack(?:\*\*)?\s*[:—–-]/i;
995
+ function checkBlastRadiusScope(body, _labels, sharedPackages, projectPaths) {
996
+ if (sharedPackages.length === 0)
997
+ return { status: "pass", errors: [] };
998
+ const text = [
999
+ rationaleFieldText(PATH_TEXT(body), "Boundary"),
1000
+ rationaleFieldText(PATH_TEXT(body), "Project\\(s\\)|Project(?:s)?\\s*\\+|blast radius")
1001
+ ].join(`
1002
+ `);
1003
+ const named = sharedPackages.filter((d) => namesPath(text, d));
1004
+ if (named.length === 0)
1005
+ return { status: "pass", errors: [] };
1006
+ const registered = new Set(projectPaths.map((p) => p.name.trim()));
1007
+ const declared = projectsFromBody(body);
1008
+ const projects = declared.filter((p) => registered.has(p));
1009
+ const ownedPaths = projectPaths.filter((p) => projects.includes(p.name)).map((p) => p.path.replace(/\/+$/, ""));
1010
+ const unowned = named.filter((d) => !ownedPaths.some((owned) => d === owned || d.startsWith(`${owned}/`)));
1011
+ if (unowned.length === 0)
1012
+ return { status: "pass", errors: [] };
1013
+ if (projects.length > 1)
1014
+ return { status: "pass", errors: [] };
1015
+ if (BLAST_RADIUS_ACK_RE.test(PATH_TEXT(body)))
1016
+ return { status: "pass", errors: [] };
1017
+ const unregistered = declared.filter((p) => !registered.has(p));
1018
+ return {
1019
+ status: "fail",
1020
+ errors: [
1021
+ `issue-validation blast radius: the rationale names ${unowned.join(", ")} — a shared collision domain no declared project (${projects.join(", ") || "none"}) owns — but declares a single registered project and no \`blast-radius-ack:\` line.${unregistered.length > 0 ? ` Not counted: ${unregistered.join(", ")} — no row in \`.vinaya/projects.md\`, so it adds no review lens.` : ""} Project(s) drives the review fan-out (.vinaya/projects.md); list every consumer in the blast radius, or add \`blast-radius-ack: <why one lens is enough>\`.`
1022
+ ]
1023
+ };
1024
+ }
1025
+ var BRIEF_MARKERS = [
1026
+ { name: "## References", pattern: /(?:^#{1,6}\s*|\*\*)\s*References\b/im },
1027
+ { name: "Technical surface map", pattern: /(?:^#{1,6}\s*|\*\*)\s*Technical surface map\b/im },
1028
+ { name: "Premise", pattern: /(?:^#{1,6}\s*|\*\*)\s*Premise(?:\*\*)?\s*[:—–]/im },
1029
+ { name: "Step 0", pattern: /(?:^#{1,6}\s*|\*\*)\s*Step 0\b/im },
1030
+ { name: "Test Plan", pattern: /(?:^#{1,6}\s*|\*\*)\s*Test Plan\b/im }
1031
+ ];
1032
+ function checkNoBriefContent(body) {
1033
+ const text = stripCode(body);
1034
+ const errors = BRIEF_MARKERS.filter((m) => m.pattern.test(text)).map((m) => `issue-validation brief content: the Issue body carries a brief-shaped "${m.name}" section. Brief-time content (surface pointers, skills-to-read, premise, test plan) belongs in the brief, not the Issue — it goes stale before work starts (aeg-root/skills/brief-authoring/SKILL.md). Move it to the brief.`);
1035
+ return { status: errors.length > 0 ? "fail" : "pass", errors };
1036
+ }
1037
+ function rationaleFieldText(text, labelPattern) {
1038
+ const re = new RegExp(`(?:\\*\\*|^#{1,4}\\s+)\\s*(?:${labelPattern})[^\\n]*\\n?([\\s\\S]*?)(?=\\n\\s*(?:\\*\\*[A-Z]|#{1,4}\\s)|(?![\\s\\S]))`, "im");
1039
+ const m = re.exec(text);
1040
+ return m ? m[0] : "";
1041
+ }
1042
+ var DOC_PATH_RE = /(?:(?:aeg-root|apps|packages|specs|docs|tools|\.claude|\.github)\/[\w./@-]*\.(?:md|mdx)|\.claude\/(?:skills|rules)\/[\w./-]+|\b(?:docs-index|decisions|projects|state-machine|enforcement|process|README|CLAUDE)\.md\b|\b[\w-]+-(?:spec|decisions|backlog)\.md\b)/i;
1043
+ var NO_DOC_SURFACE_RE = /(?:\*\*)?\s*no-doc-surface/i;
1044
+ function checkRationaleNamesDocs(body) {
1045
+ const text = PATH_TEXT(body);
1046
+ const scope = [rationaleFieldText(text, "Docs to keep coherent|§7"), rationaleFieldText(text, "Traps")].join(`
1047
+ `);
1048
+ if (NO_DOC_SURFACE_RE.test(scope))
1049
+ return { status: "pass", errors: [] };
1050
+ if (DOC_PATH_RE.test(scope))
1051
+ return { status: "pass", errors: [] };
1052
+ return {
1053
+ status: "fail",
1054
+ errors: [
1055
+ 'issue-validation docs read: neither "Docs to keep coherent" nor "Traps to avoid" names a concrete doc path (aeg-root/…, .claude/skills/…, apps/*/CLAUDE.md, apps/*/specs/…). Naming one is the artifact of having read the surface being planned — the forge write triggers no skill-check hook, so this field is the only read-obligation signal. For a genuinely doc-less surface, write the explicit `no-doc-surface` sentinel.'
1056
+ ]
1057
+ };
1058
+ }
792
1059
 
793
1060
  // ../../packages/aeg-core/src/coherence-checks.ts
794
1061
  var R1_GRANDFATHERED_ISSUES = new Set([279, 280, 281, 282]);
795
1062
  // ../../packages/aeg-core/src/verdict-extraction.ts
1063
+ var HEAD_SHA_PATTERN = /^[ \t]*(?:\*{1,3}|_{1,3})?Judged head:\s*([0-9a-f]{7,40})(?![A-Za-z0-9])/im;
1064
+ function extractHeadSha(comment) {
1065
+ const m = comment.match(HEAD_SHA_PATTERN);
1066
+ return m ? m[1].toLowerCase() : null;
1067
+ }
796
1068
  function extractVerdict(comments, valuePattern, missingLabel) {
797
1069
  const clearHits = comments.filter((c) => valuePattern.test(c));
798
1070
  if (clearHits.length > 0) {
799
1071
  const latest = clearHits[clearHits.length - 1];
800
1072
  const m = latest.match(valuePattern);
801
- return { value: m[1].toUpperCase().replace(/[_-]/g, " "), danglingNote: null };
1073
+ return {
1074
+ value: m[1].toUpperCase().replace(/[_-]/g, " "),
1075
+ headSha: extractHeadSha(latest),
1076
+ danglingNote: null
1077
+ };
802
1078
  }
803
1079
  return {
804
1080
  value: `no ${missingLabel} pass was run before merge — DANGLING, see below`,
1081
+ headSha: null,
805
1082
  danglingNote: `no ${missingLabel} verdict comment found on this PR`
806
1083
  };
807
1084
  }
@@ -963,6 +1240,7 @@ var CHECKS_WORKFLOW_PATH = ".github/workflows/vinaya-checks.yml";
963
1240
  var REVIEW_WORKFLOW_PATH = ".github/workflows/vinaya-review.yml";
964
1241
  var REVIEW_VERDICT_WORKFLOW_PATH = ".github/workflows/vinaya-review-verdict.yml";
965
1242
  var ARCHIVIST_WORKFLOW_PATH = ".github/workflows/vinaya-archivist.yml";
1243
+ var BODY_CHECKS_WORKFLOW_PATH = ".github/workflows/vinaya-body-checks.yml";
966
1244
  var MANAGED_NOTE = "Managed by Vinaya — created by `vinaya init`. `vinaya upgrade` regenerates it; `vinaya eject` removes it.";
967
1245
  function starterConfig() {
968
1246
  return {
@@ -989,9 +1267,31 @@ function ownVersion() {
989
1267
  return pkg.version;
990
1268
  }
991
1269
  var SETUP_BUN_SHA = "0c5077e51419868618aeaa5fe8019c62421857d6";
992
- function vinayaSetupSteps(selfHost) {
1270
+ function vinayaSetupSteps(selfHost, sourceTrust = "pull-request") {
993
1271
  if (!selfHost)
994
1272
  return "";
1273
+ if (sourceTrust === "trusted") {
1274
+ return ` # Pinned to a commit, not the mutable \`v2\` tag. A repoint of
1275
+ # \`v2\` would execute new upstream code in every adopter on the next run,
1276
+ # with no diff anywhere to review. Resolved from the \`v2\` tag on
1277
+ # 2026-08-14. Bun's own version still comes from the repo's
1278
+ # \`packageManager\` field, not from this pin.
1279
+ - uses: oven-sh/setup-bun@${SETUP_BUN_SHA}
1280
+ # This repo declares the \`@attalabs/vinaya\` workspace package itself, so
1281
+ # \`npx @attalabs/vinaya\` resolves to that local member instead of the
1282
+ # registry and dies on its unbuilt \`bin\`. Build and run the trusted
1283
+ # default-branch copy directly.
1284
+ #
1285
+ # This workflow executes only a trusted checkout selected by its event:
1286
+ # never the pull request head or merge ref. The review-authority decision
1287
+ # therefore comes from code the pull request cannot alter. \`--ignore-scripts\`
1288
+ # remains defense in depth against lifecycle hooks in that trusted tree.
1289
+ - name: Build the trusted Vinaya CLI
1290
+ run: |
1291
+ bun install --frozen-lockfile --ignore-scripts
1292
+ bun run --cwd ${selfHost.dir} build
1293
+ `;
1294
+ }
995
1295
  return ` # Pinned to a commit, not the mutable \`v2\` tag. This is the first
996
1296
  # THIRD-PARTY action this generator writes into an adopter's repository,
997
1297
  # and it runs in the same job that then builds and executes pull-request
@@ -1095,7 +1395,7 @@ jobs:
1095
1395
  - uses: actions/setup-node@v4
1096
1396
  with:
1097
1397
  node-version: 20
1098
- ${vinayaSetupSteps(selfHost)}${adopterSetupStep(ciSetup)} - name: Run checks
1398
+ ${vinayaSetupSteps(selfHost, "pull-request")}${adopterSetupStep(ciSetup)} - name: Run checks
1099
1399
  env:
1100
1400
  GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
1101
1401
  PR_NUMBER: \${{ github.event.pull_request.number }}
@@ -1129,21 +1429,27 @@ ${vinayaSetupSteps(selfHost)}${adopterSetupStep(ciSetup)} - name: Run check
1129
1429
  } >> "$GITHUB_STEP_SUMMARY"
1130
1430
  `;
1131
1431
  }
1132
- function reviewWorkflow(selfHost, ciSetup) {
1432
+ function reviewWorkflow(selfHost) {
1133
1433
  return `# ${MANAGED_NOTE}
1134
1434
  #
1135
- # The required review gate pull_request events only. The verdict-comment
1435
+ # The required review gate. \`pull_request_target\` is the trust boundary:
1436
+ # GitHub loads this workflow and its default checkout from the repository's
1437
+ # default branch, never from the pull request being judged. The job does not
1438
+ # fetch, check out, install, build, or execute pull-request content.
1439
+ #
1440
+ # The verdict-comment
1136
1441
  # half lives in its own workflow (vinaya-review-verdict.yml): a new PR
1137
- # comment fires a different GitHub event that this pull_request-only
1442
+ # comment fires a different GitHub event that this
1138
1443
  # workflow structurally cannot receive — and keeping the comment path in a
1139
1444
  # separate FILE means this workflow's runs never list permanently-skipped
1140
1445
  # comment jobs on the PR's checks panel. When a clean final verdict lands,
1141
1446
  # the verdict workflow re-runs this one, so the required check below goes
1142
1447
  # green natively with no manual rerun.
1143
1448
  name: Vinaya Review Gate
1449
+ run-name: "Vinaya Review Gate PR #\${{ github.event.pull_request.number }} @ \${{ github.event.pull_request.head.sha }}"
1144
1450
 
1145
1451
  on:
1146
- pull_request:
1452
+ pull_request_target:
1147
1453
  types: [opened, synchronize, reopened, labeled, unlabeled]
1148
1454
 
1149
1455
  # One run per pull request COMMIT, always. Several \`types:\` above can fire in the
@@ -1183,30 +1489,96 @@ jobs:
1183
1489
  steps:
1184
1490
  - uses: actions/checkout@v4
1185
1491
  with:
1186
- # This job executes repo content; don't leave the token in
1187
- # .git/config for scripts to read.
1492
+ # Explicit trusted ref: never use the PR head/merge ref in this job.
1493
+ ref: \${{ github.event.repository.default_branch }}
1188
1494
  persist-credentials: false
1189
1495
  fetch-depth: 0
1190
1496
  - uses: actions/setup-node@v4
1191
1497
  with:
1192
1498
  node-version: 20
1193
- ${vinayaSetupSteps(selfHost)}${adopterSetupStep(ciSetup)} - name: Review gate
1499
+ ${vinayaSetupSteps(selfHost, "trusted")} - name: Review gate
1194
1500
  env:
1195
1501
  GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
1196
1502
  # PR_NUMBER is what makes the review-gate check EVALUATE: without
1197
1503
  # it the adapter reads "no PR yet — local dev" and exits 0, and
1198
1504
  # the gate is green regardless of review state.
1199
1505
  PR_NUMBER: \${{ github.event.pull_request.number }}
1200
- BRANCH: \${{ github.head_ref }}
1201
1506
  run: ${vinayaRun(selfHost, "check review-gate")}
1202
1507
  `;
1203
1508
  }
1204
- function reviewVerdictWorkflow(selfHost, ciSetup) {
1509
+ function bodyChecksWorkflow(selfHost) {
1510
+ return `# ${MANAGED_NOTE}
1511
+ #
1512
+ # Required, PR-content-independent checks that need a trust anchor no pull
1513
+ # request can rewrite. \`pull_request_target\` is the same boundary
1514
+ # \`vinaya-review.yml\` uses: GitHub loads this workflow and its default
1515
+ # checkout from the repository's default branch, never from the pull
1516
+ # request being judged. The job does not fetch, check out, install, build,
1517
+ # or execute pull-request content — every check registered here reads only
1518
+ # live-fetched PR metadata (\`gh pr view\`), never the diff or repo tree, the
1519
+ # same shape \`review-gate\` already requires of its own checks.
1520
+ #
1521
+ # \`body-bare-digits\` is the one check here today: its Changesets-release
1522
+ # exemption live-fetches the PR's real author, keyed on \`PR_NUMBER\` — on a
1523
+ # \`pull_request\` trigger that value is PR-editable (the PR's own workflow
1524
+ # YAML controls it), so an attacker could redirect it to any
1525
+ # already-approved PR by the configured release actor. Found live (round 5,
1526
+ # security review, PR #165), and verified no env-var or git-state signal
1527
+ # inside a \`pull_request\` job closes it — \`pull_request_target\` does,
1528
+ # because the workflow text assigning \`PR_NUMBER\` comes from THIS file on
1529
+ # the default branch, which a pull request cannot edit.
1530
+ name: Vinaya Body Checks
1531
+ run-name: "Vinaya Body Checks PR #\${{ github.event.pull_request.number }} @ \${{ github.event.pull_request.head.sha }}"
1532
+
1533
+ on:
1534
+ pull_request_target:
1535
+ types: [opened, synchronize, reopened, edited]
1536
+
1537
+ # Same collapsing rationale as \`vinaya-review.yml\`'s concurrency group — see
1538
+ # that file's own comment for the two measured failure modes (duplicate
1539
+ # runs from simultaneous event types; an old rerun cancelling the current
1540
+ # commit's run) this group exists to prevent.
1541
+ concurrency:
1542
+ group: vinaya-body-checks-\${{ github.event.pull_request.number || github.ref }}-\${{ github.event.pull_request.head.sha || github.sha }}
1543
+ cancel-in-progress: true
1544
+
1545
+ jobs:
1546
+ vinaya-body-checks:
1547
+ name: vinaya check body-bare-digits
1548
+ runs-on: ubuntu-latest
1549
+ permissions:
1550
+ contents: read
1551
+ pull-requests: read
1552
+ steps:
1553
+ - uses: actions/checkout@v4
1554
+ with:
1555
+ # Explicit trusted ref: never use the PR head/merge ref in this job.
1556
+ ref: \${{ github.event.repository.default_branch }}
1557
+ persist-credentials: false
1558
+ fetch-depth: 0
1559
+ - uses: actions/setup-node@v4
1560
+ with:
1561
+ node-version: 20
1562
+ ${vinayaSetupSteps(selfHost, "trusted")} - name: Body checks
1563
+ env:
1564
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
1565
+ # PR_NUMBER is what makes the Changesets-release exemption
1566
+ # EVALUATE its live gh pr view fetch: without it the check reads
1567
+ # "no PR yet — local dev" and falls through to the ordinary
1568
+ # bare-digit scan.
1569
+ PR_NUMBER: \${{ github.event.pull_request.number }}
1570
+ # PR_BODY is what makes body-bare-digits EVALUATE at all — the bin
1571
+ # reads \`process.env.PR_BODY\` only, never fetches it itself.
1572
+ PR_BODY: \${{ github.event.pull_request.body }}
1573
+ run: ${vinayaRun(selfHost, "check body-bare-digits")}
1574
+ `;
1575
+ }
1576
+ function reviewVerdictWorkflow(selfHost) {
1205
1577
  return `# ${MANAGED_NOTE}
1206
1578
  #
1207
1579
  # The verdict-comment half of the review gate. A reviewer's verdict arrives
1208
1580
  # as a PR comment (\`VERDICT: APPROVE\` / \`VERDICT: PASS\`), which fires
1209
- # GitHub's \`issue_comment\` event — an event the required pull_request
1581
+ # GitHub's \`issue_comment\` event — an event the required PR-event
1210
1582
  # workflow cannot receive. This workflow evaluates the gate on that comment
1211
1583
  # and, when the evaluation is clean, RE-RUNS the required workflow so its
1212
1584
  # check goes green natively with no manual rerun. (Writing check-run
@@ -1214,11 +1586,10 @@ function reviewVerdictWorkflow(selfHost, ciSetup) {
1214
1586
  # restricts check-run updates to the owning workflow — re-running is the
1215
1587
  # supported channel.)
1216
1588
  #
1217
- # Privilege split, deliberate: \`evaluate\` checks out and executes repo
1218
- # content and therefore holds NO write permission; \`retrigger\` holds
1219
- # \`actions: write\` but checks out and executes nothing its only inputs
1220
- # are the evaluator's outputs, resolved via \`gh pr view\` before any repo
1221
- # content ran. A malicious branch can at worst fail its own evaluation.
1589
+ # Privilege split, deliberate: \`evaluate\` executes only the default branch's
1590
+ # trusted gate code and holds NO write permission; \`retrigger\` holds
1591
+ # \`actions: write\` but checks out and executes nothing. Neither job checks
1592
+ # out or executes pull-request content.
1222
1593
  name: Vinaya Review Gate (on verdict)
1223
1594
 
1224
1595
  on:
@@ -1235,38 +1606,36 @@ jobs:
1235
1606
  pull-requests: read
1236
1607
  issues: read
1237
1608
  outputs:
1238
- branch: \${{ steps.pr.outputs.branch }}
1609
+ number: \${{ steps.pr.outputs.number }}
1239
1610
  sha: \${{ steps.pr.outputs.sha }}
1240
1611
  steps:
1241
- # issue_comment payloads carry no PR head SHA/branch — resolve them
1242
- # before checkout, and check out that exact commit (the event's default
1243
- # ref is the repo's default branch, not the PR head).
1612
+ # issue_comment payloads carry no PR head SHA — resolve it for immutable
1613
+ # run identity only. It never selects executable code.
1244
1614
  - name: Resolve PR head
1245
1615
  id: pr
1246
1616
  env:
1247
1617
  GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
1248
1618
  run: |
1249
1619
  NUMBER="\${{ github.event.issue.number }}"
1250
- BRANCH=$(gh pr view "$NUMBER" --repo "\${{ github.repository }}" --json headRefName -q .headRefName)
1251
1620
  SHA=$(gh pr view "$NUMBER" --repo "\${{ github.repository }}" --json headRefOid -q .headRefOid)
1252
1621
  echo "number=$NUMBER" >> "$GITHUB_OUTPUT"
1253
- echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
1254
1622
  echo "sha=$SHA" >> "$GITHUB_OUTPUT"
1255
1623
  - uses: actions/checkout@v4
1256
1624
  with:
1257
- ref: \${{ steps.pr.outputs.sha }}
1625
+ # issue_comment already sources workflows from the default branch;
1626
+ # make the trusted code checkout explicit too.
1627
+ ref: \${{ github.event.repository.default_branch }}
1258
1628
  persist-credentials: false
1259
1629
  fetch-depth: 0
1260
1630
  - uses: actions/setup-node@v4
1261
1631
  with:
1262
1632
  node-version: 20
1263
- ${vinayaSetupSteps(selfHost)}${adopterSetupStep(ciSetup)} - name: Review gate (verdict evaluation)
1633
+ ${vinayaSetupSteps(selfHost, "trusted")} - name: Review gate (verdict evaluation)
1264
1634
  env:
1265
1635
  GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
1266
1636
  # Same wiring as the required workflow: PR_NUMBER is what makes
1267
1637
  # the adapter evaluate instead of no-op'ing as "local dev".
1268
1638
  PR_NUMBER: \${{ steps.pr.outputs.number }}
1269
- BRANCH: \${{ steps.pr.outputs.branch }}
1270
1639
  run: ${vinayaRun(selfHost, "check review-gate")}
1271
1640
 
1272
1641
  # Executes nothing; consumes only the evaluator's outputs.
@@ -1287,16 +1656,11 @@ ${vinayaSetupSteps(selfHost)}${adopterSetupStep(ciSetup)} - name: Review ga
1287
1656
  - name: Re-run the required review gate for this branch
1288
1657
  env:
1289
1658
  GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
1290
- BRANCH: \${{ needs.evaluate.outputs.branch }}
1659
+ PR_NUMBER: \${{ needs.evaluate.outputs.number }}
1291
1660
  HEAD_SHA: \${{ needs.evaluate.outputs.sha }}
1292
1661
  run: |
1293
- # \`--branch ""\` is NOT a no-op: gh drops the filter and lists runs
1294
- # across every branch, so the rerun would land on an unrelated PR's
1295
- # gate. Reachable now that this job runs on a failed evaluation —
1296
- # \`Resolve PR head\` is the only producer of this output, and its
1297
- # failure used to be unreachable from here.
1298
- if [ -z "$BRANCH" ]; then
1299
- echo "No branch resolved (the evaluate job failed before resolving it) - nothing to re-run."
1662
+ if [ -z "$PR_NUMBER" ]; then
1663
+ echo "No PR number resolved (the evaluate job failed before resolving it) - nothing to re-run."
1300
1664
  exit 0
1301
1665
  fi
1302
1666
  # Same guard, same producer: an empty SHA would make the selection
@@ -1306,43 +1670,28 @@ ${vinayaSetupSteps(selfHost)}${adopterSetupStep(ciSetup)} - name: Review ga
1306
1670
  echo "No head SHA resolved (the evaluate job failed before resolving it) - nothing to re-run."
1307
1671
  exit 0
1308
1672
  fi
1309
- # Select by HEAD SHA, never by recency. "Newest completed" looks
1310
- # right and is not: \`--status completed\` excludes a run that is
1311
- # currently re-running but INCLUDES cancelled ones, so a verdict
1312
- # arriving while the current head's run is still in flight can
1313
- # select a stale cancelled sibling, rerun it, and — because the
1314
- # review workflow's concurrency group is keyed per pull request —
1315
- # cancel the live evaluation it was supposed to refresh.
1316
- #
1317
- # The run for this head SHA is the only one whose conclusion gates
1318
- # the merge; runs for earlier commits are scoped to those commits
1319
- # and never gate the current head. Cancelled runs are excluded for
1320
- # the same reason, not a mechanical one — a rerun with nothing else
1321
- # in its group completes normally. A cancellation here means the
1322
- # \`opened\`+\`labeled\` collapse discarded that run in favour of a
1323
- # sibling on the same commit, and it is the survivor whose
1324
- # conclusion the merge box reads.
1325
- # \`--commit\` filters server-side, deliberately. gh's \`--jq\` is a
1326
- # single-expression flag of its own, NOT a passthrough to jq, so jq's
1327
- # variable-binding flag cannot be used with it: pflag swallows that
1328
- # flag as the expression and gh exits 1 on the stray token — which
1329
- # under \`bash -e\` kills the step before the empty-RUN_ID no-op can
1330
- # run, so every verdict reddens and nothing reruns. Filter on the
1331
- # server and keep the jq expression free of variables.
1332
- RUN_ID=$(gh run list --repo "\${{ github.repository }}" \\
1333
- --workflow vinaya-review.yml --branch "$BRANCH" \\
1334
- --commit "$HEAD_SHA" \\
1335
- --json databaseId,event,status,conclusion \\
1336
- --jq '[.[]
1337
- | select(.event=="pull_request")
1338
- | select(.status=="completed")
1339
- | select(.conclusion!="cancelled")]
1340
- | .[0].databaseId // empty')
1673
+ # pull_request_target runs execute at the DEFAULT branch SHA, so a
1674
+ # run's head_sha cannot identify the PR commit. GitHub's nested
1675
+ # pull_requests snapshot is also not immutable: an old rerun can
1676
+ # expose the PR's current head. The required workflow therefore
1677
+ # records PR number + head SHA in run-name at creation, and this
1678
+ # query matches that immutable display_title exactly.
1679
+ set -o pipefail
1680
+ RUN_TITLE="Vinaya Review Gate PR #$PR_NUMBER @ $HEAD_SHA"
1681
+ RUN_ID=$(gh api --paginate \\
1682
+ "repos/\${{ github.repository }}/actions/workflows/vinaya-review.yml/runs?event=pull_request_target&per_page=100" \\
1683
+ | jq -sr --arg title "$RUN_TITLE" '
1684
+ [.[].workflow_runs[]
1685
+ | select(.display_title == $title)
1686
+ | select(.event == "pull_request_target")
1687
+ | select(.status == "completed")
1688
+ | select(.conclusion != "cancelled")
1689
+ | .id][0] // empty')
1341
1690
  if [ -z "$RUN_ID" ]; then
1342
- echo "No completed, non-cancelled pull_request run of vinaya-review.yml for $HEAD_SHA - nothing to re-run."
1691
+ echo "No completed, non-cancelled pull_request_target run of vinaya-review.yml for PR #$PR_NUMBER at $HEAD_SHA - nothing to re-run."
1343
1692
  exit 0
1344
1693
  fi
1345
- echo "Re-running vinaya-review.yml run $RUN_ID for $HEAD_SHA"
1694
+ echo "Re-running vinaya-review.yml run $RUN_ID for PR #$PR_NUMBER at $HEAD_SHA"
1346
1695
  # Two verdict comments landing together run two retriggers in
1347
1696
  # parallel (this workflow has no concurrency group, deliberately —
1348
1697
  # a queued retrigger is a delayed one). Both can select the same
@@ -1536,6 +1885,14 @@ function starterDocOwners() {
1536
1885
  # This file absent, OR no glob matches any changed code file → silent no-op.
1537
1886
  # The gate has no opinion until you teach it one.
1538
1887
  #
1888
+ # \`vinaya doctor\` (never \`vinaya check\`) separately reports — it does not
1889
+ # fail the check — any binding whose code glob matches zero tracked files
1890
+ # anywhere in the repo, or whose in-repo pointer doesn't exist on disk. This
1891
+ # catches what the dormancy rule above structurally cannot: a glob matching
1892
+ # nothing trivially satisfies "no changed file matched" on every diff,
1893
+ # forever, so a binding naming deleted or renamed code reads as healthy
1894
+ # indefinitely unless something walks the whole repo, not just one diff.
1895
+ #
1539
1896
  # ── No-doc allow-list ───────────────────────────────────────────────────────
1540
1897
  # Surfaces that legitimately need no bound doc are listed below. Format:
1541
1898
  # # no-doc: <glob> — <reason>
@@ -1575,6 +1932,13 @@ var BRANCH_PROTECTION_NOTE = `Recommended (run yourself — vinaya never applies
1575
1932
  -F required_status_checks.strict=true \\
1576
1933
  -F 'required_status_checks.contexts[]=vinaya-checks' \\
1577
1934
  -F enforce_admins=true -F restrictions=`;
1935
+ var CODEOWNERS_NOTE = `Recommended: require review on vinaya's own workflow files.
1936
+
1937
+ echo '/.github/workflows/** @your-github-login' >> .github/CODEOWNERS
1938
+
1939
+ Then add this flag to the branch-protection command above:
1940
+
1941
+ -F required_pull_request_reviews.require_code_owner_reviews=true`;
1578
1942
  function buildInitOps(ctx) {
1579
1943
  const ops = [];
1580
1944
  const hookMode = 493;
@@ -1587,13 +1951,13 @@ function buildInitOps(ctx) {
1587
1951
  ops.push({
1588
1952
  kind: "create-file",
1589
1953
  path: REVIEW_WORKFLOW_PATH,
1590
- content: reviewWorkflow(ctx.selfHost, ctx.ciSetup),
1954
+ content: reviewWorkflow(ctx.selfHost),
1591
1955
  group: "CI workflows"
1592
1956
  });
1593
1957
  ops.push({
1594
1958
  kind: "create-file",
1595
1959
  path: REVIEW_VERDICT_WORKFLOW_PATH,
1596
- content: reviewVerdictWorkflow(ctx.selfHost, ctx.ciSetup),
1960
+ content: reviewVerdictWorkflow(ctx.selfHost),
1597
1961
  group: "CI workflows"
1598
1962
  });
1599
1963
  ops.push({
@@ -1602,6 +1966,12 @@ function buildInitOps(ctx) {
1602
1966
  content: archivistWorkflow(ctx.selfHost),
1603
1967
  group: "CI workflows"
1604
1968
  });
1969
+ ops.push({
1970
+ kind: "create-file",
1971
+ path: BODY_CHECKS_WORKFLOW_PATH,
1972
+ content: bodyChecksWorkflow(ctx.selfHost),
1973
+ group: "CI workflows"
1974
+ });
1605
1975
  ops.push({
1606
1976
  kind: "managed-block",
1607
1977
  path: `${ctx.hookDir}/pre-commit`,
@@ -1652,6 +2022,7 @@ function buildInitOps(ctx) {
1652
2022
  });
1653
2023
  ops.push(...labelOps());
1654
2024
  ops.push({ kind: "print", message: BRANCH_PROTECTION_NOTE, group: "Branch protection (printed, never applied)" });
2025
+ ops.push({ kind: "print", message: CODEOWNERS_NOTE, group: "Branch protection (printed, never applied)" });
1655
2026
  return ops;
1656
2027
  }
1657
2028
  function buildInitProductOps(name) {
@@ -1668,11 +2039,12 @@ function buildInitProductOps(name) {
1668
2039
  }
1669
2040
 
1670
2041
  // src/lib/ops.ts
1671
- import { execFileSync as execFileSync2 } from "node:child_process";
2042
+ import { execFileSync as execFileSync3 } from "node:child_process";
1672
2043
  import { chmodSync, existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
1673
2044
  import { dirname as dirname3, isAbsolute, join as join4, resolve, sep } from "node:path";
1674
2045
 
1675
2046
  // src/lib/config.ts
2047
+ import { execFileSync as execFileSync2 } from "node:child_process";
1676
2048
  import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "node:fs";
1677
2049
  import { homedir } from "node:os";
1678
2050
  import { dirname as dirname2, join as join3 } from "node:path";
@@ -1800,7 +2172,9 @@ var VinayaConfigSchema = z.object({
1800
2172
  briefSchema: BriefSchemaSchema.optional(),
1801
2173
  managed: ManagedManifestSchema.optional(),
1802
2174
  principals: z.array(z.string()).min(1).optional(),
1803
- ci: z.object({ setup: z.string().min(1) }).optional()
2175
+ releaseActor: z.string().min(1).optional(),
2176
+ ci: z.object({ setup: z.string().min(1) }).optional(),
2177
+ blastRadius: z.object({ extraDomains: z.array(z.string()).optional() }).optional()
1804
2178
  });
1805
2179
  var GLOBAL_VINAYA_HOME = join3(homedir(), ".vinaya");
1806
2180
  var GLOBAL_CONFIG_PATH = join3(GLOBAL_VINAYA_HOME, "config.json");
@@ -1833,6 +2207,9 @@ function globalChecksIgnoredWarning(path) {
1833
2207
  function globalPrincipalsIgnoredWarning(path) {
1834
2208
  return `${path}: "principals" in the global config is ignored — principals may only be declared from a repo-local vinaya.config.json.`;
1835
2209
  }
2210
+ function globalReleaseActorIgnoredWarning(path) {
2211
+ return `${path}: "releaseActor" in the global config is ignored — releaseActor may only be declared from a repo-local vinaya.config.json.`;
2212
+ }
1836
2213
  function stripGlobalOnlyKeys(config, path) {
1837
2214
  if (path !== GLOBAL_CONFIG_PATH)
1838
2215
  return config;
@@ -1845,8 +2222,81 @@ function stripGlobalOnlyKeys(config, path) {
1845
2222
  console.error(`⚠ ${globalPrincipalsIgnoredWarning(path)}`);
1846
2223
  result = { ...result, principals: undefined };
1847
2224
  }
2225
+ if (result.releaseActor) {
2226
+ console.error(`⚠ ${globalReleaseActorIgnoredWarning(path)}`);
2227
+ result = { ...result, releaseActor: undefined };
2228
+ }
1848
2229
  return result;
1849
2230
  }
2231
+ function resolvePrincipalAllowlist(config) {
2232
+ return config?.principals ?? PRINCIPAL_ALLOWLIST;
2233
+ }
2234
+ function trustAnchorRepo() {
2235
+ const wellFormed = (slug) => /^[^/\s]+\/[^/\s]+$/.test(slug) ? slug : null;
2236
+ const fromRunner = process.env.GITHUB_REPOSITORY?.trim();
2237
+ if (fromRunner) {
2238
+ const validated = wellFormed(fromRunner);
2239
+ if (validated)
2240
+ return validated;
2241
+ }
2242
+ try {
2243
+ const url = execFileSync2("git", ["remote", "get-url", "origin"], {
2244
+ encoding: "utf-8",
2245
+ stdio: ["ignore", "pipe", "pipe"]
2246
+ }).trim();
2247
+ const m = url.match(/github\.com[:/]([^/]+)\/(.+?)(?:\.git)?\/?$/);
2248
+ return m?.[1] && m[2] ? wellFormed(`${m[1]}/${m[2]}`) : null;
2249
+ } catch {
2250
+ return null;
2251
+ }
2252
+ }
2253
+ function ghFetchTrustAnchorConfig() {
2254
+ const repo = trustAnchorRepo();
2255
+ if (!repo)
2256
+ throw new Error("could not resolve repo identity for the trust-anchor read");
2257
+ return execFileSync2("gh", ["api", `repos/${repo}/contents/${LOCAL_CONFIG_FILENAME}`, "--jq", ".content"], {
2258
+ encoding: "utf-8",
2259
+ stdio: ["ignore", "pipe", "pipe"]
2260
+ });
2261
+ }
2262
+ function firstLine(err) {
2263
+ return err?.message?.split(`
2264
+ `)[0] ?? "unknown error";
2265
+ }
2266
+ function isMissingFileError(err) {
2267
+ const stderr = err?.stderr;
2268
+ const haystack = [
2269
+ err?.message ?? "",
2270
+ typeof stderr === "string" ? stderr : stderr?.toString() ?? ""
2271
+ ].join(`
2272
+ `);
2273
+ return /\b404\b|not found/i.test(haystack);
2274
+ }
2275
+ function loadTrustAnchorConfig(fetcher = ghFetchTrustAnchorConfig) {
2276
+ const warn = (why) => process.stdout.write(`⚠ could not read the trust-anchor config (\`principals\`/\`releaseActor\`) from the default branch (${why}) — falling back to vinaya's built-in defaults.
2277
+ `);
2278
+ let base64;
2279
+ try {
2280
+ base64 = fetcher().trim();
2281
+ } catch (err) {
2282
+ if (!isMissingFileError(err))
2283
+ warn(firstLine(err));
2284
+ return null;
2285
+ }
2286
+ if (!base64)
2287
+ return null;
2288
+ try {
2289
+ const raw = Buffer.from(base64, "base64").toString("utf-8");
2290
+ const parsed = VinayaConfigSchema.safeParse(JSON.parse(raw));
2291
+ if (parsed.success)
2292
+ return parsed.data;
2293
+ warn("it did not satisfy the config schema");
2294
+ return null;
2295
+ } catch {
2296
+ warn("it was not readable as JSON");
2297
+ return null;
2298
+ }
2299
+ }
1850
2300
  function readRepoCiSetup(repoRoot) {
1851
2301
  const p = join3(repoRoot, "vinaya.config.json");
1852
2302
  if (!existsSync2(p))
@@ -1897,7 +2347,7 @@ function resolveManagedBlockPath(repoRoot, opPath) {
1897
2347
  if (!opPath.startsWith(".git/"))
1898
2348
  return join4(repoRoot, opPath);
1899
2349
  try {
1900
- const commonDir = execFileSync2("git", ["-C", repoRoot, "rev-parse", "--git-common-dir"], {
2350
+ const commonDir = execFileSync3("git", ["-C", repoRoot, "rev-parse", "--git-common-dir"], {
1901
2351
  encoding: "utf8"
1902
2352
  }).trim();
1903
2353
  const gitDir = isAbsolute(commonDir) ? commonDir : join4(repoRoot, commonDir);
@@ -2463,7 +2913,7 @@ function realDeps() {
2463
2913
  return { detectRepo: detectGitRepo };
2464
2914
  }
2465
2915
  function sh(args, input) {
2466
- return execFileSync3(args[0], args.slice(1), { encoding: "utf8", input }).trim();
2916
+ return execFileSync4(args[0], args.slice(1), { encoding: "utf8", input }).trim();
2467
2917
  }
2468
2918
  function shJson(args) {
2469
2919
  return JSON.parse(sh(args));
@@ -2639,7 +3089,7 @@ async function archiveTrancheCommand(args) {
2639
3089
  }
2640
3090
 
2641
3091
  // src/commands/audit.ts
2642
- import { execFileSync as execFileSync4 } from "node:child_process";
3092
+ import { execFileSync as execFileSync5 } from "node:child_process";
2643
3093
 
2644
3094
  // src/lib/envelope.ts
2645
3095
  var ENVELOPE_SCHEMA_VERSION = 1;
@@ -2667,7 +3117,7 @@ var DIRECT_PUSH_MARKER = `<!-- ${DIRECT_PUSH_LABEL} -->`;
2667
3117
  var DIRECT_PUSH_LABEL_DESCRIPTION = "A commit landed on main with no associated merged PR (ring-2 detection)";
2668
3118
  function shSoft(args, input) {
2669
3119
  try {
2670
- return execFileSync4(args[0], args.slice(1), {
3120
+ return execFileSync5(args[0], args.slice(1), {
2671
3121
  encoding: "utf8",
2672
3122
  input,
2673
3123
  timeout: COMMAND_TIMEOUT_MS
@@ -2691,7 +3141,7 @@ function shSoftJson(args) {
2691
3141
  }
2692
3142
  }
2693
3143
  function sh2(args, input) {
2694
- return execFileSync4(args[0], args.slice(1), { encoding: "utf8", input }).trim();
3144
+ return execFileSync5(args[0], args.slice(1), { encoding: "utf8", input }).trim();
2695
3145
  }
2696
3146
  function listTaskBranches() {
2697
3147
  const out = shSoft(["git", "ls-remote", "--heads", "origin", "task/*"]);
@@ -2941,7 +3391,7 @@ async function auditCommand(args) {
2941
3391
  }
2942
3392
 
2943
3393
  // src/commands/check.ts
2944
- import { execFileSync as execFileSync5 } from "node:child_process";
3394
+ import { execFileSync as execFileSync6 } from "node:child_process";
2945
3395
 
2946
3396
  // src/checks/contract.ts
2947
3397
  var CHECK_SCHEMA_VERSION = 1;
@@ -3051,6 +3501,21 @@ function coreCheckRegistry() {
3051
3501
  BRANCH: { optional: true }
3052
3502
  }
3053
3503
  },
3504
+ {
3505
+ name: "body-bare-digits",
3506
+ run: bin("check-body-bare-digits"),
3507
+ scope: "diff",
3508
+ timeoutMs: 15000,
3509
+ requiresOpenPr: true,
3510
+ ownWorkflow: true,
3511
+ env: {
3512
+ PR_BODY: { optional: true },
3513
+ PR_NUMBER: { optional: true },
3514
+ GITHUB_REPOSITORY: { optional: true },
3515
+ GITHUB_TOKEN: { optional: true },
3516
+ GH_TOKEN: { optional: true }
3517
+ }
3518
+ },
3054
3519
  {
3055
3520
  name: "no-disk-state",
3056
3521
  run: bin("check-no-disk-state"),
@@ -3076,7 +3541,6 @@ function coreCheckRegistry() {
3076
3541
  timeoutMs: 30000,
3077
3542
  ownWorkflow: true,
3078
3543
  env: {
3079
- BRANCH: { optional: true },
3080
3544
  PR_NUMBER: { optional: true },
3081
3545
  GITHUB_REPOSITORY: { optional: true },
3082
3546
  GITHUB_TOKEN: { optional: true },
@@ -3478,7 +3942,7 @@ async function runChecks(specs, opts) {
3478
3942
  // src/commands/check.ts
3479
3943
  function git(args) {
3480
3944
  try {
3481
- return execFileSync5("git", args, { encoding: "utf8" }).trim();
3945
+ return execFileSync6("git", args, { encoding: "utf8" }).trim();
3482
3946
  } catch {
3483
3947
  return "";
3484
3948
  }
@@ -3611,11 +4075,13 @@ async function checkCommand(args) {
3611
4075
  process.stdout.write(`${renderPlanTable(result)}
3612
4076
  `);
3613
4077
  }
3614
- process.exit(result.failures.length > 0 ? 1 : 0);
4078
+ process.exitCode = result.failures.length > 0 ? 1 : 0;
4079
+ return;
3615
4080
  }
3616
4081
  if (!allRequested && !requestedName) {
3617
4082
  console.error("Usage: vinaya check <name> | --all | --plan [--json] [--diff-only] [--local] [--parallel[=n]]");
3618
- process.exit(2);
4083
+ process.exitCode = 2;
4084
+ return;
3619
4085
  }
3620
4086
  if (refusals.length > 0) {
3621
4087
  const outcome = {
@@ -3636,7 +4102,8 @@ async function checkCommand(args) {
3636
4102
  process.stdout.write(` ${e.severity}: ${e.message}
3637
4103
  `);
3638
4104
  }
3639
- process.exit(1);
4105
+ process.exitCode = 1;
4106
+ return;
3640
4107
  }
3641
4108
  const allSpecs = result.resolved.map((entry2) => entry2.spec);
3642
4109
  const notices = substitutionNotices(result.resolved);
@@ -3650,7 +4117,8 @@ async function checkCommand(args) {
3650
4117
  const specsToRun = allRequested ? allSpecs.filter(runsUnderAll) : allSpecs.filter((s) => s.name === requestedName);
3651
4118
  if (!allRequested && specsToRun.length === 0) {
3652
4119
  console.error(`Unknown check: ${requestedName}`);
3653
- process.exit(2);
4120
+ process.exitCode = 2;
4121
+ return;
3654
4122
  }
3655
4123
  const changed = diffOnly ? changedFiles() : null;
3656
4124
  const outcomes = specsToRun.length > 0 ? await runChecks(specsToRun, {
@@ -3677,18 +4145,18 @@ async function checkCommand(args) {
3677
4145
  }
3678
4146
  }
3679
4147
  const failed = outcomes.some((o) => o.status === "fail" || o.status === "error" || o.status === "timeout");
3680
- process.exit(failed ? 1 : 0);
4148
+ process.exitCode = failed ? 1 : 0;
3681
4149
  }
3682
4150
 
3683
4151
  // src/commands/demo.ts
3684
- import { execFileSync as execFileSync6, spawnSync } from "node:child_process";
4152
+ import { execFileSync as execFileSync7, spawnSync } from "node:child_process";
3685
4153
  import { existsSync as existsSync6, readFileSync as readFileSync5, rmSync as rmSync2, unlinkSync, writeFileSync as writeFileSync3 } from "node:fs";
3686
4154
  import { isAbsolute as isAbsolute2, join as join7 } from "node:path";
3687
4155
  var DEMO_BRANCH_PREFIX = "vinaya/demo-break-";
3688
4156
  var FIXTURE_PATH = ".vinaya-demo-brief.md";
3689
4157
  var STATE_FILENAME = "vinaya-demo-state.json";
3690
4158
  function gitCapture(repoRoot, args) {
3691
- return execFileSync6("git", args, { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
4159
+ return execFileSync7("git", args, { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
3692
4160
  }
3693
4161
  function currentBranchName(repoRoot) {
3694
4162
  try {
@@ -3699,7 +4167,7 @@ function currentBranchName(repoRoot) {
3699
4167
  }
3700
4168
  function branchExists(repoRoot, name) {
3701
4169
  try {
3702
- execFileSync6("git", ["rev-parse", "--verify", "--quiet", `refs/heads/${name}`], {
4170
+ execFileSync7("git", ["rev-parse", "--verify", "--quiet", `refs/heads/${name}`], {
3703
4171
  cwd: repoRoot,
3704
4172
  stdio: ["ignore", "pipe", "pipe"]
3705
4173
  });
@@ -3784,12 +4252,12 @@ Test Plan: unit-tests-only
3784
4252
  ## Stop Conditions
3785
4253
  Autonomy: Do not stop to ask clarifying questions — this is a scripted demo; proceed and clean up automatically.
3786
4254
 
3787
- Illustrative Step 0 (never actually run by this demo):
4255
+ Illustrative \`Step 0\` (never actually run by this demo):
3788
4256
  \`\`\`
3789
4257
  git worktree add .worktrees/demo-fixture -b demo/fixture origin/main
3790
4258
  \`\`\`
3791
4259
 
3792
- Closes #0 (fixture value — no real Issue; discarded with the demo branch)
4260
+ Closes \`#0\` (fixture value — no real Issue; discarded with the demo branch)
3793
4261
  `;
3794
4262
  }
3795
4263
  function cleanup(repoRoot, originalBranch, demoBranch, statePath) {
@@ -3901,7 +4369,8 @@ async function demoBreakCommand(args) {
3901
4369
  }
3902
4370
 
3903
4371
  // src/commands/doctor.ts
3904
- import { existsSync as existsSync8, readFileSync as readFileSync8, statSync as statSync2 } from "node:fs";
4372
+ import { execFileSync as execFileSync8 } from "node:child_process";
4373
+ import { existsSync as existsSync8, readdirSync as readdirSync3, readFileSync as readFileSync8, statSync as statSync2 } from "node:fs";
3905
4374
  import { join as join9 } from "node:path";
3906
4375
 
3907
4376
  // src/lib/self-host.ts
@@ -4182,6 +4651,90 @@ function diagnoseCustomChecks(repoRoot, config) {
4182
4651
  }
4183
4652
  return findings;
4184
4653
  }
4654
+ function listTrackedFiles(repoRoot) {
4655
+ try {
4656
+ return execFileSync8("git", ["ls-files"], { cwd: repoRoot, encoding: "utf-8" }).split(`
4657
+ `).map((s) => s.trim()).filter(Boolean);
4658
+ } catch {
4659
+ return [];
4660
+ }
4661
+ }
4662
+ function diagnoseDocOwnersHealth(repoRoot) {
4663
+ const path = join9(repoRoot, DOC_OWNERS_PATH);
4664
+ const content = existsSync8(path) ? readFileSync8(path, "utf-8") : null;
4665
+ const state = classifyDocOwnersManifest(content);
4666
+ if (state === "absent" || state === "empty")
4667
+ return [];
4668
+ const { bindings } = parseDocOwners(content);
4669
+ if (bindings.length === 0)
4670
+ return [];
4671
+ const codeFiles = listTrackedFiles(repoRoot).filter(isCodeFile);
4672
+ const findings = [];
4673
+ for (const b of bindings) {
4674
+ let flagged = false;
4675
+ const re = globToRegex(b.glob);
4676
+ if (!codeFiles.some((f) => re.test(f))) {
4677
+ flagged = true;
4678
+ findings.push(warn("doc-owners", `${DOC_OWNERS_PATH}:${b.lineNum} binds glob '${b.glob}', which matches none of the ${codeFiles.length} ` + "tracked code file(s) in this repo — the code it names may have been deleted, renamed, or never " + "existed. Repoint the binding to the code's new location, or remove it."));
4679
+ }
4680
+ if (!isUrlPointer(b.pointer)) {
4681
+ const pointerPath = join9(repoRoot, pointerToPath(b.pointer));
4682
+ if (!existsSync8(pointerPath)) {
4683
+ flagged = true;
4684
+ findings.push(warn("doc-owners", `${DOC_OWNERS_PATH}:${b.lineNum} points to ${b.pointer}, which does not exist on disk. Repoint the ` + "binding, or add the missing doc."));
4685
+ }
4686
+ }
4687
+ if (!flagged) {
4688
+ findings.push(ok("doc-owners", `${DOC_OWNERS_PATH}:${b.lineNum} — '${b.glob}' → ${b.pointer} is live.`));
4689
+ }
4690
+ }
4691
+ return findings;
4692
+ }
4693
+ var LEGACY_AEG_PACKAGES_PATH = ".aeg/packages";
4694
+ function readWorkspacesForDoctor(repoRoot) {
4695
+ const fromPackageJson = () => {
4696
+ try {
4697
+ const pkg = JSON.parse(readFileSync8(join9(repoRoot, "package.json"), "utf-8"));
4698
+ return Array.isArray(pkg.workspaces) ? pkg.workspaces.filter((w) => typeof w === "string") : [];
4699
+ } catch {
4700
+ return [];
4701
+ }
4702
+ };
4703
+ const fromPnpmWorkspaceYaml = () => {
4704
+ try {
4705
+ return parsePnpmWorkspaceYaml(readFileSync8(join9(repoRoot, "pnpm-workspace.yaml"), "utf-8"));
4706
+ } catch {
4707
+ return [];
4708
+ }
4709
+ };
4710
+ return [...fromPackageJson(), ...fromPnpmWorkspaceYaml()];
4711
+ }
4712
+ function listChildDirsForDoctor(dir, repoRoot) {
4713
+ try {
4714
+ return readdirSync3(join9(repoRoot, dir), { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
4715
+ } catch {
4716
+ return [];
4717
+ }
4718
+ }
4719
+ function diagnoseBlastRadiusDeprecation(repoRoot, config) {
4720
+ const derived = deriveWorkspacePackageDomains(readWorkspacesForDoctor(repoRoot), (dir) => listChildDirsForDoctor(dir, repoRoot));
4721
+ const defaults = deriveBuiltinCrossCuttingDefaults((p) => existsSync8(join9(repoRoot, p)));
4722
+ const covered = new Set([...derived, ...defaults]);
4723
+ const configExtra = new Set(config?.blastRadius?.extraDomains ?? []);
4724
+ const legacyPath = join9(repoRoot, LEGACY_AEG_PACKAGES_PATH);
4725
+ if (!existsSync8(legacyPath)) {
4726
+ return [
4727
+ info("blast-radius", `checkBlastRadiusScope is active via live derivation (${derived.length} packages/* domain(s)) + built-in defaults (${defaults.length} present) — no ${LEGACY_AEG_PACKAGES_PATH}, and the check is not dormant.`)
4728
+ ];
4729
+ }
4730
+ const legacyEntries = readFileSync8(legacyPath, "utf-8").split(`
4731
+ `).map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith("#"));
4732
+ const uncovered = legacyEntries.filter((e) => !covered.has(e) && !configExtra.has(e));
4733
+ const migrationNote = uncovered.length === 0 ? "every entry is already covered by live derivation, the built-in defaults, or vinaya.config.json blastRadius.extraDomains — the file can be deleted." : `migrate ${uncovered.length} entr${uncovered.length === 1 ? "y" : "ies"} not yet covered before deleting it: ${uncovered.join(", ")}.`;
4734
+ return [
4735
+ warn("blast-radius", `${LEGACY_AEG_PACKAGES_PATH} is deprecated — checkBlastRadiusScope now derives packages/* domains live and ships built-in cross-cutting defaults. Declare anything beyond those in vinaya.config.json's blastRadius.extraDomains instead. ${migrationNote}`)
4736
+ ];
4737
+ }
4185
4738
  function diagnoseEnvDeclarations(repoRoot, config) {
4186
4739
  const resolved = resolveChecks(coreCheckRegistry(), config?.checks).resolved;
4187
4740
  const specs = resolved.map((entry2) => entry2.source === "config" ? { ...entry2.spec, run: join9(repoRoot, entry2.spec.run) } : entry2.spec);
@@ -4242,6 +4795,48 @@ async function diagnoseBranchProtection(deps, owner, repo) {
4242
4795
  }
4243
4796
  return info("branch-protection", "main branch protection could not be determined (no gh auth, no remote, or a permission gap).");
4244
4797
  }
4798
+ function coversWorkflowsDir(pattern) {
4799
+ const bare = pattern.replace(/\*+$/, "").replace(/\/+$/, "");
4800
+ return bare === ".github/workflows" || bare === "/.github/workflows";
4801
+ }
4802
+ function diagnoseCodeowners(repoRoot) {
4803
+ const path = join9(repoRoot, ".github", "CODEOWNERS");
4804
+ if (!existsSync8(path)) {
4805
+ return info("codeowners", "no .github/CODEOWNERS — vinaya's workflow files have no required-review protection; see `vinaya init`'s printed recommendation.");
4806
+ }
4807
+ let body;
4808
+ try {
4809
+ body = readFileSync8(path, "utf-8");
4810
+ } catch (err) {
4811
+ return warn("codeowners", `.github/CODEOWNERS exists but could not be read: ${err.message}`);
4812
+ }
4813
+ const covered = body.split(`
4814
+ `).map((line) => line.trim()).filter((line) => line && !line.startsWith("#")).some((line) => coversWorkflowsDir(line.split(/\s+/)[0] ?? ""));
4815
+ return covered ? info("codeowners", ".github/CODEOWNERS covers .github/workflows/**.") : warn("codeowners", ".github/CODEOWNERS exists but has no entry covering .github/workflows/** — workflow file edits can merge unreviewed.");
4816
+ }
4817
+ var TEST_INVOCATION_SUBSTRINGS = ["npm test", "npm run test", "bun test", "bunx turbo test"];
4818
+ function diagnoseTestCi(repoRoot) {
4819
+ let pkg;
4820
+ try {
4821
+ pkg = JSON.parse(readFileSync8(join9(repoRoot, "package.json"), "utf-8"));
4822
+ } catch {
4823
+ return [];
4824
+ }
4825
+ const testScript = pkg?.scripts?.test;
4826
+ if (typeof testScript !== "string" || testScript.trim() === "")
4827
+ return [];
4828
+ const workflowsDir = join9(repoRoot, ".github/workflows");
4829
+ const files = existsSync8(workflowsDir) ? readdirSync3(workflowsDir).filter((name) => statSync2(join9(workflowsDir, name)).isFile()) : [];
4830
+ const invoked = files.some((name) => {
4831
+ const content = readFileSync8(join9(workflowsDir, name), "utf-8");
4832
+ return TEST_INVOCATION_SUBSTRINGS.some((s) => content.includes(s));
4833
+ });
4834
+ if (invoked)
4835
+ return [];
4836
+ return [
4837
+ warn("test-ci", "no workflow under .github/workflows/ appears to invoke this repo's `package.json` test script — " + "vinaya requires a Test Plan on every PR and enforces it as a blocking gate, but nothing here verifies that tests actually run.")
4838
+ ];
4839
+ }
4245
4840
  function symbolFor(severity) {
4246
4841
  switch (severity) {
4247
4842
  case "ok":
@@ -4295,12 +4890,16 @@ async function runDoctor(args, deps) {
4295
4890
  hasDrift = install.hasDrift;
4296
4891
  findings.push(...await diagnoseHookRouting(repo.repoRoot, hookDir, deps.readHooksPath));
4297
4892
  findings.push(...diagnoseCustomChecks(repo.repoRoot, configRead.config));
4893
+ findings.push(...diagnoseDocOwnersHealth(repo.repoRoot));
4298
4894
  }
4895
+ findings.push(...diagnoseBlastRadiusDeprecation(repo.repoRoot, configRead.kind === "ok" ? configRead.config : null));
4299
4896
  findings.push(...diagnoseEnvDeclarations(repo.repoRoot, configRead.kind === "ok" ? configRead.config : null));
4300
4897
  findings.push(...diagnoseCheckClassification(configRead.kind === "ok" ? configRead.config : null));
4301
4898
  findings.push(...diagnoseGlobalConfigChecks());
4302
4899
  findings.push(...await diagnoseEnvironment(deps, hasDrift));
4303
4900
  findings.push(await diagnoseBranchProtection(deps, repo.owner, repo.repo));
4901
+ findings.push(diagnoseCodeowners(repo.repoRoot));
4902
+ findings.push(...diagnoseTestCi(repo.repoRoot));
4304
4903
  const healthy = findings.every((f) => f.severity === "ok" || f.severity === "info");
4305
4904
  if (jsonOutput) {
4306
4905
  printJson({ healthy, findings });
@@ -4314,8 +4913,9 @@ async function doctorCommand(args) {
4314
4913
  }
4315
4914
 
4316
4915
  // src/commands/doctrine.ts
4317
- import { existsSync as existsSync9 } from "node:fs";
4916
+ import { existsSync as existsSync9, readdirSync as readdirSync4, readFileSync as readFileSync9 } from "node:fs";
4318
4917
  import { dirname as dirname4, join as join10, sep as sep3 } from "node:path";
4918
+ import matter2 from "gray-matter";
4319
4919
  var ENTRY_SEGMENTS = ["skills", "aeg", "SKILL.md"];
4320
4920
  function resolveDoctrineRoot(pkg = packageRoot(import.meta.url)) {
4321
4921
  const candidates = [join10(pkg, "aeg-root")];
@@ -4327,6 +4927,15 @@ function resolveDoctrineRoot(pkg = packageRoot(import.meta.url)) {
4327
4927
  }
4328
4928
  return null;
4329
4929
  }
4930
+ function listRoleNames(root) {
4931
+ const rolesDir = join10(root, "roles");
4932
+ if (!existsSync9(rolesDir))
4933
+ return [];
4934
+ return readdirSync4(rolesDir).filter((name) => name.endsWith(".md")).map((name) => name.slice(0, -".md".length)).filter((roleName) => {
4935
+ const { data } = matter2(readFileSync9(join10(rolesDir, `${roleName}.md`), "utf8"));
4936
+ return data.actor !== "human";
4937
+ }).sort();
4938
+ }
4330
4939
  function doctrineCommand(args) {
4331
4940
  const root = resolveDoctrineRoot();
4332
4941
  if (root === null) {
@@ -4334,6 +4943,24 @@ function doctrineCommand(args) {
4334
4943
  `);
4335
4944
  process.exit(1);
4336
4945
  }
4946
+ const roleFlagIndex = args.indexOf("--role");
4947
+ if (roleFlagIndex !== -1) {
4948
+ const roleName = args[roleFlagIndex + 1];
4949
+ const validRoleNames = listRoleNames(root);
4950
+ if (roleName === undefined || roleName.startsWith("--") || !validRoleNames.includes(roleName)) {
4951
+ process.stderr.write(`vinaya doctrine --role: '${roleName ?? ""}' is not a known role. ` + `Valid role names: ${validRoleNames.join(", ")}
4952
+ `);
4953
+ process.exit(1);
4954
+ }
4955
+ const entry3 = join10(root, "roles", `${roleName}.md`);
4956
+ if (args.includes("--json")) {
4957
+ printJson({ root, entry: entry3 });
4958
+ return;
4959
+ }
4960
+ process.stdout.write(`${entry3}
4961
+ `);
4962
+ return;
4963
+ }
4337
4964
  const entry2 = join10(root, ...ENTRY_SEGMENTS);
4338
4965
  if (args.includes("--json")) {
4339
4966
  printJson({ root, entry: entry2 });
@@ -4344,7 +4971,7 @@ function doctrineCommand(args) {
4344
4971
  }
4345
4972
 
4346
4973
  // src/commands/eject.ts
4347
- import { existsSync as existsSync10, readFileSync as readFileSync9 } from "node:fs";
4974
+ import { existsSync as existsSync10, readFileSync as readFileSync10 } from "node:fs";
4348
4975
  import { join as join11 } from "node:path";
4349
4976
  function realDeps4() {
4350
4977
  return {
@@ -4367,7 +4994,7 @@ function readManifest(repoRoot) {
4367
4994
  return { kind: "none" };
4368
4995
  let raw;
4369
4996
  try {
4370
- raw = JSON.parse(readFileSync9(p, "utf-8"));
4997
+ raw = JSON.parse(readFileSync10(p, "utf-8"));
4371
4998
  } catch (err) {
4372
4999
  return { kind: "orphan", reason: `vinaya.config.json is not valid JSON: ${err.message}` };
4373
5000
  }
@@ -4457,11 +5084,11 @@ async function ejectCommand(args) {
4457
5084
  }
4458
5085
 
4459
5086
  // src/commands/init.ts
4460
- import { existsSync as existsSync12, readFileSync as readFileSync11, writeFileSync as writeFileSync5 } from "node:fs";
5087
+ import { existsSync as existsSync12, readFileSync as readFileSync12, writeFileSync as writeFileSync5 } from "node:fs";
4461
5088
  import { join as join13 } from "node:path";
4462
5089
 
4463
5090
  // src/lib/registry-write.ts
4464
- import { existsSync as existsSync11, mkdirSync as mkdirSync3, readFileSync as readFileSync10, writeFileSync as writeFileSync4 } from "node:fs";
5091
+ import { existsSync as existsSync11, mkdirSync as mkdirSync3, readFileSync as readFileSync11, writeFileSync as writeFileSync4 } from "node:fs";
4465
5092
  import { dirname as dirname5, join as join12 } from "node:path";
4466
5093
  var PROJECTS_REGISTRY_PATH = ".vinaya/projects.md";
4467
5094
  function rowLine(name, path, specsPath) {
@@ -4532,7 +5159,7 @@ function planRegistryRow(repoRoot, name, path, specsPath) {
4532
5159
  if (!existsSync11(abs2)) {
4533
5160
  return { action: "create-host", rowLine: line, path: PROJECTS_REGISTRY_PATH };
4534
5161
  }
4535
- const existing = readFileSync10(abs2, "utf-8");
5162
+ const existing = readFileSync11(abs2, "utf-8");
4536
5163
  const already = parseRegistry(existing).some((p) => p.name === name);
4537
5164
  return { action: already ? "skip-present" : "append-row", rowLine: line, path: PROJECTS_REGISTRY_PATH };
4538
5165
  }
@@ -4542,7 +5169,7 @@ function applyRegistryRow(repoRoot, plan, name, path, specsPath) {
4542
5169
  mkdirSync3(dirname5(abs2), { recursive: true });
4543
5170
  writeFileSync4(abs2, freshProjectsRegistry(name, path, specsPath), "utf-8");
4544
5171
  } else if (plan.action === "append-row") {
4545
- const existing = readFileSync10(abs2, "utf-8");
5172
+ const existing = readFileSync11(abs2, "utf-8");
4546
5173
  writeFileSync4(abs2, appendRegistryRow(existing, name, path, specsPath), "utf-8");
4547
5174
  }
4548
5175
  }
@@ -4581,14 +5208,14 @@ function readManifest2(repoRoot) {
4581
5208
  if (!existsSync12(p))
4582
5209
  return null;
4583
5210
  try {
4584
- return VinayaConfigSchema.parse(JSON.parse(readFileSync11(p, "utf-8"))).managed ?? null;
5211
+ return VinayaConfigSchema.parse(JSON.parse(readFileSync12(p, "utf-8"))).managed ?? null;
4585
5212
  } catch {
4586
5213
  return null;
4587
5214
  }
4588
5215
  }
4589
5216
  function writeManifest(repoRoot, manifest) {
4590
5217
  const configAbs = join13(repoRoot, CONFIG_PATH);
4591
- const seed = JSON.parse(readFileSync11(configAbs, "utf-8"));
5218
+ const seed = JSON.parse(readFileSync12(configAbs, "utf-8"));
4592
5219
  writeFileSync5(configAbs, `${JSON.stringify({ ...seed, managed: manifest }, null, 2)}
4593
5220
  `, "utf-8");
4594
5221
  }
@@ -4750,11 +5377,12 @@ async function initProductCommand(args) {
4750
5377
  }
4751
5378
 
4752
5379
  // src/commands/issue.ts
4753
- import { execFileSync as execFileSync7 } from "node:child_process";
5380
+ import { execFileSync as execFileSync10 } from "node:child_process";
4754
5381
 
4755
5382
  // src/lib/forge-write.ts
4756
- import { mkdtempSync, rmSync as rmSync3, writeFileSync as writeFileSync6 } from "node:fs";
4757
- import { readFileSync as readFileSync12 } from "node:fs";
5383
+ import { execFileSync as execFileSync9 } from "node:child_process";
5384
+ import { existsSync as existsSync13, mkdtempSync, readdirSync as readdirSync5, rmSync as rmSync3, writeFileSync as writeFileSync6 } from "node:fs";
5385
+ import { readFileSync as readFileSync13 } from "node:fs";
4758
5386
  import { tmpdir } from "node:os";
4759
5387
  import { join as join14 } from "node:path";
4760
5388
  class ForgeArgError extends Error {
@@ -4766,13 +5394,13 @@ function locateBody(args) {
4766
5394
  const p = args[i + 1];
4767
5395
  if (!p)
4768
5396
  throw new ForgeArgError("`--body-file` was given with no path.");
4769
- return { body: readFileSync12(p, "utf8"), source: { kind: "file", argIndex: i + 1, inlineForm: false } };
5397
+ return { body: readFileSync13(p, "utf8"), source: { kind: "file", argIndex: i + 1, inlineForm: false } };
4770
5398
  }
4771
5399
  if (a.startsWith("--body-file=")) {
4772
5400
  const p = a.slice("--body-file=".length);
4773
5401
  if (!p)
4774
5402
  throw new ForgeArgError("`--body-file=` was given with no path.");
4775
- return { body: readFileSync12(p, "utf8"), source: { kind: "file", argIndex: i, inlineForm: true } };
5403
+ return { body: readFileSync13(p, "utf8"), source: { kind: "file", argIndex: i, inlineForm: true } };
4776
5404
  }
4777
5405
  if (a === "--body" || a === "-b") {
4778
5406
  const v = args[i + 1];
@@ -4956,6 +5584,86 @@ function validateForgeWrite(input) {
4956
5584
  }
4957
5585
  return errors;
4958
5586
  }
5587
+ function git2(args) {
5588
+ try {
5589
+ return execFileSync9("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
5590
+ } catch {
5591
+ return "";
5592
+ }
5593
+ }
5594
+ function repoRoot() {
5595
+ return git2(["rev-parse", "--show-toplevel"]);
5596
+ }
5597
+ function readWorkspaceGlobs(root) {
5598
+ const fromPackageJson = () => {
5599
+ try {
5600
+ const pkg = JSON.parse(readFileSync13(join14(root, "package.json"), "utf8"));
5601
+ return Array.isArray(pkg.workspaces) ? pkg.workspaces.filter((w) => typeof w === "string") : [];
5602
+ } catch {
5603
+ return [];
5604
+ }
5605
+ };
5606
+ const fromPnpmWorkspaceYaml = () => {
5607
+ try {
5608
+ return parsePnpmWorkspaceYaml(readFileSync13(join14(root, "pnpm-workspace.yaml"), "utf8"));
5609
+ } catch {
5610
+ return [];
5611
+ }
5612
+ };
5613
+ return [...fromPackageJson(), ...fromPnpmWorkspaceYaml()];
5614
+ }
5615
+ function listWorkspaceChildDirs(dir, root) {
5616
+ try {
5617
+ return readdirSync5(join14(root, dir), { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
5618
+ } catch {
5619
+ return [];
5620
+ }
5621
+ }
5622
+ function readConfigExtraDomains(root) {
5623
+ try {
5624
+ const parsed = VinayaConfigSchema.safeParse(JSON.parse(readFileSync13(join14(root, "vinaya.config.json"), "utf8")));
5625
+ return parsed.success ? parsed.data.blastRadius?.extraDomains ?? [] : [];
5626
+ } catch {
5627
+ return [];
5628
+ }
5629
+ }
5630
+ function readSharedPackages(root = repoRoot()) {
5631
+ if (!root)
5632
+ return [];
5633
+ const derived = deriveWorkspacePackageDomains(readWorkspaceGlobs(root), (dir) => listWorkspaceChildDirs(dir, root));
5634
+ const defaults = deriveBuiltinCrossCuttingDefaults((p) => existsSync13(join14(root, p)));
5635
+ const configExtra = readConfigExtraDomains(root);
5636
+ return [...new Set([...derived, ...defaults, ...configExtra])];
5637
+ }
5638
+ function readProjectPaths(root = repoRoot()) {
5639
+ if (!root)
5640
+ return [];
5641
+ try {
5642
+ return parseRegistry(readFileSync13(join14(root, ".vinaya/projects.md"), "utf8"));
5643
+ } catch {
5644
+ return [];
5645
+ }
5646
+ }
5647
+ var CHECK_ISSUE_CONTENT = "issue-content";
5648
+ var ISSUE_CONTENT_RECOVERY = {
5649
+ blastRadius: "Add a second registered `Project(s)` this task also touches, or a `blast-radius-ack: <why one lens is enough>` line, then re-run `{cmd}`.",
5650
+ noBriefContent: "Move the brief-shaped section named above out of the Issue body and into the brief — the Issue carries the Planner's durable rationale, not the brief's just-in-time surface — then re-run `{cmd}`.",
5651
+ rationaleNamesDocs: 'Name a concrete doc/skill path (e.g. `aeg-root/…`, `.claude/skills/…/SKILL.md`) in "Docs to keep coherent" or "Traps", or write the `no-doc-surface` sentinel if the surface genuinely has none, then re-run `{cmd}`.'
5652
+ };
5653
+ function validateIssueContent(input) {
5654
+ const findings = [
5655
+ [checkBlastRadiusScope(input.body, input.labels, input.sharedPackages, input.projectPaths).errors, "blastRadius"],
5656
+ [checkNoBriefContent(input.body).errors, "noBriefContent"],
5657
+ [checkRationaleNamesDocs(input.body).errors, "rationaleNamesDocs"]
5658
+ ];
5659
+ const errors = [];
5660
+ for (const [messages, kind] of findings) {
5661
+ const recovery = ISSUE_CONTENT_RECOVERY[kind].replace("{cmd}", input.retryCommand);
5662
+ for (const message of messages)
5663
+ errors.push(makeCheckError(CHECK_ISSUE_CONTENT, message, recovery));
5664
+ }
5665
+ return errors;
5666
+ }
4959
5667
 
4960
5668
  // src/commands/issue.ts
4961
5669
  var RETRY_CREATE = "vinaya issue create --validate-only …";
@@ -4973,7 +5681,7 @@ function locateBodyOrRefuse(ghArgs, retryCommand) {
4973
5681
  function fetchForgeLabels(issueRef) {
4974
5682
  let out;
4975
5683
  try {
4976
- out = execFileSync7("gh", ["issue", "view", issueRef, "--json", "labels"], {
5684
+ out = execFileSync10("gh", ["issue", "view", issueRef, "--json", "labels"], {
4977
5685
  encoding: "utf8",
4978
5686
  stdio: ["ignore", "pipe", "pipe"]
4979
5687
  });
@@ -5001,7 +5709,7 @@ function reportPass(json, command) {
5001
5709
  function runGhWrite(ghCmd, ghArgs, bodyResult, json) {
5002
5710
  const { finalArgs, cleanup: cleanup2 } = resolveShippableArgs(ghArgs, bodyResult);
5003
5711
  try {
5004
- const out = execFileSync7("gh", [...ghCmd, ...finalArgs], { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] });
5712
+ const out = execFileSync10("gh", [...ghCmd, ...finalArgs], { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] });
5005
5713
  const url = out.trim();
5006
5714
  if (json)
5007
5715
  printJson({ validated: true, written: true, url });
@@ -5012,22 +5720,31 @@ function runGhWrite(ghCmd, ghArgs, bodyResult, json) {
5012
5720
  cleanup2();
5013
5721
  }
5014
5722
  }
5015
- function validateTaskIssue(body, title, retryCommand) {
5723
+ function validateTaskIssue(body, title, labels, retryCommand) {
5016
5724
  if (body === null) {
5017
5725
  refuse([
5018
5726
  makeCheckError("forge-args", "A task Issue (a `vinaya/tranche:*` label) requires a `--body-file <path>` so the rationale gate can validate it.", `Add \`--body-file <path>\`, then re-run \`${retryCommand}\`.`)
5019
5727
  ]);
5020
5728
  }
5021
5729
  const sections = resolveSections("issue", retryCommand);
5022
- const errors = validateForgeWrite({
5730
+ const schemaErrors = validateForgeWrite({
5023
5731
  body,
5024
5732
  title,
5025
5733
  sections,
5026
5734
  changedFiles: [],
5027
5735
  retryCommand
5028
5736
  });
5029
- if (errors.length > 0)
5030
- refuse(errors);
5737
+ if (schemaErrors.length > 0)
5738
+ refuse(schemaErrors);
5739
+ const contentErrors = validateIssueContent({
5740
+ body,
5741
+ labels,
5742
+ sharedPackages: readSharedPackages(),
5743
+ projectPaths: readProjectPaths(),
5744
+ retryCommand
5745
+ });
5746
+ if (contentErrors.length > 0)
5747
+ refuse(contentErrors);
5031
5748
  }
5032
5749
  function issueCreateCommand(args) {
5033
5750
  const json = args.includes("--json");
@@ -5038,7 +5755,7 @@ function issueCreateCommand(args) {
5038
5755
  const title = extractTitle(ghArgs);
5039
5756
  const labels = extractLabels(ghArgs);
5040
5757
  if (isTaskIssueLabelSet(labels)) {
5041
- validateTaskIssue(body, title, RETRY_CREATE);
5758
+ validateTaskIssue(body, title, labels, RETRY_CREATE);
5042
5759
  }
5043
5760
  if (validateOnly) {
5044
5761
  reportPass(json, "issue create");
@@ -5062,7 +5779,7 @@ function issueEditCommand(args) {
5062
5779
  const title = extractTitle(ghArgs);
5063
5780
  const labels = [...new Set([...fetchForgeLabels(issueRef), ...extractLabels(ghArgs)])];
5064
5781
  if (isTaskIssueLabelSet(labels)) {
5065
- validateTaskIssue(body, title, RETRY_EDIT);
5782
+ validateTaskIssue(body, title, labels, RETRY_EDIT);
5066
5783
  }
5067
5784
  if (validateOnly) {
5068
5785
  reportPass(json, "issue edit");
@@ -5072,7 +5789,7 @@ function issueEditCommand(args) {
5072
5789
  }
5073
5790
 
5074
5791
  // src/commands/new-check.ts
5075
- import { chmodSync as chmodSync2, existsSync as existsSync13, mkdirSync as mkdirSync4, readFileSync as readFileSync13, writeFileSync as writeFileSync7 } from "node:fs";
5792
+ import { chmodSync as chmodSync2, existsSync as existsSync14, mkdirSync as mkdirSync4, readFileSync as readFileSync14, writeFileSync as writeFileSync7 } from "node:fs";
5076
5793
  import { join as join15 } from "node:path";
5077
5794
  var TEMPLATE_PATH = join15(packageRoot(import.meta.url), "templates", "custom-check.template.ts");
5078
5795
  var CHECKS_DIR = join15("scripts", "vinaya-checks");
@@ -5093,15 +5810,15 @@ function newCheckCommand(args) {
5093
5810
  process.exit(2);
5094
5811
  }
5095
5812
  const scriptsDir = join15(process.cwd(), CHECKS_DIR);
5096
- if (!existsSync13(scriptsDir))
5813
+ if (!existsSync14(scriptsDir))
5097
5814
  mkdirSync4(scriptsDir, { recursive: true });
5098
5815
  const fileStem = name.slice(name.indexOf("/") + 1);
5099
5816
  const targetPath = join15(scriptsDir, `${fileStem}.ts`);
5100
- if (existsSync13(targetPath)) {
5817
+ if (existsSync14(targetPath)) {
5101
5818
  console.error(`Error: ${targetPath} already exists.`);
5102
5819
  process.exit(1);
5103
5820
  }
5104
- const template = readFileSync13(TEMPLATE_PATH, "utf-8");
5821
+ const template = readFileSync14(TEMPLATE_PATH, "utf-8");
5105
5822
  const contents = template.split("{{CHECK_NAME}}").join(name);
5106
5823
  writeFileSync7(targetPath, contents, "utf-8");
5107
5824
  chmodSync2(targetPath, 493);
@@ -5115,21 +5832,21 @@ ${registration}
5115
5832
  }
5116
5833
 
5117
5834
  // src/commands/pr.ts
5118
- import { execFileSync as execFileSync8 } from "node:child_process";
5835
+ import { execFileSync as execFileSync11 } from "node:child_process";
5119
5836
  var RETRY_CREATE2 = "vinaya pr create --validate-only …";
5120
5837
  var RETRY_EDIT2 = "vinaya pr edit <n> --validate-only …";
5121
- function git2(args) {
5838
+ function git3(args) {
5122
5839
  try {
5123
- return execFileSync8("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
5840
+ return execFileSync11("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
5124
5841
  } catch {
5125
5842
  return "";
5126
5843
  }
5127
5844
  }
5128
5845
  function localChangedFiles() {
5129
5846
  const base = process.env.BASE_SHA || "origin/main";
5130
- let out = git2(["diff", "--name-only", `${base}...HEAD`]);
5847
+ let out = git3(["diff", "--name-only", `${base}...HEAD`]);
5131
5848
  if (!out)
5132
- out = git2(["diff", "--name-only", "main...HEAD"]);
5849
+ out = git3(["diff", "--name-only", "main...HEAD"]);
5133
5850
  return out.split(`
5134
5851
  `).map((s) => s.trim()).filter(Boolean);
5135
5852
  }
@@ -5154,7 +5871,7 @@ function reportPass2(json, command) {
5154
5871
  function runGhWrite2(ghCmd, ghArgs, bodyResult, json) {
5155
5872
  const { finalArgs, cleanup: cleanup2 } = resolveShippableArgs(ghArgs, bodyResult);
5156
5873
  try {
5157
- const out = execFileSync8("gh", [...ghCmd, ...finalArgs], { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] });
5874
+ const out = execFileSync11("gh", [...ghCmd, ...finalArgs], { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] });
5158
5875
  const url = out.trim();
5159
5876
  if (json)
5160
5877
  printJson({ validated: true, written: true, url });
@@ -5168,7 +5885,7 @@ function runGhWrite2(ghCmd, ghArgs, bodyResult, json) {
5168
5885
  function fetchPrForgeContext(prRef) {
5169
5886
  let viewOut;
5170
5887
  try {
5171
- viewOut = execFileSync8("gh", ["pr", "view", prRef, "--json", "headRefName,files"], {
5888
+ viewOut = execFileSync11("gh", ["pr", "view", prRef, "--json", "headRefName,files"], {
5172
5889
  encoding: "utf8",
5173
5890
  stdio: ["ignore", "pipe", "pipe"]
5174
5891
  });
@@ -5207,8 +5924,8 @@ function prCreateCommand(args) {
5207
5924
  }
5208
5925
  const sections = resolveSections("pr", RETRY_CREATE2);
5209
5926
  const changedFiles2 = localChangedFiles();
5210
- const headCommit = git2(["rev-parse", "--verify", "--quiet", "HEAD"]);
5211
- const branch = headCommit === "" ? "" : git2(["symbolic-ref", "--quiet", "--short", "HEAD"]);
5927
+ const headCommit = git3(["rev-parse", "--verify", "--quiet", "HEAD"]);
5928
+ const branch = headCommit === "" ? "" : git3(["symbolic-ref", "--quiet", "--short", "HEAD"]);
5212
5929
  const errors = validateForgeWrite({
5213
5930
  body,
5214
5931
  title,
@@ -5270,14 +5987,14 @@ function prEditCommand(args) {
5270
5987
  }
5271
5988
 
5272
5989
  // src/commands/pr-report.ts
5273
- import { execFileSync as execFileSync9, spawnSync as spawnSync2 } from "node:child_process";
5274
- import { existsSync as existsSync14, readFileSync as readFileSync14, writeFileSync as writeFileSync8 } from "node:fs";
5990
+ import { execFileSync as execFileSync12, spawnSync as spawnSync2 } from "node:child_process";
5991
+ import { existsSync as existsSync15, readFileSync as readFileSync15, writeFileSync as writeFileSync8 } from "node:fs";
5275
5992
  import { join as join16 } from "node:path";
5276
5993
  var EVIDENCE_START = "<!-- AEG:EVIDENCE:START -->";
5277
5994
  var EVIDENCE_END = "<!-- AEG:EVIDENCE:END -->";
5278
- function git3(args) {
5995
+ function git4(args) {
5279
5996
  try {
5280
- return execFileSync9("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
5997
+ return execFileSync12("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
5281
5998
  } catch {
5282
5999
  return "";
5283
6000
  }
@@ -5295,7 +6012,7 @@ class GitCommandError extends Error {
5295
6012
  }
5296
6013
  function gitStrict(args) {
5297
6014
  try {
5298
- return execFileSync9("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
6015
+ return execFileSync12("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
5299
6016
  } catch (err) {
5300
6017
  const stderr = err.stderr;
5301
6018
  throw new GitCommandError(args, String(stderr ?? err.message).trim() || "non-zero exit");
@@ -5316,7 +6033,7 @@ function resolveMergeBase(head) {
5316
6033
  const primary = process.env.BASE_SHA || "origin/main";
5317
6034
  const tried = primary === "main" ? [primary] : [primary, "main"];
5318
6035
  for (const ref of tried) {
5319
- const base = git3(["merge-base", ref, head]);
6036
+ const base = git4(["merge-base", ref, head]);
5320
6037
  if (base)
5321
6038
  return base;
5322
6039
  }
@@ -5331,7 +6048,7 @@ function computeGroupA() {
5331
6048
  function resolveSelfEntry() {
5332
6049
  const root = packageRoot(import.meta.url);
5333
6050
  const dist = join16(root, "dist", "index.js");
5334
- return existsSync14(dist) ? dist : join16(root, "src", "index.ts");
6051
+ return existsSync15(dist) ? dist : join16(root, "src", "index.ts");
5335
6052
  }
5336
6053
  var FAILING_STATUSES = new Set(["fail", "error", "timeout"]);
5337
6054
  function anyGateFailed(outcomes) {
@@ -5436,7 +6153,7 @@ async function prReportCommand(args) {
5436
6153
  throw err;
5437
6154
  }
5438
6155
  if (writePath) {
5439
- const existing = existsSync14(writePath) ? readFileSync14(writePath, "utf8") : "";
6156
+ const existing = existsSync15(writePath) ? readFileSync15(writePath, "utf8") : "";
5440
6157
  const updated = replaceEvidenceBlock(existing, result.blockInner);
5441
6158
  writeFileSync8(writePath, updated);
5442
6159
  process.stdout.write(`Wrote AEG:EVIDENCE block to ${writePath}
@@ -5449,12 +6166,12 @@ async function prReportCommand(args) {
5449
6166
  }
5450
6167
 
5451
6168
  // src/commands/quickstart.ts
5452
- import { execFileSync as execFileSync10 } from "node:child_process";
5453
- import { existsSync as existsSync16, readFileSync as readFileSync16 } from "node:fs";
6169
+ import { execFileSync as execFileSync13 } from "node:child_process";
6170
+ import { existsSync as existsSync17, readFileSync as readFileSync17 } from "node:fs";
5454
6171
  import { join as join18 } from "node:path";
5455
6172
 
5456
6173
  // src/lib/doc-owners-write.ts
5457
- import { existsSync as existsSync15, mkdirSync as mkdirSync5, readFileSync as readFileSync15, writeFileSync as writeFileSync9 } from "node:fs";
6174
+ import { existsSync as existsSync16, mkdirSync as mkdirSync5, readFileSync as readFileSync16, writeFileSync as writeFileSync9 } from "node:fs";
5458
6175
  import { dirname as dirname6, join as join17 } from "node:path";
5459
6176
  function bindingLine(glob, pointer) {
5460
6177
  return `${glob} ${pointer}`;
@@ -5470,23 +6187,23 @@ function appendDocOwnersBinding(content, glob, pointer) {
5470
6187
  return `${content}${sep4}${bindingLine(glob, pointer)}
5471
6188
  `;
5472
6189
  }
5473
- function planDocOwnersBinding(repoRoot, glob, pointer) {
5474
- const abs2 = join17(repoRoot, DOC_OWNERS_PATH);
6190
+ function planDocOwnersBinding(repoRoot2, glob, pointer) {
6191
+ const abs2 = join17(repoRoot2, DOC_OWNERS_PATH);
5475
6192
  const line = bindingLine(glob, pointer);
5476
- if (!existsSync15(abs2)) {
6193
+ if (!existsSync16(abs2)) {
5477
6194
  return { action: "create-host", line, path: DOC_OWNERS_PATH };
5478
6195
  }
5479
- const existing = readFileSync15(abs2, "utf-8");
6196
+ const existing = readFileSync16(abs2, "utf-8");
5480
6197
  const already = parseDocOwners(existing).bindings.some((b) => b.glob === glob);
5481
6198
  return { action: already ? "skip-present" : "append-row", line, path: DOC_OWNERS_PATH };
5482
6199
  }
5483
- function applyDocOwnersBinding(repoRoot, plan, glob, pointer) {
5484
- const abs2 = join17(repoRoot, DOC_OWNERS_PATH);
6200
+ function applyDocOwnersBinding(repoRoot2, plan, glob, pointer) {
6201
+ const abs2 = join17(repoRoot2, DOC_OWNERS_PATH);
5485
6202
  if (plan.action === "create-host") {
5486
6203
  mkdirSync5(dirname6(abs2), { recursive: true });
5487
6204
  writeFileSync9(abs2, freshDocOwners(glob, pointer), "utf-8");
5488
6205
  } else if (plan.action === "append-row") {
5489
- const existing = readFileSync15(abs2, "utf-8");
6206
+ const existing = readFileSync16(abs2, "utf-8");
5490
6207
  writeFileSync9(abs2, appendDocOwnersBinding(existing, glob, pointer), "utf-8");
5491
6208
  }
5492
6209
  }
@@ -5543,13 +6260,13 @@ var GLOB_INJECTION_RE = /[\s\r\n]/;
5543
6260
  var POINTER_INJECTION_RE = /[\r\n]/;
5544
6261
  var NAME_INJECTION_RE = /[\s|\r\n]/;
5545
6262
  var PATH_INJECTION_RE2 = /[|\r\n]/;
5546
- function pointerIsMissingLocalFile(repoRoot, pointer) {
6263
+ function pointerIsMissingLocalFile(repoRoot2, pointer) {
5547
6264
  if (/^https?:\/\//i.test(pointer))
5548
6265
  return false;
5549
6266
  const path = pointer.split("#")[0] ?? pointer;
5550
- return !existsSync16(join18(repoRoot, path));
6267
+ return !existsSync17(join18(repoRoot2, path));
5551
6268
  }
5552
- async function bindDocOwnerLoop(deps, repoRoot) {
6269
+ async function bindDocOwnerLoop(deps, repoRoot2) {
5553
6270
  let any = false;
5554
6271
  for (;; ) {
5555
6272
  if (any && !await deps.confirm("Bind another doc→code pair?", false))
@@ -5564,11 +6281,11 @@ async function bindDocOwnerLoop(deps, repoRoot) {
5564
6281
  }
5565
6282
  if (GLOB_INJECTION_RE.test(glob) || POINTER_INJECTION_RE.test(pointer)) {
5566
6283
  console.error(red("Error: glob must not contain whitespace, and the pointer must not contain a newline."));
5567
- } else if (pointerIsMissingLocalFile(repoRoot, pointer)) {
6284
+ } else if (pointerIsMissingLocalFile(repoRoot2, pointer)) {
5568
6285
  console.error(red(`✗ "${pointer}" does not exist in this repo (and isn't a URL).`));
5569
6286
  } else {
5570
- const plan = planDocOwnersBinding(repoRoot, glob, pointer);
5571
- applyDocOwnersBinding(repoRoot, plan, glob, pointer);
6287
+ const plan = planDocOwnersBinding(repoRoot2, glob, pointer);
6288
+ applyDocOwnersBinding(repoRoot2, plan, glob, pointer);
5572
6289
  process.stdout.write(`${green(renderDocOwnersBindingDiffLine(plan))}
5573
6290
  `);
5574
6291
  any = true;
@@ -5614,7 +6331,7 @@ async function registerProjectLoop(deps) {
5614
6331
  }
5615
6332
  }
5616
6333
  function readPackageVersion() {
5617
- const pkg = JSON.parse(readFileSync16(join18(packageRoot(import.meta.url), "package.json"), "utf-8"));
6334
+ const pkg = JSON.parse(readFileSync17(join18(packageRoot(import.meta.url), "package.json"), "utf-8"));
5618
6335
  return pkg.version;
5619
6336
  }
5620
6337
  function realDeps6() {
@@ -5644,8 +6361,8 @@ function realDeps6() {
5644
6361
  closeStdin
5645
6362
  };
5646
6363
  }
5647
- function execGit(repoRoot, args) {
5648
- return execFileSync10("git", args, { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
6364
+ function execGit(repoRoot2, args) {
6365
+ return execFileSync13("git", args, { cwd: repoRoot2, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
5649
6366
  }
5650
6367
  function errorDetail(err) {
5651
6368
  const stderr = err.stderr;
@@ -5653,31 +6370,31 @@ function errorDetail(err) {
5653
6370
  return stderr.toString().trim();
5654
6371
  return err instanceof Error ? err.message : String(err);
5655
6372
  }
5656
- function gitStatusShort(repoRoot) {
5657
- return execGit(repoRoot, ["status", "--short"]);
6373
+ function gitStatusShort(repoRoot2) {
6374
+ return execGit(repoRoot2, ["status", "--short"]);
5658
6375
  }
5659
- function commitInstall(repoRoot) {
5660
- execGit(repoRoot, ["add", "-A"]);
5661
- const staged = execGit(repoRoot, ["diff", "--cached", "--stat"]);
6376
+ function commitInstall(repoRoot2) {
6377
+ execGit(repoRoot2, ["add", "-A"]);
6378
+ const staged = execGit(repoRoot2, ["diff", "--cached", "--stat"]);
5662
6379
  if (!staged)
5663
6380
  return { committed: false, message: "Nothing to commit — working tree already clean." };
5664
6381
  try {
5665
- execGit(repoRoot, ["commit", "-m", "Chore: install Vinaya"]);
6382
+ execGit(repoRoot2, ["commit", "-m", "Chore: install Vinaya"]);
5666
6383
  return { committed: true, message: "✓ Committed `Chore: install Vinaya`." };
5667
6384
  } catch (err) {
5668
6385
  return { committed: false, message: `git commit failed: ${errorDetail(err)}` };
5669
6386
  }
5670
6387
  }
5671
- function pushInstall(repoRoot) {
6388
+ function pushInstall(repoRoot2) {
5672
6389
  try {
5673
- const branch = execGit(repoRoot, ["symbolic-ref", "--short", "HEAD"]);
6390
+ const branch = execGit(repoRoot2, ["symbolic-ref", "--short", "HEAD"]);
5674
6391
  let hasUpstream = true;
5675
6392
  try {
5676
- execGit(repoRoot, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
6393
+ execGit(repoRoot2, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
5677
6394
  } catch {
5678
6395
  hasUpstream = false;
5679
6396
  }
5680
- execGit(repoRoot, hasUpstream ? ["push"] : ["push", "-u", "origin", branch]);
6397
+ execGit(repoRoot2, hasUpstream ? ["push"] : ["push", "-u", "origin", branch]);
5681
6398
  return { pushed: true, message: "✓ Pushed." };
5682
6399
  } catch (err) {
5683
6400
  return { pushed: false, message: `git push failed: ${errorDetail(err)}` };
@@ -5761,11 +6478,331 @@ async function quickstartCommand(args) {
5761
6478
  process.exit(await runQuickstart(args, realDeps6()));
5762
6479
  }
5763
6480
 
6481
+ // src/commands/review-post.ts
6482
+ import { execFileSync as execFileSync14 } from "node:child_process";
6483
+ import { readFileSync as readFileSync18, rmSync as rmSync4, writeFileSync as writeFileSync10 } from "node:fs";
6484
+ import { tmpdir as tmpdir2 } from "node:os";
6485
+ import { join as join19 } from "node:path";
6486
+ class FindingsParseError extends Error {
6487
+ }
6488
+ var CODE_REVIEW_SEVERITIES = ["BLOCKER", "MAJOR", "MINOR"];
6489
+ var SECURITY_SEVERITIES = ["CRITICAL", "HIGH", "MEDIUM", "LOW"];
6490
+ function parseFindingsFile(content, allowedSeverities) {
6491
+ const lines = content.split(`
6492
+ `).map((l) => l.trim()).filter((l) => l.length > 0);
6493
+ return lines.map((line, idx) => {
6494
+ const parts = line.split("|");
6495
+ if (parts.length !== 3) {
6496
+ throw new FindingsParseError(`findings file line ${idx + 1}: expected exactly 3 \`|\`-delimited fields (SEVERITY|file:line|description), found ${parts.length}: ${line}`);
6497
+ }
6498
+ const severity = parts[0].trim().toUpperCase();
6499
+ const location = parts[1].trim();
6500
+ const description = parts[2].trim();
6501
+ if (!allowedSeverities.includes(severity)) {
6502
+ throw new FindingsParseError(`findings file line ${idx + 1}: severity "${parts[0]}" is not one of ${allowedSeverities.join("|")}: ${line}`);
6503
+ }
6504
+ if (!location || !description) {
6505
+ throw new FindingsParseError(`findings file line ${idx + 1}: file:line and description must both be non-empty: ${line}`);
6506
+ }
6507
+ return { severity, location, description };
6508
+ });
6509
+ }
6510
+ function sortBySeverity(findings, order) {
6511
+ return findings.map((f, i) => ({ f, i })).sort((a, b) => order.indexOf(a.f.severity) - order.indexOf(b.f.severity) || a.i - b.i).map(({ f }) => f);
6512
+ }
6513
+ function renderFindingsSection(findings) {
6514
+ if (findings.length === 0)
6515
+ return "None.";
6516
+ return findings.map((f, i) => `${i + 1}. [${f.severity}] ${f.location} — ${f.description}`).join(`
6517
+ `);
6518
+ }
6519
+ function renderTokensLine(role, roleLabel, input) {
6520
+ return `Tokens: ${input.taskId}: ${role} — ${roleLabel} — ${input.model} — ${input.tokensIn}/${input.tokensOut}/${input.cost}`;
6521
+ }
6522
+ var CODE_REVIEW_VERDICT_TEXT = {
6523
+ APPROVE: "APPROVE",
6524
+ REQUEST_CHANGES: "REQUEST CHANGES"
6525
+ };
6526
+ function renderCodeReviewComment(input) {
6527
+ const sorted = sortBySeverity(input.findings, CODE_REVIEW_SEVERITIES);
6528
+ return [
6529
+ `VERDICT: ${CODE_REVIEW_VERDICT_TEXT[input.verdict]}`,
6530
+ "",
6531
+ `Judged head: ${input.headSha}`,
6532
+ "",
6533
+ `BRIEF CONFORMANCE: ${input.briefConformance}`,
6534
+ `SPEC CONFORMANCE: ${input.specConformance}`,
6535
+ "",
6536
+ "FINDINGS (ordered by severity):",
6537
+ renderFindingsSection(sorted),
6538
+ "",
6539
+ `SCOPE: ${input.scope}`,
6540
+ `TESTS: ${input.tests}`,
6541
+ `DOCS: ${input.docs}`,
6542
+ "",
6543
+ renderTokensLine("review", "Reviewer", input)
6544
+ ].join(`
6545
+ `);
6546
+ }
6547
+ function isNoneFoundClaim(value) {
6548
+ return value.trim().toLowerCase().replace(/[-_]+/g, " ").replace(/\s+/g, " ") === "none found";
6549
+ }
6550
+ function renderSecurityComment(input) {
6551
+ const sorted = sortBySeverity(input.findings, SECURITY_SEVERITIES);
6552
+ const lines = [
6553
+ `VERDICT: ${input.verdict}`,
6554
+ "",
6555
+ `Judged head: ${input.headSha}`,
6556
+ "",
6557
+ "FINDINGS (ordered by severity):",
6558
+ renderFindingsSection(sorted),
6559
+ "",
6560
+ `CONFIG SCAN: ${input.configScan}`,
6561
+ ""
6562
+ ];
6563
+ if (input.secretsEvidence !== null) {
6564
+ lines.push("```", input.secretsEvidence, "```", "");
6565
+ }
6566
+ lines.push(`SECRETS: ${input.secrets}`, "", renderTokensLine("security", "Security", input));
6567
+ return lines.join(`
6568
+ `);
6569
+ }
6570
+ function isBoundToHead(extraction, headSha) {
6571
+ if (!extraction.headSha)
6572
+ return false;
6573
+ return headSha.toLowerCase().startsWith(extraction.headSha.toLowerCase());
6574
+ }
6575
+ function checkExtraction(extraction, expectedValue, headSha) {
6576
+ if (extraction.danglingNote) {
6577
+ return {
6578
+ ok: false,
6579
+ reason: `no clean VERDICT was found on re-fetch (${extraction.danglingNote}) — the posted comment does not match the gate's line-anchored \`VERDICT:\` pattern.`
6580
+ };
6581
+ }
6582
+ if (extraction.value !== expectedValue) {
6583
+ return {
6584
+ ok: false,
6585
+ reason: `re-extraction found VERDICT "${extraction.value}", expected "${expectedValue}" — the posted comment's VERDICT line does not match what this command rendered.`
6586
+ };
6587
+ }
6588
+ if (!extraction.headSha) {
6589
+ return { ok: false, reason: "the winning VERDICT comment carries no `Judged head:` line on re-fetch." };
6590
+ }
6591
+ if (!isBoundToHead(extraction, headSha)) {
6592
+ return {
6593
+ ok: false,
6594
+ reason: `re-extraction found \`Judged head: ${extraction.headSha}\`, which does not cover the resolved head ${headSha}.`
6595
+ };
6596
+ }
6597
+ return { ok: true, reason: "clean" };
6598
+ }
6599
+ function principalBodies(comments, principalAllowlist) {
6600
+ return comments.filter((c) => isPrincipal(c.author, principalAllowlist)).map((c) => c.body);
6601
+ }
6602
+ function verifyPostedCodeReview(comments, verdict, headSha, principalAllowlist) {
6603
+ return checkExtraction(extractCodeReviewVerdict(principalBodies(comments, principalAllowlist)), CODE_REVIEW_VERDICT_TEXT[verdict], headSha);
6604
+ }
6605
+ function verifyPostedSecurity(comments, verdict, headSha, principalAllowlist) {
6606
+ return checkExtraction(extractSecurityReviewVerdict(principalBodies(comments, principalAllowlist)), verdict, headSha);
6607
+ }
6608
+ function parseFlags(args) {
6609
+ const map = new Map;
6610
+ for (let i = 0;i < args.length; i++) {
6611
+ const a = args[i];
6612
+ if (a.startsWith("--")) {
6613
+ const value = args[i + 1];
6614
+ if (value === undefined || value.startsWith("--")) {
6615
+ map.set(a, "");
6616
+ continue;
6617
+ }
6618
+ map.set(a, value);
6619
+ i++;
6620
+ }
6621
+ }
6622
+ return map;
6623
+ }
6624
+ function refuseCmd(message, recovery) {
6625
+ refuse([makeCheckError("review-post", message, recovery)]);
6626
+ }
6627
+ function requireFlag(flags2, name) {
6628
+ const v = flags2.get(name);
6629
+ if (v === undefined || v === "") {
6630
+ refuseCmd(`Missing required \`${name}\` flag.`, `Pass \`${name} <value>\`, then re-run \`vinaya review post ...\`.`);
6631
+ }
6632
+ return v;
6633
+ }
6634
+ function requireTokenField(flags2, name) {
6635
+ const v = requireFlag(flags2, name);
6636
+ if (v !== "-" && !/^\d+$/.test(v)) {
6637
+ refuseCmd(`\`${name} ${v}\` is neither a non-negative integer nor \`-\` (unknown).`, `Pass a whole number or \`-\` for ${name}.`);
6638
+ }
6639
+ return v;
6640
+ }
6641
+ function readFindingsFile(path, allowedSeverities) {
6642
+ if (!path)
6643
+ return [];
6644
+ let content;
6645
+ try {
6646
+ content = readFileSync18(path, "utf8");
6647
+ } catch {
6648
+ refuseCmd(`Could not read findings file at ${path}.`, "Check the path and re-run.");
6649
+ }
6650
+ try {
6651
+ return parseFindingsFile(content, allowedSeverities);
6652
+ } catch (err) {
6653
+ if (err instanceof FindingsParseError) {
6654
+ refuseCmd(err.message, "Fix the malformed line in the findings file, then re-run.");
6655
+ }
6656
+ throw err;
6657
+ }
6658
+ }
6659
+ function normalizeCodeReviewVerdict(raw) {
6660
+ const v = raw.trim().toUpperCase().replace(/[-\s]+/g, "_");
6661
+ if (v === "APPROVE")
6662
+ return "APPROVE";
6663
+ if (v === "REQUEST_CHANGES")
6664
+ return "REQUEST_CHANGES";
6665
+ refuseCmd(`\`--verdict ${raw}\` is not APPROVE or REQUEST_CHANGES.`, "Pass `--verdict APPROVE` or `--verdict REQUEST_CHANGES`.");
6666
+ }
6667
+ function normalizeSecurityVerdict(raw) {
6668
+ const v = raw.trim().toUpperCase();
6669
+ if (v === "PASS")
6670
+ return "PASS";
6671
+ if (v === "FAIL")
6672
+ return "FAIL";
6673
+ refuseCmd(`\`--verdict ${raw}\` is not PASS or FAIL.`, "Pass `--verdict PASS` or `--verdict FAIL`.");
6674
+ }
6675
+ function gh(args) {
6676
+ try {
6677
+ return execFileSync14("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
6678
+ } catch (err) {
6679
+ const stderr = err.stderr;
6680
+ throw new Error(String(stderr ?? err.message).trim() || "gh command failed");
6681
+ }
6682
+ }
6683
+ function resolveHeadSha(pr) {
6684
+ let out;
6685
+ try {
6686
+ out = gh(["pr", "view", pr, "--json", "headRefOid", "-q", ".headRefOid"]);
6687
+ } catch (err) {
6688
+ refuseCmd(`Could not resolve PR ${pr}'s head via \`gh pr view --json headRefOid\`: ${err instanceof Error ? err.message : String(err)}`, "Confirm `gh auth status` passes and the PR number is correct, then re-run.");
6689
+ }
6690
+ if (!out) {
6691
+ refuseCmd(`\`gh pr view ${pr} --json headRefOid\` returned no head sha.`, "Confirm PR exists, then re-run.");
6692
+ }
6693
+ return out;
6694
+ }
6695
+ function postComment(pr, body) {
6696
+ const tmp = join19(tmpdir2(), `vinaya-review-post-${process.pid}-${Date.now()}.md`);
6697
+ writeFileSync10(tmp, body);
6698
+ try {
6699
+ return gh(["pr", "comment", pr, "--body-file", tmp]).trim();
6700
+ } catch (err) {
6701
+ refuseCmd(`Rendered comment failed to post via \`gh pr comment\`: ${err instanceof Error ? err.message : String(err)}`, "Check `gh auth status`/network, then re-run — nothing was posted.");
6702
+ } finally {
6703
+ rmSync4(tmp, { force: true });
6704
+ }
6705
+ }
6706
+ function fetchComments(pr) {
6707
+ const out = gh(["pr", "view", pr, "--json", "comments"]);
6708
+ const parsed = JSON.parse(out);
6709
+ return parsed.comments.map((c) => ({ body: c.body, author: c.author?.login ?? null }));
6710
+ }
6711
+ async function reviewPostCommand(args) {
6712
+ const json = args.includes("--json");
6713
+ const flags2 = parseFlags(args.filter((a) => a !== "--json"));
6714
+ const role = flags2.get("--role");
6715
+ if (role !== "code-reviewer" && role !== "security") {
6716
+ refuseCmd(`\`--role ${role ?? "(missing)"}\` is not \`code-reviewer\` or \`security\`.`, "Pass `--role code-reviewer` or `--role security`.");
6717
+ }
6718
+ const pr = requireFlag(flags2, "--pr");
6719
+ const taskId = requireFlag(flags2, "--task-id");
6720
+ const model = requireFlag(flags2, "--model");
6721
+ const tokensIn = requireTokenField(flags2, "--tokens-in");
6722
+ const tokensOut = requireTokenField(flags2, "--tokens-out");
6723
+ const cost = requireFlag(flags2, "--cost");
6724
+ const tokens = { taskId, model, tokensIn, tokensOut, cost };
6725
+ const headSha = resolveHeadSha(pr);
6726
+ const principalAllowlist = resolvePrincipalAllowlist(loadTrustAnchorConfig());
6727
+ let body;
6728
+ let verify;
6729
+ if (role === "code-reviewer") {
6730
+ const verdict = normalizeCodeReviewVerdict(requireFlag(flags2, "--verdict"));
6731
+ const findings = readFindingsFile(flags2.get("--findings-file"), CODE_REVIEW_SEVERITIES);
6732
+ if (findings.some((f) => f.severity === "BLOCKER") && verdict === "APPROVE") {
6733
+ refuseCmd("Findings include a BLOCKER but `--verdict APPROVE` — reviewer.md requires REQUEST CHANGES whenever any BLOCKER finding exists.", "Pass `--verdict REQUEST_CHANGES`, or fix the finding severity if BLOCKER was a miscategorization.");
6734
+ }
6735
+ const input = {
6736
+ ...tokens,
6737
+ headSha,
6738
+ verdict,
6739
+ briefConformance: requireFlag(flags2, "--brief-conformance"),
6740
+ specConformance: requireFlag(flags2, "--spec-conformance"),
6741
+ findings,
6742
+ scope: requireFlag(flags2, "--scope"),
6743
+ tests: requireFlag(flags2, "--tests"),
6744
+ docs: requireFlag(flags2, "--docs")
6745
+ };
6746
+ body = renderCodeReviewComment(input);
6747
+ verify = (comments2) => verifyPostedCodeReview(comments2, verdict, headSha, principalAllowlist);
6748
+ } else {
6749
+ const verdict = normalizeSecurityVerdict(requireFlag(flags2, "--verdict"));
6750
+ const findings = readFindingsFile(flags2.get("--findings-file"), SECURITY_SEVERITIES);
6751
+ if (findings.some((f) => f.severity === "CRITICAL" || f.severity === "HIGH") && verdict === "PASS") {
6752
+ refuseCmd("Findings include a CRITICAL/HIGH finding but `--verdict PASS` — security.md requires FAIL whenever any CRITICAL or HIGH finding exists.", "Pass `--verdict FAIL`, or fix the finding severity if it was a miscategorization.");
6753
+ }
6754
+ const secrets = requireFlag(flags2, "--secrets");
6755
+ const secretsEvidenceFile = flags2.get("--secrets-evidence-file");
6756
+ if (isNoneFoundClaim(secrets) && !secretsEvidenceFile) {
6757
+ refuseCmd('`--secrets` normalizes to "none found" but no `--secrets-evidence-file` was given — security.md: "SECRETS: none found" with no scan output pasted is an unbacked self-attestation.', "Pass `--secrets-evidence-file <path>` containing the actual scanner output, or change `--secrets` to describe what was found instead.");
6758
+ }
6759
+ let secretsEvidence = null;
6760
+ if (secretsEvidenceFile) {
6761
+ try {
6762
+ secretsEvidence = readFileSync18(secretsEvidenceFile, "utf8");
6763
+ } catch {
6764
+ refuseCmd(`Could not read secrets evidence file at ${secretsEvidenceFile}.`, "Check the path and re-run.");
6765
+ }
6766
+ }
6767
+ const input = {
6768
+ ...tokens,
6769
+ headSha,
6770
+ verdict,
6771
+ findings,
6772
+ configScan: requireFlag(flags2, "--config-scan"),
6773
+ secrets,
6774
+ secretsEvidence
6775
+ };
6776
+ body = renderSecurityComment(input);
6777
+ verify = (comments2) => verifyPostedSecurity(comments2, verdict, headSha, principalAllowlist);
6778
+ }
6779
+ const url = postComment(pr, body);
6780
+ let comments;
6781
+ try {
6782
+ comments = fetchComments(pr);
6783
+ } catch (err) {
6784
+ refuseCmd(`Posted the comment (${url}) but could not re-fetch PR ${pr}'s comments to self-verify: ${err instanceof Error ? err.message : String(err)}`, "Check `gh auth status`/network and manually confirm the posted comment parses cleanly — this command could not verify it.");
6785
+ }
6786
+ const result = verify(comments);
6787
+ if (!result.ok) {
6788
+ refuseCmd(`Posted comment ${url}, but self-verification FAILED on re-parse: ${result.reason}`, "The posted comment does not re-parse clean through the same extractCodeReviewVerdict/extractSecurityReviewVerdict functions the merge gate calls. Do not treat the post as valid — inspect the comment and this command for drift, fix, and re-run.");
6789
+ }
6790
+ if (json) {
6791
+ printJson({ posted: true, url, role, headSha, selfVerified: true });
6792
+ } else {
6793
+ process.stdout.write(`${body}
6794
+
6795
+ Posted: ${url}
6796
+ Self-verification: clean — re-parsed VERDICT is bound to head ${headSha}.
6797
+ `);
6798
+ }
6799
+ }
6800
+
5764
6801
  // src/commands/studio.ts
5765
6802
  import { execFile as execFile5, spawn as spawn2 } from "node:child_process";
5766
- import { existsSync as existsSync17, readFileSync as readFileSync17, renameSync } from "node:fs";
6803
+ import { existsSync as existsSync18, readFileSync as readFileSync19, renameSync } from "node:fs";
5767
6804
  import net from "node:net";
5768
- import { dirname as dirname7, join as join19 } from "node:path";
6805
+ import { dirname as dirname7, join as join20 } from "node:path";
5769
6806
  import { promisify as promisify5 } from "node:util";
5770
6807
 
5771
6808
  // src/lib/studio-bundle.ts
@@ -5781,25 +6818,25 @@ var FALLBACK_PORT = 3108;
5781
6818
  function resolveStudioTarget(cwd, moduleUrl = import.meta.url) {
5782
6819
  let dir = cwd;
5783
6820
  for (;; ) {
5784
- const webDir = join19(dir, "apps", "vinaya-studio", "web");
5785
- const pkgPath = join19(webDir, "package.json");
5786
- if (existsSync17(pkgPath)) {
6821
+ const webDir = join20(dir, "apps", "vinaya-studio", "web");
6822
+ const pkgPath = join20(webDir, "package.json");
6823
+ if (existsSync18(pkgPath)) {
5787
6824
  try {
5788
- const pkg = JSON.parse(readFileSync17(pkgPath, "utf-8"));
6825
+ const pkg = JSON.parse(readFileSync19(pkgPath, "utf-8"));
5789
6826
  if (pkg.name === "@atta/vinaya-studio-web") {
5790
6827
  return { kind: "workspace", webDir };
5791
6828
  }
5792
6829
  } catch {}
5793
6830
  }
5794
- if (existsSync17(join19(dir, ".git")))
6831
+ if (existsSync18(join20(dir, ".git")))
5795
6832
  break;
5796
6833
  const parent = dirname7(dir);
5797
6834
  if (parent === dir)
5798
6835
  break;
5799
6836
  dir = parent;
5800
6837
  }
5801
- const standaloneWebDir = join19(packageRoot(moduleUrl), "studio-standalone", "apps", "vinaya-studio", "web");
5802
- if (existsSync17(join19(standaloneWebDir, "server.js"))) {
6838
+ const standaloneWebDir = join20(packageRoot(moduleUrl), "studio-standalone", "apps", "vinaya-studio", "web");
6839
+ if (existsSync18(join20(standaloneWebDir, "server.js"))) {
5803
6840
  return { kind: "package", packageDir: standaloneWebDir };
5804
6841
  }
5805
6842
  return { kind: "missing" };
@@ -5819,9 +6856,9 @@ function isPortFree(port) {
5819
6856
  });
5820
6857
  }
5821
6858
  function ensureStudioNodeModules(bundleRoot) {
5822
- const real = join19(bundleRoot, "node_modules");
5823
- const packed = join19(bundleRoot, STUDIO_NODE_MODULES_PACKED_DIRNAME);
5824
- if (!existsSync17(real) && existsSync17(packed)) {
6859
+ const real = join20(bundleRoot, "node_modules");
6860
+ const packed = join20(bundleRoot, STUDIO_NODE_MODULES_PACKED_DIRNAME);
6861
+ if (!existsSync18(real) && existsSync18(packed)) {
5825
6862
  renameSync(packed, real);
5826
6863
  }
5827
6864
  }
@@ -5847,8 +6884,8 @@ async function runStudio(cwd, args, moduleUrl = import.meta.url) {
5847
6884
  case "workspace":
5848
6885
  return spawnDev(target.webDir, args);
5849
6886
  case "package": {
5850
- const bundleRoot = join19(target.packageDir, "..", "..", "..");
5851
- return spawnStandalone(cwd, join19(target.packageDir, "server.js"), bundleRoot);
6887
+ const bundleRoot = join20(target.packageDir, "..", "..", "..");
6888
+ return spawnStandalone(cwd, join20(target.packageDir, "server.js"), bundleRoot);
5852
6889
  }
5853
6890
  case "missing":
5854
6891
  console.error("Vinaya Studio isn't available in this install — no published @attalabs/vinaya build bundles the Studio app yet. Inside a checkout that contains Studio's source (apps/vinaya-studio/web), `vinaya studio` runs it directly.");
@@ -5857,8 +6894,8 @@ async function runStudio(cwd, args, moduleUrl = import.meta.url) {
5857
6894
  }
5858
6895
 
5859
6896
  // src/commands/upgrade.ts
5860
- import { existsSync as existsSync18, readFileSync as readFileSync18, rmSync as rmSync4, writeFileSync as writeFileSync10 } from "node:fs";
5861
- import { join as join20 } from "node:path";
6897
+ import { existsSync as existsSync19, readFileSync as readFileSync20, rmSync as rmSync5, writeFileSync as writeFileSync11 } from "node:fs";
6898
+ import { join as join21 } from "node:path";
5862
6899
  function realDeps7() {
5863
6900
  return {
5864
6901
  detectRepo: detectGitRepo,
@@ -5875,13 +6912,13 @@ function realDeps7() {
5875
6912
  function flags2(args) {
5876
6913
  return { dryRun: args.includes("--dry-run"), yes: args.includes("--yes") };
5877
6914
  }
5878
- function readManifest3(repoRoot) {
5879
- const p = join20(repoRoot, CONFIG_PATH);
5880
- if (!existsSync18(p))
6915
+ function readManifest3(repoRoot2) {
6916
+ const p = join21(repoRoot2, CONFIG_PATH);
6917
+ if (!existsSync19(p))
5881
6918
  return { kind: "missing" };
5882
6919
  let raw;
5883
6920
  try {
5884
- raw = JSON.parse(readFileSync18(p, "utf-8"));
6921
+ raw = JSON.parse(readFileSync20(p, "utf-8"));
5885
6922
  } catch (err) {
5886
6923
  return { kind: "invalid", error: `invalid JSON: ${err.message}` };
5887
6924
  }
@@ -5896,31 +6933,31 @@ function readManifest3(repoRoot) {
5896
6933
  return { kind: "not-initialized" };
5897
6934
  return { kind: "ok", manifest: parsed.data.managed };
5898
6935
  }
5899
- function writeManifestVersion(repoRoot, manifest) {
5900
- const configAbs = join20(repoRoot, CONFIG_PATH);
5901
- const seed = JSON.parse(readFileSync18(configAbs, "utf-8"));
6936
+ function writeManifestVersion(repoRoot2, manifest) {
6937
+ const configAbs = join21(repoRoot2, CONFIG_PATH);
6938
+ const seed = JSON.parse(readFileSync20(configAbs, "utf-8"));
5902
6939
  const updated = { ...manifest, version: MANAGED_MANIFEST_VERSION };
5903
- writeFileSync10(configAbs, `${JSON.stringify({ ...seed, managed: updated }, null, 2)}
6940
+ writeFileSync11(configAbs, `${JSON.stringify({ ...seed, managed: updated }, null, 2)}
5904
6941
  `, "utf-8");
5905
6942
  }
5906
- function stripFor(repoRoot, path, marker, comment) {
5907
- const abs2 = resolveManagedBlockPath(repoRoot, path);
5908
- if (!existsSync18(abs2))
6943
+ function stripFor(repoRoot2, path, marker, comment) {
6944
+ const abs2 = resolveManagedBlockPath(repoRoot2, path);
6945
+ if (!existsSync19(abs2))
5909
6946
  return { path, marker, comment, present: false, removesHost: false };
5910
- const stripped = stripBlockFromContent(readFileSync18(abs2, "utf-8"), marker, comment);
6947
+ const stripped = stripBlockFromContent(readFileSync20(abs2, "utf-8"), marker, comment);
5911
6948
  if (stripped === null)
5912
6949
  return { path, marker, comment, present: false, removesHost: false };
5913
6950
  return { path, marker, comment, present: true, removesHost: blockStripLeavesEmpty(stripped) };
5914
6951
  }
5915
- function planHookRouting(repoRoot, manifest, recorded, hooksPathValue) {
6952
+ function planHookRouting(repoRoot2, manifest, recorded, hooksPathValue) {
5916
6953
  const none = { target: recorded, strips: [], arm: false, migratesManifest: false, blockedReason: null };
5917
6954
  if (recorded === ".husky")
5918
6955
  return none;
5919
6956
  if (recorded === TRACKED_HOOK_DIR) {
5920
- const strips = manifest.blocks.filter((b) => b.path.startsWith(`${TRACKED_HOOK_DIR}/`)).map((b) => stripFor(repoRoot, `.git/hooks/${b.marker}`, b.marker, b.comment)).filter((s) => s.present);
6957
+ const strips = manifest.blocks.filter((b) => b.path.startsWith(`${TRACKED_HOOK_DIR}/`)).map((b) => stripFor(repoRoot2, `.git/hooks/${b.marker}`, b.marker, b.comment)).filter((s) => s.present);
5921
6958
  if (hooksPathValue === TRACKED_HOOK_DIR)
5922
6959
  return { ...none, strips };
5923
- const foreign = foreignRawHooks(repoRoot);
6960
+ const foreign = foreignRawHooks(repoRoot2);
5924
6961
  if (foreign.length > 0) {
5925
6962
  return {
5926
6963
  ...none,
@@ -5933,17 +6970,17 @@ function planHookRouting(repoRoot, manifest, recorded, hooksPathValue) {
5933
6970
  const legacy = manifest.blocks.filter((b) => b.path.startsWith(".git/hooks/"));
5934
6971
  const blocked = [];
5935
6972
  for (const b of legacy) {
5936
- const abs2 = resolveManagedBlockPath(repoRoot, b.path);
5937
- if (!existsSync18(abs2))
6973
+ const abs2 = resolveManagedBlockPath(repoRoot2, b.path);
6974
+ if (!existsSync19(abs2))
5938
6975
  continue;
5939
- const stripped = stripBlockFromContent(readFileSync18(abs2, "utf-8"), b.marker, b.comment);
6976
+ const stripped = stripBlockFromContent(readFileSync20(abs2, "utf-8"), b.marker, b.comment);
5940
6977
  if (stripped === null)
5941
6978
  blocked.push(`${b.path} exists without vinaya's managed block`);
5942
6979
  else if (!blockStripLeavesEmpty(stripped))
5943
6980
  blocked.push(`${b.path} carries your own lines alongside the managed block`);
5944
6981
  }
5945
6982
  const legacyNames = new Set(legacy.map((b) => b.path.slice(".git/hooks/".length)));
5946
- for (const f of activeRawHooks(repoRoot)) {
6983
+ for (const f of activeRawHooks(repoRoot2)) {
5947
6984
  if (!legacyNames.has(f))
5948
6985
  blocked.push(`.git/hooks/${f} is an active raw hook vinaya does not manage`);
5949
6986
  }
@@ -5951,7 +6988,7 @@ function planHookRouting(repoRoot, manifest, recorded, hooksPathValue) {
5951
6988
  return { ...none, blockedReason: blocked.join("; ") };
5952
6989
  return {
5953
6990
  target: TRACKED_HOOK_DIR,
5954
- strips: legacy.map((b) => stripFor(repoRoot, b.path, b.marker, b.comment)).filter((s) => s.present),
6991
+ strips: legacy.map((b) => stripFor(repoRoot2, b.path, b.marker, b.comment)).filter((s) => s.present),
5955
6992
  arm: hooksPathValue !== TRACKED_HOOK_DIR,
5956
6993
  migratesManifest: true,
5957
6994
  blockedReason: null
@@ -5963,7 +7000,7 @@ function translateHookPaths(manifest) {
5963
7000
  blocks: manifest.blocks.map((b) => b.path.startsWith(".git/hooks/") ? { ...b, path: `${TRACKED_HOOK_DIR}/${b.path.slice(".git/hooks/".length)}` } : b)
5964
7001
  };
5965
7002
  }
5966
- function planUpgrade(ops, repoRoot, manifest, routing) {
7003
+ function planUpgrade(ops, repoRoot2, manifest, routing) {
5967
7004
  const entries = [];
5968
7005
  let hasChanges = routing.arm || routing.migratesManifest || routing.strips.some((s) => s.present);
5969
7006
  const ownedFiles = new Set(manifest.files);
@@ -5971,8 +7008,8 @@ function planUpgrade(ops, repoRoot, manifest, routing) {
5971
7008
  const ownedBlocks = new Set(manifest.blocks.map((b) => blockKey(b.path, b.marker)));
5972
7009
  for (const op of ops) {
5973
7010
  if (op.kind === "create-file") {
5974
- const abs2 = join20(repoRoot, op.path);
5975
- const exists = existsSync18(abs2);
7011
+ const abs2 = join21(repoRoot2, op.path);
7012
+ const exists = existsSync19(abs2);
5976
7013
  const owned = ownedFiles.has(op.path);
5977
7014
  let action;
5978
7015
  if (op.path === CONFIG_PATH || op.path === DOC_OWNERS_PATH) {
@@ -5982,7 +7019,7 @@ function planUpgrade(ops, repoRoot, manifest, routing) {
5982
7019
  } else if (!exists) {
5983
7020
  action = "recreate";
5984
7021
  hasChanges = true;
5985
- } else if (readFileSync18(abs2, "utf-8") !== op.content) {
7022
+ } else if (readFileSync20(abs2, "utf-8") !== op.content) {
5986
7023
  action = "regenerate";
5987
7024
  hasChanges = true;
5988
7025
  } else {
@@ -5990,16 +7027,16 @@ function planUpgrade(ops, repoRoot, manifest, routing) {
5990
7027
  }
5991
7028
  entries.push({ kind: "create-file", op, action });
5992
7029
  } else if (op.kind === "managed-block") {
5993
- const abs2 = resolveManagedBlockPath(repoRoot, op.path);
7030
+ const abs2 = resolveManagedBlockPath(repoRoot2, op.path);
5994
7031
  const owned = ownedBlocks.has(blockKey(op.path, op.marker));
5995
7032
  let action;
5996
7033
  if (!owned) {
5997
7034
  action = "not-installed";
5998
- } else if (!existsSync18(abs2)) {
7035
+ } else if (!existsSync19(abs2)) {
5999
7036
  action = "recreate-host";
6000
7037
  hasChanges = true;
6001
7038
  } else {
6002
- const content = readFileSync18(abs2, "utf-8");
7039
+ const content = readFileSync20(abs2, "utf-8");
6003
7040
  const { begin, end } = markerLines(op.marker, op.comment);
6004
7041
  if (!(content.includes(begin) && content.includes(end))) {
6005
7042
  action = "recreate-append";
@@ -6097,45 +7134,45 @@ function renderUpgradeDiff(plan) {
6097
7134
  return lines.join(`
6098
7135
  `);
6099
7136
  }
6100
- function regenerateBlock(repoRoot, op) {
6101
- const abs2 = resolveManagedBlockPath(repoRoot, op.path);
6102
- const content = readFileSync18(abs2, "utf-8");
7137
+ function regenerateBlock(repoRoot2, op) {
7138
+ const abs2 = resolveManagedBlockPath(repoRoot2, op.path);
7139
+ const content = readFileSync20(abs2, "utf-8");
6103
7140
  const stripped = stripBlockFromContent(content, op.marker, op.comment);
6104
7141
  if (stripped !== null) {
6105
- writeFileSync10(abs2, stripped.endsWith(`
7142
+ writeFileSync11(abs2, stripped.endsWith(`
6106
7143
  `) ? stripped : `${stripped}
6107
7144
  `, "utf-8");
6108
7145
  }
6109
- appendBlock(repoRoot, op);
7146
+ appendBlock(repoRoot2, op);
6110
7147
  }
6111
- function applyUpgrade(plan, repoRoot) {
7148
+ function applyUpgrade(plan, repoRoot2) {
6112
7149
  for (const e of plan.entries) {
6113
7150
  if (e.kind === "create-file") {
6114
7151
  if (e.action === "regenerate" || e.action === "recreate") {
6115
- writeFileWithDirs(join20(repoRoot, e.op.path), e.op.content, e.op.mode);
7152
+ writeFileWithDirs(join21(repoRoot2, e.op.path), e.op.content, e.op.mode);
6116
7153
  }
6117
7154
  } else {
6118
7155
  if (e.action === "recreate-host")
6119
- createHost(repoRoot, e.op);
7156
+ createHost(repoRoot2, e.op);
6120
7157
  else if (e.action === "recreate-append")
6121
- appendBlock(repoRoot, e.op);
7158
+ appendBlock(repoRoot2, e.op);
6122
7159
  else if (e.action === "regenerate-block")
6123
- regenerateBlock(repoRoot, e.op);
7160
+ regenerateBlock(repoRoot2, e.op);
6124
7161
  }
6125
7162
  }
6126
7163
  for (const s of plan.routing.strips) {
6127
7164
  if (!s.present)
6128
7165
  continue;
6129
- const abs2 = resolveManagedBlockPath(repoRoot, s.path);
6130
- if (!existsSync18(abs2))
7166
+ const abs2 = resolveManagedBlockPath(repoRoot2, s.path);
7167
+ if (!existsSync19(abs2))
6131
7168
  continue;
6132
- const stripped = stripBlockFromContent(readFileSync18(abs2, "utf-8"), s.marker, s.comment);
7169
+ const stripped = stripBlockFromContent(readFileSync20(abs2, "utf-8"), s.marker, s.comment);
6133
7170
  if (stripped === null)
6134
7171
  continue;
6135
7172
  if (blockStripLeavesEmpty(stripped))
6136
- rmSync4(abs2, { force: true });
7173
+ rmSync5(abs2, { force: true });
6137
7174
  else
6138
- writeFileSync10(abs2, stripped.endsWith(`
7175
+ writeFileSync11(abs2, stripped.endsWith(`
6139
7176
  `) ? stripped : `${stripped}
6140
7177
  `, "utf-8");
6141
7178
  }
@@ -6217,16 +7254,16 @@ async function upgradeCommand(args) {
6217
7254
  }
6218
7255
 
6219
7256
  // src/commands/waiver.ts
6220
- import { execFileSync as execFileSync11 } from "node:child_process";
7257
+ import { execFileSync as execFileSync15 } from "node:child_process";
6221
7258
  function labelFor(kind) {
6222
7259
  return kind === "docs" ? WAIVER_LABEL : WAIVER_LABEL_REVIEW;
6223
7260
  }
6224
- function gh(args) {
6225
- return execFileSync11("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: process.env }).trim();
7261
+ function gh2(args) {
7262
+ return execFileSync15("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: process.env }).trim();
6226
7263
  }
6227
7264
  function detectPrNumber() {
6228
7265
  try {
6229
- const out = gh(["pr", "view", "--json", "number", "-q", ".number"]);
7266
+ const out = gh2(["pr", "view", "--json", "number", "-q", ".number"]);
6230
7267
  return /^\d+$/.test(out) ? out : null;
6231
7268
  } catch {
6232
7269
  return null;
@@ -6322,8 +7359,8 @@ Reason: ${reason}`];
6322
7359
  }
6323
7360
  process.stdout.write(`Applying \`${label2}\` to PR #${prNumber} under your authenticated \`gh\` identity — vinaya never fabricates an actor.
6324
7361
  `);
6325
- execFileSync11("gh", editArgs, { stdio: "inherit", env: process.env });
6326
- execFileSync11("gh", commentArgs, { stdio: "inherit", env: process.env });
7362
+ execFileSync15("gh", editArgs, { stdio: "inherit", env: process.env });
7363
+ execFileSync15("gh", commentArgs, { stdio: "inherit", env: process.env });
6327
7364
  process.stdout.write(`
6328
7365
  ✓ Label applied and reason posted as a PR comment.
6329
7366
  `);
@@ -6483,6 +7520,48 @@ var COMMANDS = [
6483
7520
  ],
6484
7521
  status: "shipped"
6485
7522
  },
7523
+ {
7524
+ name: "review post",
7525
+ description: "Render, post, and self-verify a code-reviewer or security-review verdict comment on a PR",
7526
+ flags: [
7527
+ { flag: "--role", description: "`code-reviewer` or `security` — selects which role template is rendered" },
7528
+ {
7529
+ flag: "--pr",
7530
+ description: "Target PR number — its real head is resolved via `gh pr view`, never caller-supplied"
7531
+ },
7532
+ {
7533
+ flag: "--verdict",
7534
+ description: "code-reviewer: `APPROVE` | `REQUEST_CHANGES`. security: `PASS` | `FAIL`"
7535
+ },
7536
+ {
7537
+ flag: "--findings-file",
7538
+ description: "One finding per line: `SEVERITY|file:line|description` (`|`-delimited). Omit for zero findings."
7539
+ },
7540
+ { flag: "--brief-conformance", description: "code-reviewer only: the BRIEF CONFORMANCE line" },
7541
+ { flag: "--spec-conformance", description: "code-reviewer only: the SPEC CONFORMANCE line" },
7542
+ { flag: "--scope", description: "code-reviewer only: the SCOPE line" },
7543
+ { flag: "--tests", description: "code-reviewer only: the TESTS line" },
7544
+ { flag: "--docs", description: "code-reviewer only: the DOCS line" },
7545
+ { flag: "--config-scan", description: "security only: the CONFIG SCAN line" },
7546
+ { flag: "--secrets", description: "security only: the SECRETS line" },
7547
+ {
7548
+ flag: "--secrets-evidence-file",
7549
+ description: 'security only: required whenever `--secrets` claims "none found" — the pasted scanner output backing that claim'
7550
+ },
7551
+ { flag: "--task-id", description: "the closing `Tokens:` line's task id" },
7552
+ { flag: "--model", description: "the closing `Tokens:` line's model name" },
7553
+ { flag: "--tokens-in", description: "a non-negative integer, or `-` if unknown" },
7554
+ { flag: "--tokens-out", description: "a non-negative integer, or `-` if unknown" },
7555
+ { flag: "--cost", description: "free text, or `-` if unknown" },
7556
+ { flag: "--json", description: "Enveloped JSON output (schema: 1)" }
7557
+ ],
7558
+ details: [
7559
+ "Every structural line (`VERDICT:`, `Judged head:`) is rendered from this command's own validated enum/sha inputs — never from a caller-supplied string — so a Reviewer's free-typed prose can no longer produce a shape the merge gate's line-anchored regex fails to see.",
7560
+ "Refuses a contradictory verdict before posting anything: a BLOCKER finding with `--verdict APPROVE`, or a CRITICAL/HIGH finding with `--verdict PASS`, is rejected outright, mirroring each role doc's own consistency rule.",
7561
+ "After posting, re-fetches the PR's comments and runs them through the exact `extractCodeReviewVerdict`/`extractSecurityReviewVerdict` functions `checkReviewGate` calls — the same functions, not a second implementation — and exits non-zero naming precisely what failed to re-parse if the post does not come back clean and bound to the resolved head. There is no `--skip-verify` escape."
7562
+ ],
7563
+ status: "shipped"
7564
+ },
6486
7565
  {
6487
7566
  name: "doctor",
6488
7567
  description: "Diagnose hook, workflow, and config health — report only, never mutates",
@@ -6628,9 +7707,9 @@ function printHelp() {
6628
7707
  }
6629
7708
 
6630
7709
  // src/index.ts
6631
- var PACKAGE_ROOT = join21(dirname8(fileURLToPath2(import.meta.url)), "..");
7710
+ var PACKAGE_ROOT = join22(dirname8(fileURLToPath2(import.meta.url)), "..");
6632
7711
  function readVersion2() {
6633
- const pkg = JSON.parse(readFileSync19(join21(PACKAGE_ROOT, "package.json"), "utf-8"));
7712
+ const pkg = JSON.parse(readFileSync21(join22(PACKAGE_ROOT, "package.json"), "utf-8"));
6634
7713
  return pkg.version;
6635
7714
  }
6636
7715
  var [, , command, ...args] = process.argv;
@@ -6733,6 +7812,16 @@ try {
6733
7812
  }
6734
7813
  break;
6735
7814
  }
7815
+ case "review": {
7816
+ const [subcommand, ...rest] = args;
7817
+ if (subcommand === "post") {
7818
+ await reviewPostCommand(rest);
7819
+ } else {
7820
+ console.error(`Unknown 'review' subcommand: ${subcommand ?? "(none)"} (expected 'post')`);
7821
+ process.exit(2);
7822
+ }
7823
+ break;
7824
+ }
6736
7825
  case "demo": {
6737
7826
  const [subcommand, ...rest] = args;
6738
7827
  if (subcommand === "break") {