@attalabs/vinaya 0.22.0 → 0.23.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 (188) hide show
  1. package/README.md +13 -3
  2. package/aeg-root/contracts/archivist-tranche-archivist.md +2 -2
  3. package/aeg-root/contracts/brief-developer.md +1 -0
  4. package/aeg-root/contracts/developer-reviewer.md +3 -3
  5. package/aeg-root/contracts/reviewer-archivist.md +3 -3
  6. package/aeg-root/enforcement.md +26 -20
  7. package/aeg-root/process.md +8 -4
  8. package/aeg-root/roles/archivist.md +2 -2
  9. package/aeg-root/roles/brief-author.md +9 -3
  10. package/aeg-root/roles/developer.md +63 -31
  11. package/aeg-root/roles/planner.md +1 -1
  12. package/aeg-root/roles/principal.md +14 -0
  13. package/aeg-root/roles/reviewer.md +32 -15
  14. package/aeg-root/roles/security.md +15 -5
  15. package/aeg-root/skills/aeg/SKILL.md +9 -5
  16. package/aeg-root/skills/aeg-roles/SKILL.md +2 -2
  17. package/aeg-root/skills/brief-authoring/SKILL.md +39 -27
  18. package/aeg-root/state-machine.md +9 -11
  19. package/aeg-root/templates/brief-template.md +6 -6
  20. package/aeg-root/templates/pr-report-template.md +10 -7
  21. package/aeg-root/tranche-model.md +6 -2
  22. package/dist/checks/bin/check-body-bare-digits.js +552 -139
  23. package/dist/checks/bin/check-branch-topology.js +603 -144
  24. package/dist/checks/bin/check-brief-shape.js +593 -142
  25. package/dist/checks/bin/check-changeset-coverage.js +935 -161
  26. package/dist/checks/bin/check-closes-n.js +603 -144
  27. package/dist/checks/bin/check-coherence.js +614 -147
  28. package/dist/checks/bin/check-dead-branch-push.js +552 -139
  29. package/dist/checks/bin/check-dispatch-readiness.js +612 -145
  30. package/dist/checks/bin/check-doc-coverage-push.js +689 -218
  31. package/dist/checks/bin/check-doc-coverage.js +689 -218
  32. package/dist/checks/bin/check-doctrine-no-procedures.js +4794 -0
  33. package/dist/checks/bin/check-doctrine-portability.js +684 -146
  34. package/dist/checks/bin/check-evidence-fresh.js +994 -174
  35. package/dist/checks/bin/check-exec-bits.js +4931 -0
  36. package/dist/checks/bin/check-first-push-dispatch.js +603 -144
  37. package/dist/checks/bin/check-issue-assignment.js +603 -144
  38. package/dist/checks/bin/check-main-branch-refusal.js +552 -139
  39. package/dist/checks/bin/check-no-disk-state.js +552 -139
  40. package/dist/checks/bin/check-pr-report-density.js +4461 -0
  41. package/dist/checks/bin/check-quoted-command.js +621 -155
  42. package/dist/checks/bin/check-reader-resolvable-prose.js +617 -154
  43. package/dist/checks/bin/check-registry-gates.js +595 -144
  44. package/dist/checks/bin/check-retired-vocabulary.js +617 -154
  45. package/dist/checks/bin/check-review-gate.js +670 -141
  46. package/dist/checks/bin/check-single-plan-pr.js +552 -139
  47. package/dist/checks/bin/check-test-plan.js +553 -140
  48. package/dist/checks/bin/check-token-collection-wired.js +552 -139
  49. package/dist/checks/bin/check-token-report.js +552 -139
  50. package/dist/checks/bin/check-workspace-escape.js +985 -145
  51. package/dist/index.js +8500 -5518
  52. package/package.json +1 -1
  53. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/actions.ts +173 -0
  54. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/anchored-region.ts +113 -0
  55. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/archive-task.ts +206 -0
  56. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/baseline-capture.ts +65 -0
  57. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/blast-radius-domains.ts +192 -0
  58. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/branch-topology-gate.ts +85 -0
  59. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/brief-validation.ts +511 -0
  60. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/claude-code-transcript.ts +460 -0
  61. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/coherence-checks.ts +787 -0
  62. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dead-branch-push-audit.ts +55 -0
  63. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dead-branch-push-guard.ts +77 -0
  64. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/derive-section7.ts +66 -0
  65. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/derive-tranche.ts +94 -0
  66. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/diagram-model.ts +372 -0
  67. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/direct-main-push.ts +39 -0
  68. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dispatch-gate.ts +224 -0
  69. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doc-owners.ts +314 -0
  70. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/build-doc-nav.ts +38 -0
  71. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/docs-coherence.ts +136 -0
  72. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/index.ts +29 -0
  73. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/legacy-anchors.ts +48 -0
  74. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/nav-helpers.ts +17 -0
  75. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/node-route.ts +134 -0
  76. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/parse-doc.ts +43 -0
  77. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/published-prose.ts +306 -0
  78. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/surfaced-manifest.ts +89 -0
  79. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/types.ts +33 -0
  80. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-portability.ts +170 -0
  81. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-source.ts +23 -0
  82. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/ensure-label.ts +28 -0
  83. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/file-classify.ts +51 -0
  84. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/first-push-dispatch-gate.ts +80 -0
  85. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/gate-audience.ts +168 -0
  86. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/index.ts +301 -0
  87. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/issue-assignment.ts +102 -0
  88. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/issue-validation.ts +746 -0
  89. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/leftover-detection.ts +68 -0
  90. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/local-anchor-coverage.ts +245 -0
  91. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/main-branch-refusal.ts +64 -0
  92. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/manifest-validity.ts +81 -0
  93. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/markdown-table.ts +76 -0
  94. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/metering-io-guard.ts +119 -0
  95. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/milestone-validation.ts +240 -0
  96. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/no-disk-state.ts +50 -0
  97. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-ledger.ts +77 -0
  98. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-registry.ts +105 -0
  99. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-token-report.ts +165 -0
  100. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-tranche.ts +183 -0
  101. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/pr-tier.ts +81 -0
  102. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/premise-check.ts +125 -0
  103. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/quoted-command.ts +275 -0
  104. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/reader-resolvable-prose.ts +320 -0
  105. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-checks.ts +255 -0
  106. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-parse.ts +164 -0
  107. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-scaffold.ts +216 -0
  108. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/report-tokens.ts +261 -0
  109. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/retired-vocabulary.ts +163 -0
  110. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/review-gate.ts +269 -0
  111. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/single-plan-pr.ts +73 -0
  112. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/state-machine-model.ts +269 -0
  113. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/state-source.ts +14 -0
  114. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/status-block.ts +8 -0
  115. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/sum-ledger.ts +26 -0
  116. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/symbol-collisions.ts +78 -0
  117. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/test-plan-gate.ts +104 -0
  118. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/test-plan-section.ts +65 -0
  119. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/types.ts +174 -0
  120. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/verdict-extraction.ts +140 -0
  121. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/vocabulary-citation.ts +114 -0
  122. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/waiver-label.ts +52 -0
  123. package/studio-standalone/_node_modules/@attalabs/aeg-core/src/workspace-escape.ts +172 -0
  124. package/studio-standalone/apps/vinaya-studio/web/.next/BUILD_ID +1 -1
  125. package/studio-standalone/apps/vinaya-studio/web/.next/build-manifest.json +3 -3
  126. package/studio-standalone/apps/vinaya-studio/web/.next/prerender-manifest.json +3 -3
  127. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.html +1 -1
  128. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.rsc +1 -1
  129. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  130. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  131. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  132. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  133. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  134. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page/server-reference-manifest.json +2 -2
  135. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  136. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  137. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/api/coherence/route.js.nft.json +1 -1
  138. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page/server-reference-manifest.json +2 -2
  139. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page.js.nft.json +1 -1
  140. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page_client-reference-manifest.js +1 -1
  141. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page/server-reference-manifest.json +2 -2
  142. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page.js.nft.json +1 -1
  143. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page_client-reference-manifest.js +1 -1
  144. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page/server-reference-manifest.json +2 -2
  145. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page.js.nft.json +1 -1
  146. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page_client-reference-manifest.js +1 -1
  147. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page/server-reference-manifest.json +2 -2
  148. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page.js.nft.json +1 -1
  149. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page_client-reference-manifest.js +1 -1
  150. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page/server-reference-manifest.json +2 -2
  151. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page.js.nft.json +1 -1
  152. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page_client-reference-manifest.js +1 -1
  153. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page/server-reference-manifest.json +2 -2
  154. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page.js.nft.json +1 -1
  155. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page_client-reference-manifest.js +1 -1
  156. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page/server-reference-manifest.json +2 -2
  157. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page.js.nft.json +1 -1
  158. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page_client-reference-manifest.js +1 -1
  159. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page/server-reference-manifest.json +2 -2
  160. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page.js.nft.json +1 -1
  161. package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page_client-reference-manifest.js +1 -1
  162. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/[root-of-the-server]__02a-3g8._.js +1 -1
  163. 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
  164. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0053k9k._.js +1 -1
  165. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0112h-k._.js +1 -1
  166. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0o771t1._.js +1 -1
  167. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0puovz5._.js +1 -1
  168. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1hs0dcu._.js +1 -1
  169. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/{[root-of-the-server]__069-_41._.js → [root-of-the-server]__1mcitst._.js} +1 -1
  170. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1wc4-ip._.js +1 -1
  171. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_03x_w6q._.js +1 -1
  172. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0gvm3og._.js +1 -1
  173. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0lwxg63._.js +1 -1
  174. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1fqw88f._.js +1 -1
  175. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1n0cnq-._.js +1 -1
  176. package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/node_modules_1vo08dj._.js +2 -2
  177. package/studio-standalone/apps/vinaya-studio/web/.next/server/middleware-build-manifest.js +3 -3
  178. package/studio-standalone/apps/vinaya-studio/web/.next/server/pages/500.html +1 -1
  179. package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.js +1 -1
  180. package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.json +3 -3
  181. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{37hnluhzj9bei.js → 0jer_drkl1e4r.js} +4 -4
  182. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{267o-tsrnuwnv.js → 20x-t0a0v129z.js} +1 -1
  183. package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{1hufpuody6vjv.js → 2ou_81ahqw31e.js} +1 -1
  184. package/studio-standalone/apps/vinaya-studio/web/package.json +2 -2
  185. /package/studio-standalone/_node_modules/@attalabs/vinaya/studio-standalone/_node_modules/@attalabs/{aeg-core → vinaya/studio-standalone/_node_modules/@attalabs/aeg-core}/bin/verify-coherence.ts +0 -0
  186. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → 5xy_GA7G5KRuE3HDumIiw}/_buildManifest.js +0 -0
  187. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → 5xy_GA7G5KRuE3HDumIiw}/_clientMiddlewareManifest.js +0 -0
  188. /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → 5xy_GA7G5KRuE3HDumIiw}/_ssgManifest.js +0 -0
@@ -0,0 +1,183 @@
1
+ import { readMarkdownTable } from './parse-registry'
2
+ import type { Tranche, Lifecycle, Task } from './types'
3
+
4
+ /**
5
+ * Parse `aeg-root/tranches/<name>.md` into a typed `Tranche`.
6
+ *
7
+ * Captures:
8
+ * - the tranche's name (from `# Tranche: <name> — <timeframe>`; the
9
+ * superseded `# Iteration:` spelling is still read, see `parseName`)
10
+ * - the lifecycle marker (`Lifecycle: active|complete`, defaulting to
11
+ * `'active'` when absent — pre-§11 files have no marker)
12
+ * - the first goal paragraph
13
+ * - the `## Tasks (topology)` table rows
14
+ * - per-task rationale blocks (raw markdown, captured verbatim)
15
+ * - an optional `## Backlog` section as bullets
16
+ *
17
+ * Real tranche files include narrative references to dropped task ids in
18
+ * prose (e.g. a "Task 3a — removed" note). The parser ignores those — only
19
+ * rows present in the topology table become tasks.
20
+ */
21
+ export function parseTranche(md: string): Tranche {
22
+ return {
23
+ name: parseName(md),
24
+ lifecycle: parseLifecycle(md),
25
+ goal: parseGoal(md),
26
+ tasks: parseTasks(md),
27
+ backlog: parseBacklog(md)
28
+ }
29
+ }
30
+
31
+ function parseName(md: string): string {
32
+ // The H1 is `# Tranche: <slug> — <timeframe>`. The slug may contain
33
+ // hyphens (e.g. `some-tranche-name`, `another-v1`), so capture the first
34
+ // non-whitespace run — the space before the em-dash is the delimiter.
35
+ //
36
+ // `Iteration:` is accepted as the superseded spelling, permanently. This is
37
+ // a READER of files this repo does not necessarily own: content pulled out
38
+ // of git history predates the rename and cannot be rewritten, and an
39
+ // adopter's own topology files are theirs, not ours to migrate. Refusing
40
+ // the old marker would not fail loudly — `parseName` returns `''`, which
41
+ // reads downstream as a nameless tranche. The writer side is unaffected:
42
+ // every file this repo emits or archives carries `# Tranche:`.
43
+ const m = md.match(/^#\s+(?:Tranche|Iteration):\s+(\S+)/m)
44
+ return m?.[1] ? m[1].trim() : ''
45
+ }
46
+
47
+ function parseLifecycle(md: string): Lifecycle {
48
+ // `Lifecycle: active` or `Lifecycle: complete`. The label may be wrapped in
49
+ // bold (`**Lifecycle:** active`); the closing `**` sits after the colon.
50
+ const m = md.match(/^\s*(?:\*\*)?Lifecycle\s*:(?:\*\*)?\s*(active|complete)\b/im)
51
+ return m?.[1] ? (m[1].toLowerCase() as Lifecycle) : 'active'
52
+ }
53
+
54
+ function parseGoal(md: string): string {
55
+ // Match `Goal (…): <text>` to the end of the paragraph.
56
+ // The live files use various subtitles ("execution, not roadmap-why",
57
+ // "execution, not product-why"); strip everything up to the colon and
58
+ // capture the first paragraph that follows.
59
+ const lines = md.split(/\r?\n/)
60
+ const buf: string[] = []
61
+ let inGoal = false
62
+ for (const raw of lines) {
63
+ const line = stripBoldWrappers(raw)
64
+ if (!inGoal) {
65
+ const m = line.match(/^Goal\b[^:]*:\s*(.*)$/i)
66
+ if (m) {
67
+ inGoal = true
68
+ const rest = (m[1] ?? '').trim()
69
+ if (rest) buf.push(rest)
70
+ }
71
+ } else {
72
+ if (line.trim() === '') break
73
+ // Stop at the next markdown heading or HR.
74
+ if (/^#{1,6}\s/.test(line) || /^---+\s*$/.test(line)) break
75
+ buf.push(line.trim())
76
+ }
77
+ }
78
+ return buf.join(' ').trim()
79
+ }
80
+
81
+ function stripBoldWrappers(line: string): string {
82
+ // Remove bold markers around `Goal …:` or `Lifecycle:` style lines so the
83
+ // greedy regexes above work on either `**Goal:**` or plain `Goal:`.
84
+ return line.replace(/\*\*/g, '')
85
+ }
86
+
87
+ function parseTasks(md: string): Task[] {
88
+ const rows = readMarkdownTable(md, /^##\s+Tasks\b/i)
89
+ const rationales = extractRationales(md)
90
+ const tasks: Task[] = []
91
+ for (const row of rows) {
92
+ // Expected 6 columns: # | Task | Issue | Project(s) | Depends-on | Conflicts-with.
93
+ if (row.length < 6) continue
94
+ const id = (row[0] ?? '').trim()
95
+ if (!id) continue
96
+ tasks.push({
97
+ id,
98
+ title: (row[1] ?? '').trim(),
99
+ issue: parseIssueCell(row[2] ?? ''),
100
+ projects: parseCsvCell(row[3] ?? ''),
101
+ dependsOn: parseEdgeCell(row[4] ?? ''),
102
+ conflictsWith: parseEdgeCell(row[5] ?? ''),
103
+ rationaleMarkdown: rationales.get(id) ?? ''
104
+ })
105
+ }
106
+ return tasks
107
+ }
108
+
109
+ function parseIssueCell(cell: string): number | null {
110
+ const trimmed = stripBackticks(cell).trim()
111
+ if (!trimmed) return null
112
+ if (isEmDashOrDash(trimmed)) return null
113
+ const m = trimmed.match(/#?(\d+)/)
114
+ return m ? Number(m[1]) : null
115
+ }
116
+
117
+ function parseCsvCell(cell: string): string[] {
118
+ const cleaned = stripBackticks(cell).trim()
119
+ if (!cleaned || isEmDashOrDash(cleaned)) return []
120
+ return cleaned
121
+ .split(',')
122
+ .map((s) => s.trim())
123
+ .filter(Boolean)
124
+ }
125
+
126
+ function parseEdgeCell(cell: string): string[] {
127
+ return parseCsvCell(cell)
128
+ }
129
+
130
+ function isEmDashOrDash(s: string): boolean {
131
+ const t = s.trim()
132
+ return t === '—' || t === '-' || t === '–'
133
+ }
134
+
135
+ function stripBackticks(s: string): string {
136
+ return s.replace(/`/g, '')
137
+ }
138
+
139
+ /**
140
+ * Capture each `### Task <id> — …` block as raw markdown — from the heading
141
+ * through the line before the next `### ` / `## ` / `# ` / end-of-file.
142
+ * The id may be `1`, `7a`, `7b`, etc. Per-task rationale lives inline in the
143
+ * tranche file (see §4 template); the planner's rationale also lives in the
144
+ * Issue body, but the inline copy is what `parseTranche` exposes.
145
+ */
146
+ function extractRationales(md: string): Map<string, string> {
147
+ const out = new Map<string, string>()
148
+ const lines = md.split(/\r?\n/)
149
+ let i = 0
150
+ while (i < lines.length) {
151
+ const m = (lines[i] ?? '').match(/^###\s+Task\s+(\S+)\s+—/)
152
+ if (!m?.[1]) {
153
+ i++
154
+ continue
155
+ }
156
+ const id = m[1]
157
+ const start = i
158
+ i++
159
+ while (i < lines.length && !/^#{1,3}\s/.test(lines[i] ?? '')) i++
160
+ out.set(id, lines.slice(start, i).join('\n').trimEnd())
161
+ }
162
+ return out
163
+ }
164
+
165
+ function parseBacklog(md: string): string[] {
166
+ const lines = md.split(/\r?\n/)
167
+ let i = 0
168
+ for (; i < lines.length; i++) {
169
+ if (/^##\s+Backlog\b/i.test(lines[i] ?? '')) {
170
+ i++
171
+ break
172
+ }
173
+ }
174
+ if (i >= lines.length) return []
175
+ const bullets: string[] = []
176
+ for (; i < lines.length; i++) {
177
+ const line = lines[i] ?? ''
178
+ if (/^#{1,3}\s/.test(line)) break
179
+ const m = line.match(/^\s*[-*+]\s+(.*)$/)
180
+ if (m?.[1]) bullets.push(m[1].trim())
181
+ }
182
+ return bullets
183
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * PR tier derivation and the docs-gate override check. Pure — takes the diff,
3
+ * PR body, and env-derived strings as explicit parameters. The original
4
+ * `scripts/verify-docs.ts` read `process.env.PR_BODY` / `OVERRIDE_DOCS` /
5
+ * `PR_LABELS` directly inside these functions; that hidden I/O is removed
6
+ * here — the caller (the CLI shim) reads env once and passes the values in.
7
+ */
8
+
9
+ import { hasLabel, label } from '@attalabs/aeg-forge-state'
10
+ import { anchoredRegion } from './anchored-region'
11
+ import { isDocFile, isSpecFile } from './file-classify'
12
+
13
+ /**
14
+ * Derive a tier from the changed-file list when no `Tier:` field is in the PR body.
15
+ *
16
+ * Rules (in priority order):
17
+ * 1. Spec or doc file in diff → Tier 1
18
+ * 2. Otherwise (code/config…) → Tier 0
19
+ *
20
+ * A frozen archive carries no tier signal: nothing reads it, so touching one
21
+ * says nothing about a change's impact.
22
+ */
23
+ export function deriveTierFromDiff(changed: string[]): 0 | 1 {
24
+ if (changed.some((p) => isSpecFile(p) || isDocFile(p))) return 1
25
+ return 0
26
+ }
27
+
28
+ /**
29
+ * Read the `Tier:` field from the PR body.
30
+ *
31
+ * Tolerates the three markdown shapes the field appears in:
32
+ * - plain: `Tier: 3`
33
+ * - bold colon: `**Tier:** 3` (the `**` wraps `Tier:` including the colon)
34
+ * - bold label: `**Tier**: 3` (the `**` wraps only `Tier`)
35
+ *
36
+ * The field may appear inline in a metadata line (e.g.
37
+ * `Tranche: x · Task: 1 · **Tier:** 3 · Project: y`), so it is NOT anchored
38
+ * to line-start. Returns null when no Tier field is present at all — the caller
39
+ * decides what a missing tier means (PR mode treats it as an explicit error,
40
+ * NOT a silent default).
41
+ *
42
+ * When the body carries an `AEG:TIER` anchor pair (`anchored-region.ts`,
43
+ * task 30), the same regex runs exclusively inside that pair — a `Tier:`
44
+ * mention anywhere else (a pasted reference brief, a quoted example) is
45
+ * ignored. Bodies without the pair parse exactly as before.
46
+ */
47
+ /**
48
+ * The exact `Tier:` field grammar, exported so a consumer that needs the
49
+ * MATCH itself (not just the parsed number) — `body-bare-digits`, which
50
+ * needs to know precisely which substring is the field's real value so it
51
+ * can exempt exactly that and nothing appended after it — reuses this one
52
+ * definition rather than a second regex that could silently drift from it.
53
+ * An optional bold-open, the word Tier, an optional bold-close, a colon, an
54
+ * optional bold-close (covers `**Tier:**`), optional space, then the digit.
55
+ */
56
+ export const TIER_FIELD = /(\*\*)?\s*Tier\s*(\*\*)?\s*:\s*(\*\*)?\s*([013])\b/i
57
+
58
+ export function readTierFromPrBody(prBody: string): 0 | 1 | 3 | null {
59
+ const searchIn = anchoredRegion(prBody, 'TIER') ?? prBody
60
+ const m = searchIn.match(TIER_FIELD)
61
+ if (!m) return null
62
+ const t = Number(m[4])
63
+ return t === 0 || t === 1 || t === 3 ? (t as 0 | 1 | 3) : null
64
+ }
65
+
66
+ /**
67
+ * The body token that activates the override — the label name in brackets, so
68
+ * the two spellings cannot drift. Built from the code-owned vocabulary rather
69
+ * than written as a literal (#614): `override:docs` was the one §14 system
70
+ * label the namespace migration missed, precisely because it lived here as a
71
+ * bare string that no label-family grep looked for.
72
+ */
73
+ const OVERRIDE_BODY_TOKEN = `[${label('override-docs')}]`
74
+
75
+ export function overrideActive(opts: { overrideDocsEnv?: string; prLabels?: string; prBody?: string }): boolean {
76
+ if (opts.overrideDocsEnv === '1') return true
77
+ const labels = (opts.prLabels || '').split(',').map((s) => s.trim())
78
+ if (hasLabel('override-docs', labels)) return true
79
+ if ((opts.prBody || '').includes(OVERRIDE_BODY_TOKEN)) return true
80
+ return false
81
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Premise-pinning grammar (aeg-governance-hardening task 11, #324). Pure —
3
+ * no `fs`; file content is injected via `fileReader` so the checker stays
4
+ * testable and CWD-independent.
5
+ *
6
+ * A brief/PR body's `Premise:` block pins concrete, checkable facts about the
7
+ * code surface at authoring time — e.g. "this function still exists", "this
8
+ * constant is still absent". `verify-dispatch --premise <body-file>`
9
+ * re-asserts every pin immediately before Step 0: a premise that no longer
10
+ * holds means the surface moved since the brief was written, and the
11
+ * Developer should stop and re-dig rather than execute against a stale
12
+ * mental model (the exact failure of aeg-governance-hardening's live-fire
13
+ * #3 — two briefs described a target architecture a later, uncited migration
14
+ * had already superseded).
15
+ *
16
+ * Deliberately minimal — exactly three assertion kinds. This is a pin
17
+ * format, not a DSL; resist adding more.
18
+ */
19
+
20
+ import { createHash } from 'node:crypto'
21
+ import { anchoredRegion } from './anchored-region'
22
+
23
+ export type PremiseAssertion =
24
+ | { kind: 'contains'; path: string; value: string }
25
+ | { kind: 'absent'; path: string; value: string }
26
+ | { kind: 'sha256'; path: string; value: string }
27
+
28
+ const ASSERTION_KINDS = new Set(['contains', 'absent', 'sha256'])
29
+
30
+ /**
31
+ * True for the header line in either serialization the rest of the brief
32
+ * grammar uses (`brief-validation.ts`, `issue-validation.ts`): `**Premise:**`
33
+ * bold-inline or `### Premise` heading. Strips `*`/`#` markup first rather
34
+ * than trying to enumerate every marker placement — `**Premise:**` closes
35
+ * its bold markers *after* the colon, `**Premise**:` closes them *before*.
36
+ */
37
+ function isPremiseHeader(line: string): boolean {
38
+ const stripped = line.replace(/[*#]/g, '').trim()
39
+ return /^premise\s*:?$/i.test(stripped)
40
+ }
41
+
42
+ /** Matches one bullet line: `- <path> <kind>: <value>`. */
43
+ const PREMISE_LINE = /^[-*]\s*(\S+)\s+(contains|absent|sha256)\s*:\s*(.+)$/i
44
+
45
+ /**
46
+ * Parse every `- <path> <kind>: <value>` bullet under the `Premise:` header.
47
+ * Malformed bullets (wrong kind, missing colon) are silently skipped — this
48
+ * mirrors `parsePremiseBlock`'s sibling parsers' presence-only philosophy;
49
+ * the caller decides whether zero assertions is itself a failure
50
+ * (`checkPremiseCoverage`). Stops the block at the first blank line or the
51
+ * first non-bullet line after the header.
52
+ *
53
+ * When the body carries an `AEG:PREMISE` anchor pair (`anchored-region.ts`,
54
+ * task 30), the block is parsed exclusively inside that pair — the pair must
55
+ * contain the `**Premise:**` header line and its bullets; a premise-shaped
56
+ * block anywhere else in the body is ignored. Bodies without the pair parse
57
+ * exactly as before.
58
+ */
59
+ export function parsePremiseBlock(prBody: string): PremiseAssertion[] {
60
+ const searchIn = anchoredRegion(prBody, 'PREMISE') ?? prBody
61
+ const lines = searchIn.split(/\r?\n/)
62
+ const assertions: PremiseAssertion[] = []
63
+ let inBlock = false
64
+
65
+ for (const raw of lines) {
66
+ const line = raw.trim()
67
+ if (!inBlock) {
68
+ if (isPremiseHeader(line)) inBlock = true
69
+ continue
70
+ }
71
+ if (line === '') break
72
+ const m = line.match(PREMISE_LINE)
73
+ if (!m) break
74
+ const [, path, kindRaw, value] = m
75
+ const kind = (kindRaw as string).toLowerCase()
76
+ if (!ASSERTION_KINDS.has(kind)) continue
77
+ assertions.push({ kind, path: path as string, value: (value as string).trim() } as PremiseAssertion)
78
+ }
79
+
80
+ return assertions
81
+ }
82
+
83
+ export type PremiseCheckResult = { pass: boolean; failures: string[] }
84
+
85
+ /**
86
+ * Re-assert every parsed premise against the current file content, injected
87
+ * via `fileReader` (returns `null` when the path does not exist). One
88
+ * failure line per broken pin, naming the exact path/kind/value so the
89
+ * Developer can see precisely what moved.
90
+ */
91
+ export function checkPremises(
92
+ assertions: PremiseAssertion[],
93
+ fileReader: (path: string) => string | null
94
+ ): PremiseCheckResult {
95
+ const failures: string[] = []
96
+
97
+ for (const a of assertions) {
98
+ const content = fileReader(a.path)
99
+ if (content === null) {
100
+ failures.push(
101
+ `premise-check: ${a.path} does not exist on disk (premise asserted \`${a.kind}: ${a.value}\`) — the surface moved since this brief was authored; re-dig before proceeding.`
102
+ )
103
+ continue
104
+ }
105
+
106
+ if (a.kind === 'contains' && !content.includes(a.value)) {
107
+ failures.push(
108
+ `premise-check: ${a.path} no longer contains "${a.value}" — the premise this brief pinned has moved; re-dig before proceeding.`
109
+ )
110
+ } else if (a.kind === 'absent' && content.includes(a.value)) {
111
+ failures.push(
112
+ `premise-check: ${a.path} now contains "${a.value}", but the brief pinned it absent — the premise has moved; re-dig before proceeding.`
113
+ )
114
+ } else if (a.kind === 'sha256') {
115
+ const actual = createHash('sha256').update(content).digest('hex')
116
+ if (actual !== a.value.toLowerCase()) {
117
+ failures.push(
118
+ `premise-check: ${a.path} sha256 mismatch (brief pinned ${a.value}, file is now ${actual}) — the file changed since this brief was authored; re-dig before proceeding.`
119
+ )
120
+ }
121
+ }
122
+ }
123
+
124
+ return { pass: failures.length === 0, failures }
125
+ }
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Quoted-command staleness (Issue #128). Pure — no `fs`, no `git`/`gh`.
3
+ *
4
+ * **The defect this closes.** A doc quotes a command, config line, or file
5
+ * content verbatim, in backticks, as a statement of present fact — "what
6
+ * runs today: `X`". When a diff changes the thing being quoted, the
7
+ * quotation goes stale and nothing notices: the doc changed nothing, the
8
+ * code is correct, every existing gate passes. Measured, live: a CI
9
+ * invocation was pinned to a version in one PR; `aeg-root/enforcement.md`
10
+ * quoted the pre-pin form verbatim as "what runs today"; all 17 registered
11
+ * checks passed, `doctor` reported healthy, security passed. A code-review
12
+ * agent found it after roughly half an hour of independent re-derivation.
13
+ * The fix was one line — the detection took a review round.
14
+ *
15
+ * **Marker-based, not inferred — the Principal's explicit decision
16
+ * (2026-08-30).** The Issue leaves the claim-vs-instruction distinction
17
+ * open and calls it "the real work"; it is now settled: a doc opts a span
18
+ * in with a citation marker naming the file it quotes. An adopter-facing
19
+ * `npx @attalabs/vinaya init` in a README is instruction, correctly
20
+ * unpinned — inferring over command-looking spans would flag it, which is
21
+ * the exact false-positive that gets a gate disabled. This module
22
+ * implements NO inference and NO heuristic fallback for an unmarked span;
23
+ * only annotated quotes are ever evaluated. Coverage grows only as docs
24
+ * adopt the marker — that cost is understood and accepted.
25
+ *
26
+ * **The marker grammar.** Two-line (or inline) HTML-comment pair, the same
27
+ * invisible-on-render, code-blind idiom `anchored-region.ts` uses for the
28
+ * six PR/Issue-body gate-read fields — reused for its masking discipline
29
+ * (`maskCode`, imported below) rather than re-implemented, though this is a
30
+ * DIFFERENT field kind from `anchored-region.ts`'s closed `AnchorField`
31
+ * union: `anchoredRegionBounds` parses one of six fixed field names inside a
32
+ * forge body; this marker instead carries a free-form cited-file path
33
+ * inline in its own START tag, inside an arbitrary doc file, so it cannot
34
+ * reuse that function directly without widening a closed, PR-body-specific
35
+ * type for a doc-authoring concern outside this task's surface. The shape:
36
+ *
37
+ * <!-- AEG:QUOTES-FILE:START:<repo-relative-path> -->
38
+ * `<quoted text>`
39
+ * <!-- AEG:QUOTES-FILE:END -->
40
+ *
41
+ * Both markers may sit on their own lines or inline within a prose
42
+ * sentence (the real annotation this task ships wraps an inline backtick
43
+ * span mid-paragraph). Recognition rules mirror `anchored-region.ts`
44
+ * exactly: markers inside a fenced/indented code block or inline code span
45
+ * do not count (an authoring example showing the marker syntax itself,
46
+ * fenced, is never mistaken for a real one); a `START` with no following
47
+ * `END` is not an anchor at all; multiple pairs in one file are scanned
48
+ * left to right, each `START` paired with the next `END` after it.
49
+ *
50
+ * **The quoted text.** The content between the markers, trimmed, with one
51
+ * layer of wrapping stripped: a single-backtick inline span, a fenced block
52
+ * (backtick or tilde), or neither (bare text) — whichever the author used.
53
+ * That literal string is the predicate's subject: it either occurs verbatim
54
+ * in the cited file's content, or it does not. No normalization beyond
55
+ * that — a doc that means to tolerate whitespace/formatting drift is not
56
+ * this check's problem to solve.
57
+ *
58
+ * **The predicate.** Decidable, per the Issue: a quoted command either
59
+ * appears in the file it claims to quote, or it does not. `findCitedQuotes`
60
+ * discovers every marked span in the governed-doc corpus (the same
61
+ * `classifyProseFile` scoping `reader-resolvable-prose.ts` sweeps —
62
+ * `ships`/`reader-facing`, never `internal` — reused rather than a second
63
+ * notion of "governed doc"); `evaluateCitedQuotes` then checks each against
64
+ * the cited file's actual content, which the caller supplies (the bin reads
65
+ * it — this module never touches disk). A finding names both sides: what
66
+ * the doc claims (the quoted text) and which file it cited that no longer
67
+ * (or never did) contain it verbatim.
68
+ */
69
+
70
+ import { maskCode } from '@attalabs/aeg-forge-state/strip-code'
71
+ import { classifyProseFile, type ProseFileClass } from './reader-resolvable-prose'
72
+
73
+ // `maskCode` (index-preserving) is imported directly from the `./strip-code`
74
+ // subpath — the same narrow front door `anchored-region.ts` uses, per that
75
+ // package's own barrel comment ("reachable through the `./strip-code`
76
+ // subpath for the two callers that need them"). Not through
77
+ // `anchored-region.ts`'s re-export: that file's own `anchoredRegionBounds`
78
+ // is scoped to its closed six-field `AnchorField` grammar, which this
79
+ // marker (a free-form cited-file path, not one of those six names) cannot
80
+ // use without widening a PR-body-specific type for a doc-authoring concern
81
+ // outside this task's surface. Reusing `maskCode` itself — rather than
82
+ // writing a second masker — is the actual "do not write a second anchor
83
+ // parser" discipline this module honors.
84
+
85
+ export type QuotedCommandSourceFile = { path: string; content: string }
86
+
87
+ /** One marked span: the doc that carries it, where, what it claims, and which file it cites. */
88
+ export type CitedQuote = {
89
+ file: string
90
+ line: number
91
+ quotedText: string
92
+ citedFile: string
93
+ }
94
+
95
+ export type QuotedCommandFinding = {
96
+ file: string
97
+ line: number
98
+ citedFile: string
99
+ quotedText: string
100
+ message: string
101
+ }
102
+
103
+ /** `aeg-root/**` by default — same default `reader-resolvable-prose.ts` uses, so a caller that doesn't override either stays consistent. */
104
+ const QUOTED_COMMAND_SHIPS_PREFIX = 'aeg-root/'
105
+
106
+ /** The two classes this check sweeps for markers — `internal` never is, matching `reader-resolvable-prose.ts`'s own (identically-valued, differently-named to avoid `symbol-collisions.test.ts`) `SWEPT_CLASSES`. */
107
+ const QUOTED_COMMAND_SWEPT_CLASSES: ReadonlySet<ProseFileClass> = new Set(['ships', 'reader-facing'])
108
+
109
+ const START_PATTERN = /<!--\s*AEG:QUOTES-FILE:START:(\S+?)\s*-->/g
110
+ const END_PATTERN = /<!--\s*AEG:QUOTES-FILE:END\s*-->/
111
+
112
+ /**
113
+ * A marker's `citedFile` must be a plain repo-root-relative path — never
114
+ * absolute, never carrying a `..` traversal segment. Security finding
115
+ * (this check's own PR, round 2): an unvalidated `citedFile` turns this
116
+ * check into a file-content oracle any doc author can drive — a crafted
117
+ * marker naming `../../../../etc/hosts` (or any path outside the repo the
118
+ * check process can reach) gets its content read and compared against
119
+ * attacker-chosen `quotedText`, and the three distinguishable outcomes
120
+ * (silent pass on an exact match, a "no longer contains" finding on a
121
+ * miss, a "could not be read" finding when the target is absent) form a
122
+ * working binary-search oracle over that file's real content — reproduced
123
+ * live, three ways, including a working read of `/etc/hosts`. A path
124
+ * failing this check is not a valid citation at all: the marker is treated
125
+ * exactly like an unterminated START/END pair (silently not an anchor),
126
+ * never reaching the file-read stage, so there is no signal difference
127
+ * between "malformed marker" and "no marker" for an attacker to probe.
128
+ */
129
+ export function isValidCitedFilePath(path: string): boolean {
130
+ if (path.length === 0) return false
131
+ if (path.startsWith('/') || path.startsWith('\\')) return false
132
+ if (/^[A-Za-z]:[\\/]/.test(path)) return false
133
+ return !path.split(/[\\/]+/).includes('..')
134
+ }
135
+
136
+ function quotedCommandLineAt(content: string, index: number): number {
137
+ let line = 1
138
+ for (let i = 0; i < index; i++) {
139
+ if (content.charCodeAt(i) === 10) line++
140
+ }
141
+ return line
142
+ }
143
+
144
+ /**
145
+ * Strips exactly one layer of wrapping from the marker's inner text: a
146
+ * single-backtick inline span, a fenced block (backtick or tilde, with or
147
+ * without an info string), or neither. Returns the trimmed bare text in
148
+ * every case — this is deliberately not CommonMark-general, only the two
149
+ * shapes an author actually writes a quoted command in.
150
+ */
151
+ function extractQuotedText(raw: string): string {
152
+ const trimmed = raw.trim()
153
+ const fenced = /^(?:`{3,}|~{3,})[^\n]*\n([\s\S]*?)\n?(?:`{3,}|~{3,})$/.exec(trimmed)
154
+ if (fenced) return (fenced[1] ?? '').trim()
155
+ const inline = /^`([^`]+)`$/.exec(trimmed)
156
+ if (inline) return (inline[1] ?? '').trim()
157
+ return trimmed
158
+ }
159
+
160
+ /**
161
+ * Scans one file's content for `AEG:QUOTES-FILE` marker pairs. Searches
162
+ * position-only over `stripCode`'s masked-but-length-altering output is
163
+ * unsafe (indices would no longer map back) — so this uses `maskCode`
164
+ * (index-preserving, same primitive `anchored-region.ts` uses) to find
165
+ * marker positions code-blind, then slices the real inner text from the
166
+ * original `content` at those same indices, exactly as `anchoredRegionBounds`
167
+ * does for its own six fields.
168
+ */
169
+ function findQuotesInFile(path: string, content: string): CitedQuote[] {
170
+ const masked = maskCode(content)
171
+ const quotes: CitedQuote[] = []
172
+
173
+ START_PATTERN.lastIndex = 0
174
+ let start: RegExpExecArray | null = START_PATTERN.exec(masked)
175
+ while (start !== null) {
176
+ const citedFile = start[1] as string
177
+ const innerStart = start.index + start[0].length
178
+ const end = END_PATTERN.exec(masked.slice(innerStart))
179
+ if (end === null) {
180
+ // A START with no following END is not an anchor at all (mirrors
181
+ // anchored-region.ts's identical rule) — resume scanning right after
182
+ // this unterminated START rather than treating the rest of the file
183
+ // as consumed by it.
184
+ START_PATTERN.lastIndex = innerStart
185
+ start = START_PATTERN.exec(masked)
186
+ continue
187
+ }
188
+ const innerEnd = innerStart + end.index
189
+ if (isValidCitedFilePath(citedFile)) {
190
+ const quotedText = extractQuotedText(content.slice(innerStart, innerEnd))
191
+ if (quotedText.length > 0) {
192
+ quotes.push({ file: path, line: quotedCommandLineAt(content, innerStart), quotedText, citedFile })
193
+ }
194
+ }
195
+ START_PATTERN.lastIndex = innerEnd + end[0].length
196
+ start = START_PATTERN.exec(masked)
197
+ }
198
+ return quotes
199
+ }
200
+
201
+ /**
202
+ * Every marked span across the governed-doc corpus — `files` scoped to the
203
+ * same `ships`/`reader-facing` classes `reader-resolvable-prose.ts` sweeps
204
+ * (never `internal`), via the identical `classifyProseFile` call, not a
205
+ * second notion of "governed doc". Zero I/O: `files` is read by the caller.
206
+ */
207
+ export function findCitedQuotes(
208
+ files: readonly QuotedCommandSourceFile[],
209
+ readerFacingPrefix: string,
210
+ readerFacingSuffix: string,
211
+ shipsPrefix: string = QUOTED_COMMAND_SHIPS_PREFIX
212
+ ): CitedQuote[] {
213
+ const quotes: CitedQuote[] = []
214
+ for (const file of files) {
215
+ const cls = classifyProseFile(file.path, readerFacingPrefix, readerFacingSuffix, shipsPrefix)
216
+ if (!cls || !QUOTED_COMMAND_SWEPT_CLASSES.has(cls)) continue
217
+ quotes.push(...findQuotesInFile(file.path, file.content))
218
+ }
219
+ return quotes
220
+ }
221
+
222
+ /**
223
+ * The predicate itself: does each cited quote still appear verbatim in the
224
+ * file it claims to quote? `citedFileContents` is supplied by the caller —
225
+ * this function does no I/O and does not care whether a cited path lies
226
+ * inside or outside the governed-doc corpus (a workflow YAML a doc quotes
227
+ * is neither `ships` nor `reader-facing`, and must still be checkable). A
228
+ * cited path absent from `citedFileContents` is itself a finding (the file
229
+ * could not be read/found), not a silent pass — collapsing "unreadable"
230
+ * into "verified" would be the same fail-open class other checks in this
231
+ * package document as a real, reproduced incident.
232
+ */
233
+ export function evaluateCitedQuotes(
234
+ citedQuotes: readonly CitedQuote[],
235
+ citedFileContents: ReadonlyMap<string, string>
236
+ ): QuotedCommandFinding[] {
237
+ const findings: QuotedCommandFinding[] = []
238
+ for (const quote of citedQuotes) {
239
+ const content = citedFileContents.get(quote.citedFile)
240
+ if (content === undefined) {
241
+ findings.push({
242
+ file: quote.file,
243
+ line: quote.line,
244
+ citedFile: quote.citedFile,
245
+ quotedText: quote.quotedText,
246
+ message: `${quote.file}:${quote.line} quotes "${quote.quotedText}" as citing \`${quote.citedFile}\`, but that file could not be read`
247
+ })
248
+ continue
249
+ }
250
+ if (!content.includes(quote.quotedText)) {
251
+ findings.push({
252
+ file: quote.file,
253
+ line: quote.line,
254
+ citedFile: quote.citedFile,
255
+ quotedText: quote.quotedText,
256
+ message: `${quote.file}:${quote.line} quotes "${quote.quotedText}" as citing \`${quote.citedFile}\`, but \`${quote.citedFile}\` no longer contains that text verbatim`
257
+ })
258
+ }
259
+ }
260
+ return findings
261
+ }
262
+
263
+ /** Runs both phases in one call: discover markers, then evaluate them against the supplied cited-file contents. */
264
+ export function checkQuotedCommandStaleness(
265
+ files: readonly QuotedCommandSourceFile[],
266
+ citedFileContents: ReadonlyMap<string, string>,
267
+ readerFacingPrefix: string,
268
+ readerFacingSuffix: string,
269
+ shipsPrefix: string = QUOTED_COMMAND_SHIPS_PREFIX
270
+ ): QuotedCommandFinding[] {
271
+ return evaluateCitedQuotes(
272
+ findCitedQuotes(files, readerFacingPrefix, readerFacingSuffix, shipsPrefix),
273
+ citedFileContents
274
+ )
275
+ }