@arbiterforge/ca-pi 0.6.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 (206) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +558 -0
  3. package/package.json +35 -0
  4. package/plugins/ca-pi/CHANGELOG.md +1030 -0
  5. package/plugins/ca-pi/COMMANDS.md +90 -0
  6. package/plugins/ca-pi/ORCHESTRATOR.md +159 -0
  7. package/plugins/ca-pi/SKILLS.md +47 -0
  8. package/plugins/ca-pi/SPRINT.md +142 -0
  9. package/plugins/ca-pi/agents/INDEX.md +31 -0
  10. package/plugins/ca-pi/agents/architecture-drift-reviewer.md +86 -0
  11. package/plugins/ca-pi/agents/auth-crypto-reviewer.md +60 -0
  12. package/plugins/ca-pi/agents/backend-author.md +60 -0
  13. package/plugins/ca-pi/agents/checkpoint-aggregator.md +111 -0
  14. package/plugins/ca-pi/agents/coverage-auditor.md +71 -0
  15. package/plugins/ca-pi/agents/decision-challenger.md +116 -0
  16. package/plugins/ca-pi/agents/dependency-reviewer.md +79 -0
  17. package/plugins/ca-pi/agents/design-quality-reviewer.md +80 -0
  18. package/plugins/ca-pi/agents/finding-triage.md +86 -0
  19. package/plugins/ca-pi/agents/frontend-author.md +64 -0
  20. package/plugins/ca-pi/agents/grader.md +173 -0
  21. package/plugins/ca-pi/agents/infra-author.md +64 -0
  22. package/plugins/ca-pi/agents/map-deps.md +35 -0
  23. package/plugins/ca-pi/agents/map-structure.md +37 -0
  24. package/plugins/ca-pi/agents/migration-reviewer.md +65 -0
  25. package/plugins/ca-pi/agents/scout.md +127 -0
  26. package/plugins/ca-pi/agents/security-reviewer.md +72 -0
  27. package/plugins/ca-pi/agents/tribunal-lens-reviewer.md +65 -0
  28. package/plugins/ca-pi/extensions/codearbiter-child.js +1885 -0
  29. package/plugins/ca-pi/extensions/codearbiter.js +9802 -0
  30. package/plugins/ca-pi/generated/command-catalog.json +197 -0
  31. package/plugins/ca-pi/generated/roles.json +213 -0
  32. package/plugins/ca-pi/helpers/windows-supervisor.js +205 -0
  33. package/plugins/ca-pi/hooks/_activationlib.py +196 -0
  34. package/plugins/ca-pi/hooks/_arbiterstatelib.py +208 -0
  35. package/plugins/ca-pi/hooks/_babysitlib.py +76 -0
  36. package/plugins/ca-pi/hooks/_bashguardlib.py +1667 -0
  37. package/plugins/ca-pi/hooks/_boxlib.py +131 -0
  38. package/plugins/ca-pi/hooks/_colorlib.py +304 -0
  39. package/plugins/ca-pi/hooks/_durabilitylib.py +186 -0
  40. package/plugins/ca-pi/hooks/_entrylib.py +41 -0
  41. package/plugins/ca-pi/hooks/_fmtlib.py +161 -0
  42. package/plugins/ca-pi/hooks/_gitexec.py +45 -0
  43. package/plugins/ca-pi/hooks/_githooks.py +920 -0
  44. package/plugins/ca-pi/hooks/_gitlib.py +110 -0
  45. package/plugins/ca-pi/hooks/_hooklib.py +595 -0
  46. package/plugins/ca-pi/hooks/_host.py +115 -0
  47. package/plugins/ca-pi/hooks/_intentlib.py +242 -0
  48. package/plugins/ca-pi/hooks/_ledgerlib.py +1035 -0
  49. package/plugins/ca-pi/hooks/_metricslib.py +709 -0
  50. package/plugins/ca-pi/hooks/_pathnorm.py +74 -0
  51. package/plugins/ca-pi/hooks/_planfilelib.py +664 -0
  52. package/plugins/ca-pi/hooks/_previewlib.py +193 -0
  53. package/plugins/ca-pi/hooks/_protectedlib.py +312 -0
  54. package/plugins/ca-pi/hooks/_protectedstatelib.py +411 -0
  55. package/plugins/ca-pi/hooks/_provenancelib.py +971 -0
  56. package/plugins/ca-pi/hooks/_prunelib.py +1398 -0
  57. package/plugins/ca-pi/hooks/_prunepolicy.py +235 -0
  58. package/plugins/ca-pi/hooks/_readinjectlib.py +1080 -0
  59. package/plugins/ca-pi/hooks/_releaselib.py +2657 -0
  60. package/plugins/ca-pi/hooks/_scopelib.py +262 -0
  61. package/plugins/ca-pi/hooks/_segmentslib.py +278 -0
  62. package/plugins/ca-pi/hooks/_sensitivelib.py +270 -0
  63. package/plugins/ca-pi/hooks/_sessionlib.py +78 -0
  64. package/plugins/ca-pi/hooks/_sloplib.py +244 -0
  65. package/plugins/ca-pi/hooks/_standuplib.py +214 -0
  66. package/plugins/ca-pi/hooks/_subagentslib.py +219 -0
  67. package/plugins/ca-pi/hooks/_taskboardlib.py +1088 -0
  68. package/plugins/ca-pi/hooks/_updatelib.py +278 -0
  69. package/plugins/ca-pi/hooks/babysit.py +47 -0
  70. package/plugins/ca-pi/hooks/boardsync.py +129 -0
  71. package/plugins/ca-pi/hooks/doctor.py +420 -0
  72. package/plugins/ca-pi/hooks/git-enforce.py +325 -0
  73. package/plugins/ca-pi/hooks/hostapi.py +460 -0
  74. package/plugins/ca-pi/hooks/init-codearbiter.py +225 -0
  75. package/plugins/ca-pi/hooks/metrics.py +62 -0
  76. package/plugins/ca-pi/hooks/migration-pass.py +129 -0
  77. package/plugins/ca-pi/hooks/pi-bridge.py +543 -0
  78. package/plugins/ca-pi/hooks/post-write-edit.py +231 -0
  79. package/plugins/ca-pi/hooks/pre-bash.py +90 -0
  80. package/plugins/ca-pi/hooks/pre-edit.py +284 -0
  81. package/plugins/ca-pi/hooks/pre-read.py +81 -0
  82. package/plugins/ca-pi/hooks/pre-write.py +217 -0
  83. package/plugins/ca-pi/hooks/preview.py +69 -0
  84. package/plugins/ca-pi/hooks/prune-transcript.py +232 -0
  85. package/plugins/ca-pi/hooks/releasehash.py +216 -0
  86. package/plugins/ca-pi/hooks/security-pass.py +139 -0
  87. package/plugins/ca-pi/hooks/session-start.py +1218 -0
  88. package/plugins/ca-pi/hooks/statusline.py +736 -0
  89. package/plugins/ca-pi/hooks/taskwrite.py +351 -0
  90. package/plugins/ca-pi/hooks/update-refresh.py +51 -0
  91. package/plugins/ca-pi/hooks/wire-statusline.py +435 -0
  92. package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +55 -0
  93. package/plugins/ca-pi/includes/anti-slop-design/color.md +43 -0
  94. package/plugins/ca-pi/includes/anti-slop-design/core.md +244 -0
  95. package/plugins/ca-pi/includes/anti-slop-design/images.md +32 -0
  96. package/plugins/ca-pi/includes/anti-slop-design/layout.md +45 -0
  97. package/plugins/ca-pi/includes/anti-slop-design/medium-cli.md +39 -0
  98. package/plugins/ca-pi/includes/anti-slop-design/medium-dataviz.md +43 -0
  99. package/plugins/ca-pi/includes/anti-slop-design/medium-diagram.md +35 -0
  100. package/plugins/ca-pi/includes/anti-slop-design/medium-documents.md +70 -0
  101. package/plugins/ca-pi/includes/anti-slop-design/medium-slides.md +30 -0
  102. package/plugins/ca-pi/includes/anti-slop-design/medium-web.md +39 -0
  103. package/plugins/ca-pi/includes/anti-slop-design/typography.md +51 -0
  104. package/plugins/ca-pi/includes/author-tdd-workflow.md +14 -0
  105. package/plugins/ca-pi/includes/compaction-charter.md +16 -0
  106. package/plugins/ca-pi/includes/cut-docs.md +16 -0
  107. package/plugins/ca-pi/includes/dev-mode.md +30 -0
  108. package/plugins/ca-pi/includes/farm.md +237 -0
  109. package/plugins/ca-pi/includes/fresh-verification.md +14 -0
  110. package/plugins/ca-pi/includes/harvest.md +69 -0
  111. package/plugins/ca-pi/includes/maturity-coverage.md +102 -0
  112. package/plugins/ca-pi/includes/pi-host-notes.md +69 -0
  113. package/plugins/ca-pi/includes/redirect.md +69 -0
  114. package/plugins/ca-pi/includes/reference-map.md +22 -0
  115. package/plugins/ca-pi/includes/review-matrix.md +14 -0
  116. package/plugins/ca-pi/includes/reviewer-contract.md +53 -0
  117. package/plugins/ca-pi/includes/routing-table.md +47 -0
  118. package/plugins/ca-pi/includes/security-gate-record.md +22 -0
  119. package/plugins/ca-pi/includes/smarts/core.md +90 -0
  120. package/plugins/ca-pi/includes/smarts/decision-log-format.md +56 -0
  121. package/plugins/ca-pi/routines/INDEX.md +32 -0
  122. package/plugins/ca-pi/routines/brainstorming/SKILL.md +122 -0
  123. package/plugins/ca-pi/routines/commit-gate/SKILL.md +151 -0
  124. package/plugins/ca-pi/routines/context-check/SKILL.md +85 -0
  125. package/plugins/ca-pi/routines/context-creation/SKILL.md +171 -0
  126. package/plugins/ca-pi/routines/crypto-compliance/SKILL.md +41 -0
  127. package/plugins/ca-pi/routines/debug/SKILL.md +99 -0
  128. package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +104 -0
  129. package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +74 -0
  130. package/plugins/ca-pi/routines/decision-variance/SKILL.md +147 -0
  131. package/plugins/ca-pi/routines/decompose/SKILL.md +168 -0
  132. package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +76 -0
  133. package/plugins/ca-pi/routines/executing-plans/SKILL.md +83 -0
  134. package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +91 -0
  135. package/plugins/ca-pi/routines/post-merge-cleanup/SKILL.md +233 -0
  136. package/plugins/ca-pi/routines/refactor/SKILL.md +91 -0
  137. package/plugins/ca-pi/routines/release/SKILL.md +315 -0
  138. package/plugins/ca-pi/routines/secret-handling/SKILL.md +67 -0
  139. package/plugins/ca-pi/routines/security-architecture/SKILL.md +63 -0
  140. package/plugins/ca-pi/routines/skill-author/SKILL.md +108 -0
  141. package/plugins/ca-pi/routines/skill-author/references/skill-template.md +58 -0
  142. package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +149 -0
  143. package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +145 -0
  144. package/plugins/ca-pi/routines/tdd/SKILL.md +139 -0
  145. package/plugins/ca-pi/routines/tribunal/SKILL.md +109 -0
  146. package/plugins/ca-pi/routines/tribunal/references/ai-markers.md +29 -0
  147. package/plugins/ca-pi/routines/tribunal/references/cost-and-models.md +64 -0
  148. package/plugins/ca-pi/routines/tribunal/references/finding-record.md +27 -0
  149. package/plugins/ca-pi/routines/tribunal/references/issue-filing.md +47 -0
  150. package/plugins/ca-pi/routines/tribunal/references/lenses/appsec.md +22 -0
  151. package/plugins/ca-pi/routines/tribunal/references/lenses/architecture.md +23 -0
  152. package/plugins/ca-pi/routines/tribunal/references/lenses/coverage.md +20 -0
  153. package/plugins/ca-pi/routines/tribunal/references/lenses/infra.md +24 -0
  154. package/plugins/ca-pi/routines/tribunal/references/lenses/migration.md +22 -0
  155. package/plugins/ca-pi/routines/tribunal/references/lenses/observability.md +21 -0
  156. package/plugins/ca-pi/routines/tribunal/references/lenses/performance.md +22 -0
  157. package/plugins/ca-pi/routines/tribunal/references/lenses/reliability.md +23 -0
  158. package/plugins/ca-pi/routines/tribunal/references/lenses/secrets-supply.md +22 -0
  159. package/plugins/ca-pi/routines/tribunal/references/lenses/test-fidelity.md +24 -0
  160. package/plugins/ca-pi/routines/tribunal/references/lenses/typesafety.md +21 -0
  161. package/plugins/ca-pi/routines/tribunal/references/report.md +19 -0
  162. package/plugins/ca-pi/routines/tribunal/references/schemas.md +58 -0
  163. package/plugins/ca-pi/routines/tribunal/references/telemetry.md +28 -0
  164. package/plugins/ca-pi/routines/tribunal/references/triage.md +53 -0
  165. package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +85 -0
  166. package/plugins/ca-pi/routines/writing-plans/SKILL.md +129 -0
  167. package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +50 -0
  168. package/plugins/ca-pi/skills/ca-add-dep/SKILL.md +88 -0
  169. package/plugins/ca-pi/skills/ca-adr/SKILL.md +30 -0
  170. package/plugins/ca-pi/skills/ca-adr-status/SKILL.md +30 -0
  171. package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +36 -0
  172. package/plugins/ca-pi/skills/ca-audit/SKILL.md +51 -0
  173. package/plugins/ca-pi/skills/ca-btw/SKILL.md +23 -0
  174. package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +50 -0
  175. package/plugins/ca-pi/skills/ca-chore/SKILL.md +58 -0
  176. package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +55 -0
  177. package/plugins/ca-pi/skills/ca-commands/SKILL.md +21 -0
  178. package/plugins/ca-pi/skills/ca-commit/SKILL.md +27 -0
  179. package/plugins/ca-pi/skills/ca-conflict/SKILL.md +61 -0
  180. package/plugins/ca-pi/skills/ca-context-check/SKILL.md +32 -0
  181. package/plugins/ca-pi/skills/ca-create-context/SKILL.md +32 -0
  182. package/plugins/ca-pi/skills/ca-debug/SKILL.md +42 -0
  183. package/plugins/ca-pi/skills/ca-decompose/SKILL.md +30 -0
  184. package/plugins/ca-pi/skills/ca-dev/SKILL.md +42 -0
  185. package/plugins/ca-pi/skills/ca-doctor/SKILL.md +44 -0
  186. package/plugins/ca-pi/skills/ca-feature/SKILL.md +105 -0
  187. package/plugins/ca-pi/skills/ca-fix/SKILL.md +42 -0
  188. package/plugins/ca-pi/skills/ca-init/SKILL.md +56 -0
  189. package/plugins/ca-pi/skills/ca-metrics/SKILL.md +80 -0
  190. package/plugins/ca-pi/skills/ca-new-skill/SKILL.md +34 -0
  191. package/plugins/ca-pi/skills/ca-override/SKILL.md +72 -0
  192. package/plugins/ca-pi/skills/ca-pr/SKILL.md +61 -0
  193. package/plugins/ca-pi/skills/ca-preview/SKILL.md +86 -0
  194. package/plugins/ca-pi/skills/ca-prune/SKILL.md +100 -0
  195. package/plugins/ca-pi/skills/ca-reconcile/SKILL.md +43 -0
  196. package/plugins/ca-pi/skills/ca-refactor/SKILL.md +43 -0
  197. package/plugins/ca-pi/skills/ca-release/SKILL.md +57 -0
  198. package/plugins/ca-pi/skills/ca-review/SKILL.md +63 -0
  199. package/plugins/ca-pi/skills/ca-spike/SKILL.md +41 -0
  200. package/plugins/ca-pi/skills/ca-sprint/SKILL.md +44 -0
  201. package/plugins/ca-pi/skills/ca-standup/SKILL.md +112 -0
  202. package/plugins/ca-pi/skills/ca-status/SKILL.md +56 -0
  203. package/plugins/ca-pi/skills/ca-task/SKILL.md +61 -0
  204. package/plugins/ca-pi/skills/ca-threat-model/SKILL.md +45 -0
  205. package/plugins/ca-pi/skills/ca-tribunal/SKILL.md +44 -0
  206. package/plugins/ca-pi/skills/ca-watch/SKILL.md +78 -0
@@ -0,0 +1,270 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter - the H-09b/H-10b sensitive-line scan: crypto/TLS and secret
3
+ # detection, the pinned security diff argv, the path-aware diff walk, and the
4
+ # digests that bind a recorded gate pass to the exact lines it reviewed.
5
+ #
6
+ # Extracted from _hooklib (issue #321, architecture-002) as the first slice.
7
+ # Chosen first because it was the cleanest seam in the module, measured rather
8
+ # than guessed: the cluster referenced exactly ONE symbol from the rest of
9
+ # _hooklib (norm_path, now the _pathnorm floor), and NOTHING in the rest of
10
+ # _hooklib referenced the cluster. A one-way edge with no back-reference is
11
+ # what makes this safe to move without touching a single consumer.
12
+ #
13
+ # _hooklib re-exports every name below, so all 59 consuming files are
14
+ # unchanged and the pre-existing hook suites prove behavioural parity - which
15
+ # is the only proof that means anything for a refactor (the `refactor` skill's
16
+ # Phase 2 rule: parity comes from tests that did not move).
17
+ #
18
+ # WHY THESE BELONG TOGETHER: they are one concern with one failure mode. The
19
+ # regexes decide what counts as sensitive, the pinned argv decides what the
20
+ # diff even looks like, the walk decides which FILE a line belongs to, and the
21
+ # digests decide what a pass covered. #279 showed they fail as a unit: a
22
+ # greedy header parse in the walk un-exempted an unrelated file, and an
23
+ # unpinned prefix config silently re-opened it. Splitting them across modules
24
+ # would let one move without the others.
25
+
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ import re
30
+
31
+ from _pathnorm import norm_path
32
+
33
+
34
+ # Crypto/TLS and secret patterns — shared by the post-write reminder (H-09/H-10)
35
+ # and the blocking pre-commit gate (H-09b/H-10b) so the two never drift.
36
+ # Deliberately NOT matched: crypto.randomUUID / crypto.getRandomValues (benign
37
+ # ID generation tripped the gate on routine commits) — the bare `crypto\.`
38
+ # catch-all is narrowed to the members that actually sign, encrypt, derive
39
+ # keys, or produce security-relevant randomness. bcrypt stays: approved or
40
+ # not, a password-hashing change is exactly what crypto-compliance reviews.
41
+ CRYPTO_RE = re.compile(
42
+ r"(createHash|createCipher|createHmac|\bmd5\b|\bsha1\b|\brc4\b|\bdes\b|3des"
43
+ r"|\brc2\b|\bblowfish\b"
44
+ r"|\bRSA\b|x509|bcrypt"
45
+ r"|crypto\.(subtle|sign|verify|createSign|createVerify|generateKey"
46
+ r"|publicEncrypt|privateDecrypt|pbkdf2|scrypt|randomBytes|createDiffieHellman)"
47
+ r"|InsecureSkipVerify|verify=False"
48
+ # Node/TS TLS-disable forms — all networked first-party code here is TS, so
49
+ # this is where a verification bypass would actually land (2026-06-22 HIGH).
50
+ r"|rejectUnauthorized\s*[:=]\s*false|NODE_TLS_REJECT_UNAUTHORIZED)",
51
+ re.I,
52
+ )
53
+ # Two branches: (1) a secret keyword assigned a quoted literal, via `=` OR `:`
54
+ # (the colon/object form dominates this TS/JSON repo) — the quoted-value
55
+ # requirement keeps it from firing on every bare `token:` reference; (2) known
56
+ # high-entropy key prefixes, keyword-independent (AWS / GitHub / Anthropic).
57
+ # No LEADING word boundary on the keyword group (secrets-002): a `\b` there
58
+ # never fires when the keyword is the trailing segment of a compound identifier
59
+ # (the char left of `api_key` in `FARM_API_KEY` is `_`, a word char), so a
60
+ # hardcoded `FARM_API_KEY = "..."` silently passed the gate. The right-hand
61
+ # quoted-assignment anchor still bounds the match — a bare `token:` reference
62
+ # without a quoted value never matches.
63
+ SECRET_RE = re.compile(
64
+ r"(?:password|secret|token|api_key|apikey|private_key|passphrase|credential"
65
+ r"|aws_secret_access_key|client_secret)"
66
+ r"""["']?\s*[:=]\s*["'][^"']{4,}"""
67
+ r"|AKIA[0-9A-Z]{16}"
68
+ r"|ghp_[A-Za-z0-9]{36}"
69
+ r"|sk-ant-[A-Za-z0-9_-]{16,}",
70
+ re.I,
71
+ )
72
+
73
+
74
+ # Sensitive-scan exemption (H-09b/H-10b, #279). gate-events.log is the durable
75
+ # BLOCK/REMIND/WARN sink block()/remind()/warn() append to (observability-001,
76
+ # #186) — it is machine-written, never source code, and structurally
77
+ # guaranteed to echo the crypto/secret detector's OWN message text back at
78
+ # itself the moment the gate ever fires a crypto/secret REMIND (e.g. "Crypto/
79
+ # TLS pattern detected" itself matches CRYPTO_RE). That makes it a permanent,
80
+ # self-perpetuating false positive with zero disclosure value: nothing
81
+ # written there is a genuine crypto/secret USE, only a report ABOUT one.
82
+ # Deliberately narrow — overrides.log/triage.log/sprint-log.md stay IN SCOPE:
83
+ # they carry human-written prose (an override reason, a triage note) that
84
+ # COULD legitimately contain a leaked secret worth catching. This set is
85
+ # anchored on the REPO-RELATIVE PATH a line belongs to, never a substring
86
+ # match on the line's own text — a secret cannot escape the scan by merely
87
+ # mentioning gate-events.log on its line; only lines that actually LIVE in
88
+ # that file are exempt.
89
+ SENSITIVE_SCAN_EXEMPT_RELPATHS = frozenset({".codearbiter/gate-events.log"})
90
+
91
+
92
+ def is_sensitive_scan_exempt(rel):
93
+ """True iff `rel` (a repo-relative path, as attributed by `diff_added_
94
+ lines` or as returned by a bare `git ls-files` listing) names a file
95
+ exempt from the H-09b/H-10b crypto/secret scan. The ONE predicate both
96
+ the diff walk and the untracked/unborn-branch file listings route
97
+ through (#279 review LOW) — deliberately strict: no case-folding, no
98
+ `./`/`//` collapsing. An identifier that isn't an exact, `norm_path`'d
99
+ match resolves toward IN SCOPE (not exempt), which is the safe
100
+ direction. See SENSITIVE_SCAN_EXEMPT_RELPATHS."""
101
+ return norm_path(rel) in SENSITIVE_SCAN_EXEMPT_RELPATHS
102
+
103
+
104
+ # Every H-09b/H-10b sensitive-line reader (security-pass.py, pre-bash.py,
105
+ # git-enforce.py) MUST run `git diff` through this pinned argv, never a bare
106
+ # `["diff", ...]` (#279 review MEDIUM-1). Two independent user/global git
107
+ # config knobs change the destination-path prefix `diff_added_lines` below
108
+ # depends on: `diff.mnemonicPrefix=true` emits `c/`/`w/`/`i/`/`o/` instead of
109
+ # `a/`/`b/`, and `diff.noprefix=true` emits no prefix at all. Either one, left
110
+ # unpinned, silently un-exempts the REAL gate-events.log for any dev/CI runner
111
+ # with that config set — bringing back the exact self-DoS this whole change
112
+ # exists to close. `-c` overrides win over any config file (including repo,
113
+ # global, and system config), so pinning both flags to false here forces the
114
+ # standard `a/`/`b/` prefixes regardless of the caller's environment.
115
+ # `--no-ext-diff` additionally blocks a configured `GIT_EXTERNAL_DIFF` /
116
+ # `diff.external` from replacing git's own unified-diff output with something
117
+ # this parser was never designed to read. Centralized so a call site cannot
118
+ # forget to pin it (that was exactly how this hole would keep reopening).
119
+ SECURITY_DIFF_GIT_ARGS = (
120
+ "-c", "diff.mnemonicPrefix=false", "-c", "diff.noprefix=false",
121
+ "diff", "--no-ext-diff", "--src-prefix=a/", "--dst-prefix=b/",
122
+ )
123
+
124
+ # The fixed-width destination-path prefix `diff_added_lines` strips off a
125
+ # preamble `+++ ` line. MUST be a fixed-length slice, never a search for a
126
+ # separator: a greedy/`.+`-based parse of "diff --git a/<path> b/<path>" (the
127
+ # prior approach) resolves ambiguously when <path> itself contains " b/" —
128
+ # e.g. a real repo path `x b/.codearbiter/gate-events.log` renders that
129
+ # header as `diff --git a/x b/.codearbiter/gate-events.log b/x
130
+ # b/.codearbiter/gate-events.log`, and a greedy match backtracks group(1) to
131
+ # `.codearbiter/gate-events.log`, exempting the WHOLE unrelated source file
132
+ # (#279 review HIGH — reproduced end-to-end: an md5() call and a committed
133
+ # password both passed H-09b with no marker). Stripping a FIXED 6-character
134
+ # prefix has no such ambiguity: `"+++ b/x b/.codearbiter/gate-events.log"[6:]`
135
+ # is unconditionally `"x b/.codearbiter/gate-events.log"`, the correct full
136
+ # path, no matter what the path itself contains.
137
+ _PLUS_B_PREFIX = "+++ b/"
138
+ _PLUS_DEV_NULL = "+++ /dev/null"
139
+
140
+
141
+
142
+
143
+ def diff_added_lines(diff_text):
144
+ """Added (`+`) lines of a unified `git diff`-style text (produced via
145
+ SECURITY_DIFF_GIT_ARGS — pinned `a/`/`b/` prefixes, no external diff), as
146
+ `(path, line)` tuples — a PATH-AWARE walk so a caller can exclude lines by
147
+ the FILE they live in, never by matching the line's own text (which a
148
+ hidden secret could otherwise dodge by naming the excluded file).
149
+
150
+ Two-phase state machine per file section, `path`/`in_hunk`/`seen_section`:
151
+
152
+ 1. A bare, UNPREFIXED `diff ` line (`diff --git`, `diff --cc`, `diff
153
+ --combined`, ...) starts a new section: `path` resets to `None` and
154
+ `in_hunk` resets to False. Content can NEVER forge this line at column
155
+ 0 — every diff-body line (context/added/removed) carries a leading
156
+ ' '/'+'/'-'/'\\' character, so a file whose own content is literally
157
+ "diff --git a/x b/y" renders as "+diff --git a/x b/y" (added), never as
158
+ a bare match. Resetting `path` to None (not inheriting the PREVIOUS
159
+ section's path) on ANY `diff ` spelling matters for combined/merge
160
+ diffs (#279 review MEDIUM-2): git-enforce.py's `git diff --cached` at a
161
+ merge commit emits `diff --cc <path>` sections, which the prior
162
+ `diff --git`-only reset missed, letting a `--cc` section's added lines
163
+ silently inherit whatever path the section before it had — failing
164
+ toward EXEMPTION if that was gate-events.log.
165
+ 2. While NOT yet `in_hunk` (the section's preamble, before its first `@@`
166
+ / `@@@` hunk header), a `+++ b/<path>` line sets `path` by stripping
167
+ the FIXED 6-character prefix `_PLUS_B_PREFIX` — never a regex search
168
+ for a separator (see `_PLUS_B_PREFIX`'s comment for the ambiguity a
169
+ greedy `diff --git` parse had). `+++ /dev/null` (a deleted
170
+ destination) sets `path` back to None explicitly — no added lines are
171
+ expected in a deletion's hunk body anyway. Every other preamble line
172
+ (`--- a/<path>`, `index ...`, `new/deleted file mode`, `rename
173
+ from/to`, `similarity index`, `Binary files ... differ`) is inert
174
+ noise. A `+++ b/...`-shaped line can ONLY be trusted here, before the
175
+ section's first `@@`: once `in_hunk` is True, an apparently identical
176
+ `+++ b/...` string is body CONTENT (it carries the hunk body's own
177
+ leading `+`, i.e. the underlying source line was "++ b/..." or "+++
178
+ b/..." before diff-prefixing) and is captured as an added line like any
179
+ other, never re-parsed as an attribution header (closes the #279
180
+ review's own earlier finding: an added content line forging `+++
181
+ b/<path>` used to hijack attribution for the rest of the file).
182
+ 3. Once `in_hunk`, `+`-prefixed lines are collected as `(path, content)`;
183
+ `-`/` `/`\\` (no-newline-marker) lines are skipped; anything else ends
184
+ the hunk (not producible by well-formed `git diff` output there, but
185
+ handled rather than guessed at).
186
+
187
+ FAILS SAFE: a `+` line seen before ANY `diff ` section header at all (not
188
+ producible by real `git diff` output) is attributed to `path=None` and
189
+ STILL COLLECTED, never silently dropped. `sensitive_scan_added_lines`
190
+ treats an unattributed (`None`) path as NOT exempt — in scope for
191
+ scanning. Exempting, or discarding, an unattributable line would be the
192
+ dangerous direction; over-scanning only risks a false positive, the
193
+ harmless failure mode here.
194
+
195
+ The shared primitive behind the H-09b/H-10b crypto/secret gate's producer
196
+ (security-pass.py) and both consumers (pre-bash.py, git-enforce.py) —
197
+ implemented once here so the gate-events.log exemption can never drift
198
+ between the three independent line-collectors that used to each do their
199
+ own flat `[ln[1:] for ln in text.splitlines() if ln.startswith("+") ...]`
200
+ walk with no path information (and no forgery-resistance) at all."""
201
+ path = None
202
+ in_hunk = False
203
+ seen_section = False # True once the first `diff ` section header is seen
204
+ out = []
205
+ for line in diff_text.splitlines():
206
+ if line.startswith("diff "):
207
+ path = None
208
+ in_hunk = False
209
+ seen_section = True
210
+ continue
211
+ if line.startswith("@@"):
212
+ in_hunk = True
213
+ continue
214
+ if not in_hunk:
215
+ if not seen_section:
216
+ # No `diff ` section header seen yet at all: not producible
217
+ # by real `git diff` output. Fail SAFE (see docstring) rather
218
+ # than silently dropping a line that cannot be confidently
219
+ # classed as header noise either.
220
+ if line.startswith("+"):
221
+ out.append((None, line[1:]))
222
+ continue
223
+ # Section preamble: only a genuine `+++ b/<path>` (or `+++
224
+ # /dev/null`) line here can set `path` — see point 2 above.
225
+ if line.startswith(_PLUS_B_PREFIX):
226
+ path = line[len(_PLUS_B_PREFIX):]
227
+ elif line == _PLUS_DEV_NULL:
228
+ path = None
229
+ continue
230
+ if line.startswith("+"):
231
+ out.append((path, line[1:]))
232
+ elif line.startswith(("-", " ", "\\")):
233
+ pass # removed / context / ""
234
+ else:
235
+ # Not producible by well-formed `git diff` output (a hunk body
236
+ # line is always one of the four prefixes above); treat it as the
237
+ # hunk having ended rather than guessing.
238
+ in_hunk = False
239
+ return out
240
+
241
+
242
+ def sensitive_scan_added_lines(diff_text):
243
+ """`diff_added_lines(diff_text)` narrowed to the H-09b/H-10b crypto/secret
244
+ scan's candidate set: every added line EXCEPT those belonging to a
245
+ sensitive-scan-exempt path (currently only gate-events.log). Call this
246
+ instead of a raw `+`-line filter anywhere the crypto/secret scan reads a
247
+ diff, so the exemption is applied uniformly."""
248
+ return [ln for path, ln in diff_added_lines(diff_text)
249
+ if not (path and is_sensitive_scan_exempt(path))]
250
+
251
+
252
+ def line_digest(line):
253
+ """Digest of one added diff line, for the security-gate binding
254
+ (H-09b/H-10b). The gate-pass marker stores these digests instead of being
255
+ an empty `touch`d file, so a recorded pass admits only the exact sensitive
256
+ lines it reviewed — not whatever lands in the next 30 minutes. Trailing
257
+ whitespace is stripped so CRLF translation between worktree and index
258
+ never breaks the match."""
259
+ return hashlib.sha256(line.rstrip().encode("utf-8", "replace")).hexdigest()
260
+
261
+
262
+ def content_digest(text):
263
+ """Digest of a whole migration file's content, for the H-14 migration-gate
264
+ binding. Lines are rstripped and rejoined with \\n so CRLF translation
265
+ between worktree and index never breaks the match (same rationale as
266
+ line_digest). The producer (migration-pass.py) and the backstop
267
+ (pre-bash.py) both digest worktree content this way, so the two never
268
+ disagree on what a recorded pass covers."""
269
+ norm = "\n".join(line.rstrip() for line in text.splitlines())
270
+ return hashlib.sha256(norm.encode("utf-8", "replace")).hexdigest()
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter — statusline true-session-start resolver (extracted from
3
+ # statusline.py, architecture-004).
4
+ #
5
+ # Owns resolving the wall-clock session start from Claude Code's own session
6
+ # metadata (~/.claude/sessions/<pid>.json, matched on sessionId) — the same start
7
+ # time /usage reports, including idle/suspend gaps the current transcript alone
8
+ # can't show. The result is cached into the caller's ledger record so the O(N)
9
+ # directory scan runs at most once per session.
10
+ #
11
+ # Design principles (mirroring _ledgerlib.py):
12
+ # - Stdlib only; no third-party imports ever.
13
+ # - Zero side effects at import time.
14
+ # - Never raise on malformed input — degrade to None.
15
+ #
16
+ # Public API:
17
+ # session_start(sid, rec=None) -> float|None
18
+
19
+ import json
20
+ import os
21
+
22
+
23
+ def num(x, default=0.0):
24
+ """Coerce any host value to float; tolerate strings, None, and containers."""
25
+ try:
26
+ return float(x)
27
+ except (TypeError, ValueError):
28
+ return default
29
+
30
+
31
+ def _session_start_scan(sid):
32
+ """The O(N) fallback: scan ~/.claude/sessions/*.json for the one whose
33
+ sessionId matches `sid` and read its startedAt. The metadata file is named by
34
+ the host PID, not the sessionId, so a direct name lookup isn't possible from
35
+ here — a match-on-content scan is the only correct resolver. The caller caches
36
+ the result in the ledger so this scan runs at most once per session."""
37
+ d = os.path.join(os.path.expanduser("~"), ".claude", "sessions")
38
+ try:
39
+ names = os.listdir(d)
40
+ except OSError:
41
+ return None
42
+ for nm in names:
43
+ if not nm.endswith(".json"):
44
+ continue
45
+ fp = os.path.join(d, nm)
46
+ try:
47
+ if os.path.getsize(fp) > 65536: # metadata is <1KB; never read a large file
48
+ continue
49
+ with open(fp, encoding="utf-8") as f:
50
+ meta = json.load(f)
51
+ except (OSError, ValueError):
52
+ continue
53
+ if isinstance(meta, dict) and meta.get("sessionId") == sid:
54
+ sa = num(meta.get("startedAt"), None)
55
+ if sa:
56
+ return sa / 1000.0 if sa > 1e12 else sa # ms epoch -> seconds
57
+ return None
58
+
59
+
60
+ def session_start(sid, rec=None):
61
+ """True session start (epoch seconds) from Claude Code's own session metadata
62
+ (~/.claude/sessions/<pid>.json, matched on sessionId). This is the wall-clock
63
+ start /usage reports, INCLUDING idle/suspend gaps the current transcript can't
64
+ show. None if unavailable -> caller falls back to the transcript.
65
+
66
+ Fast path: the resolved value is cached in the ledger record (`rec["sess_start"]`),
67
+ which ledger_update persists, so subsequent renders skip the per-render directory
68
+ scan entirely. On a cache miss the full scan runs once and seeds the cache."""
69
+ if not sid:
70
+ return None
71
+ if isinstance(rec, dict):
72
+ cached = num(rec.get("sess_start"), None)
73
+ if cached:
74
+ return cached
75
+ sa = _session_start_scan(sid)
76
+ if sa and isinstance(rec, dict):
77
+ rec["sess_start"] = sa # seed the ledger cache; ledger_update persists it
78
+ return sa
@@ -0,0 +1,244 @@
1
+ # codeArbiter v2 — anti-slop copy-law detector (advisory).
2
+ #
3
+ # A lightweight guard for the single highest-signal AI tell: the em-dash / en-dash
4
+ # used as a PROSE sentence-separator (anti-slop-design core §3.A). It backs the
5
+ # PostToolUse reminder in post-write-edit.py and is the mechanical aid #60 asks
6
+ # for, so the PR #59 regression class (user-facing docs shipping with separator
7
+ # dashes) cannot recur silently.
8
+ #
9
+ # This is a heuristic, not a parser, and it is advisory — it nudges the producer
10
+ # to run the §3.A/§3.B copy self-audit; it never blocks. It honors the §3.A
11
+ # exemptions it can detect cheaply (fenced/inline code, URLs, numeric/date ranges)
12
+ # and errs toward silence on the rest.
13
+ #
14
+ # Public API:
15
+ # find_prose_separator_dashes(text) -> list[dict] findings per line; each
16
+ # {"line": int, "context": str}; empty when clean
17
+ # in_antislop_doc_scope(rel_path) -> bool True for user-facing Markdown the
18
+ # anti-slop bundle governs (repo-root + docs/**)
19
+
20
+ import re
21
+
22
+ EM_DASH = "—"
23
+ EN_DASH = "–"
24
+ _DASHES = (EM_DASH, EN_DASH)
25
+
26
+ _FENCE_RE = re.compile(r"^\s*(```|~~~)")
27
+ _INLINE_CODE_RE = re.compile(r"`[^`]*`")
28
+ # Strip URLs, autolinks/HTML tags and comments, and markdown link targets so a
29
+ # dash inside any of them is never read as prose.
30
+ _URL_RE = re.compile(r"https?://\S+|<[^>]*>|\]\([^)]*\)")
31
+ # Numeric / date range: a dash flanked by digits (pp. 12–18, 2019–2024). Correct
32
+ # typography per §3.A, never a finding.
33
+ _RANGE_RE = re.compile(r"\d\s*[–—]\s*\d")
34
+ # A letter or digit (Unicode), used to confirm a dash actually joins two text
35
+ # spans rather than standing alone (e.g. a lone "—" N/A marker in a table cell).
36
+ _WORD_RE = re.compile(r"[^\W_]", re.UNICODE)
37
+ # A DEFINITION-LIST dash: `- **term** — meaning`. Structural, not a sentence
38
+ # separator, and the rule this detector enforces says "sentence separators" -
39
+ # site/VOICE.md's own Terminology anchors section is written in exactly this
40
+ # form, so flagging it would make the gate contradict the style guide it exists
41
+ # to enforce (#338). Anchored to the start of the line and to a bolded lead-in,
42
+ # so an ordinary sentence that happens to contain bold text is untouched.
43
+ #
44
+ # The TERM is captured and kept; only the dash is dropped. Blanking the whole
45
+ # lead-in was the first cut and it was a false-negative generator: on
46
+ # - **The gate-enforcement hooks** — `a.py`, `b.py` — make zero calls.
47
+ # it removed the left-hand words, then _INLINE_CODE_RE blanked the backticks,
48
+ # and the SECOND dash - a real separator - was left with no word character on
49
+ # its left and escaped. That loosened the shared detector for docs/** and
50
+ # repo-root too, not only for site prose.
51
+ _DEFINITION_RE = re.compile(r"^(\s*(?:[-*+]\s+)?\*\*[^*]+\*\*\s*)[–—]")
52
+
53
+
54
+ def _prose_only(line):
55
+ """Drop the spans §3.A exempts so only candidate prose remains."""
56
+ # Only the definition DASH is dropped; the term is kept (group 1), so a
57
+ # later separator on the same line still has its left-hand context.
58
+ line = _DEFINITION_RE.sub(r"\1 ", line)
59
+ line = _INLINE_CODE_RE.sub(" ", line)
60
+ line = _URL_RE.sub(" ", line)
61
+ line = _RANGE_RE.sub(" ", line)
62
+ return line
63
+
64
+
65
+ def _segment_separates(seg):
66
+ """True if `seg` contains an em/en dash with word characters on BOTH sides —
67
+ i.e. it joins two text spans (a prose separator), not a lone filler dash."""
68
+ return _separating_dash_in(seg)
69
+
70
+
71
+ # Block openers: a line matching any of these starts a new markdown block, so
72
+ # the line before it is NOT soft-wrapped into it (#484 AC-2). Joining across one
73
+ # of these would invent findings a reader never sees as a single sentence.
74
+ _HEADING_RE = re.compile(r"^\s{0,3}#{1,6}\s")
75
+ _LIST_ITEM_RE = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s")
76
+ _BLOCKQUOTE_RE = re.compile(r"^\s*>")
77
+ _THEMATIC_RE = re.compile(r"^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$")
78
+ # A range split BY the wrap: `12–` / `18`. _RANGE_RE only sees one line, so the
79
+ # same-line exemption cannot reach it and it has to be re-checked on the join.
80
+ _RANGE_LEFT_RE = re.compile(r"\d\s*$")
81
+ _RANGE_RIGHT_RE = re.compile(r"^\s*\d")
82
+
83
+
84
+ def _starts_new_block(raw):
85
+ """True if `raw` opens a new markdown block rather than continuing the
86
+ previous line's paragraph. A `|` anywhere makes a line un-joinable: table
87
+ rows are scanned cell-by-cell, and joining one to its neighbour would give a
88
+ lone N/A marker the word context the cell split exists to deny it."""
89
+ return bool(not raw.strip()
90
+ or "|" in raw
91
+ or _FENCE_RE.match(raw)
92
+ or _HEADING_RE.match(raw)
93
+ or _LIST_ITEM_RE.match(raw)
94
+ or _THEMATIC_RE.match(raw)
95
+ or _BLOCKQUOTE_RE.match(raw))
96
+
97
+
98
+ def _separating_dash_in(head, *, words_before=False, words_after=False,
99
+ tail_before="", head_after=""):
100
+ """True if a dash INSIDE `head` joins two text spans, where its left context
101
+ may reach back into the preceding lines of its paragraph and its right
102
+ context forward into the following ones.
103
+
104
+ #484: `_segment_separates` required word characters on both sides of the
105
+ dash on the SAME line, so a separator at a soft-wrap boundary scored zero —
106
+ in both directions. `…three states —` / `listed below.` has its right-hand
107
+ span on the next line; `A tribunal is the heavyweight audit` / `— checkpoints
108
+ are the lean sweep.` has its left-hand span on the previous one. Both were
109
+ real, unreported VOICE.md violations in the site's own pages.
110
+
111
+ Only dashes inside `head` are considered, so attribution stays on the line
112
+ that actually holds the dash (AC-1) no matter how wide the paragraph is.
113
+
114
+ The surrounding context arrives PRE-REDUCED rather than as joined text:
115
+ `words_before`/`words_after` are booleans the caller accumulates once per
116
+ paragraph, and `tail_before`/`head_after` are only the nearest neighbours,
117
+ which is all the numeric-range adjacency check can see. Building the joined
118
+ strings here instead was quadratic in paragraph length - 14x slower at 3000
119
+ lines, measured - and this runs on every write and edit through H-13."""
120
+ for d in _DASHES:
121
+ idx = head.find(d)
122
+ while idx != -1:
123
+ left, right = head[:idx], head[idx + 1:]
124
+ if ((words_before or _WORD_RE.search(left))
125
+ and (_WORD_RE.search(right) or words_after)):
126
+ # A range split BY the wrap: the digit adjacency lives on the
127
+ # neighbouring line, so fall back to it only when this line
128
+ # contributes nothing on that side.
129
+ near_left = left if left.strip() else tail_before
130
+ near_right = right if right.strip() else head_after
131
+ if not (_RANGE_LEFT_RE.search(near_left)
132
+ and _RANGE_RIGHT_RE.search(near_right)):
133
+ return True
134
+ idx = head.find(d, idx + 1)
135
+ return False
136
+
137
+
138
+ def find_prose_separator_dashes(text):
139
+ """Return a finding per line that uses an em/en dash as a prose separator.
140
+
141
+ Each finding is {"line": <1-based int>, "context": <stripped line text>}.
142
+ Exempt: fenced code blocks, inline code, URLs, numeric/date ranges, and a
143
+ lone dash that joins no text (split on `|` so a table-cell N/A marker is not
144
+ mistaken for a separator).
145
+ """
146
+ findings = []
147
+ in_fence = False
148
+ lines = text.splitlines()
149
+ # Each paragraph is scanned as a unit so a separator that lands at a
150
+ # soft-wrap boundary is visible (#484), while every finding is still
151
+ # attributed to the line holding its dash. `_prose_only` runs PER LINE and
152
+ # before the join, so inline code, URLs, ranges and the definition-list
153
+ # lead-in are stripped exactly as they were.
154
+ index = 0
155
+ while index < len(lines):
156
+ raw = lines[index]
157
+ if _FENCE_RE.match(raw):
158
+ in_fence = not in_fence
159
+ index += 1
160
+ continue
161
+ if in_fence:
162
+ index += 1
163
+ continue
164
+ # The paragraph: this line, plus every following line that continues it.
165
+ group = [index]
166
+ following = index + 1
167
+ while following < len(lines) and not _starts_new_block(lines[following]):
168
+ group.append(following)
169
+ following += 1
170
+ prose = [_prose_only(lines[i]) for i in group]
171
+ # Reduce the paragraph's context to what the dash test actually needs,
172
+ # in one pass each direction. `word_before[k]` is "some EARLIER line in
173
+ # this paragraph has a word character", `word_after[k]` the same looking
174
+ # forward. It has to accumulate over the whole group rather than peek at
175
+ # the neighbour: a line that is entirely inline code strips to
176
+ # whitespace, so the real continuation can sit two lines from the dash.
177
+ word_before = [False] * len(prose)
178
+ for k in range(1, len(prose)):
179
+ word_before[k] = word_before[k - 1] or bool(_WORD_RE.search(prose[k - 1]))
180
+ word_after = [False] * len(prose)
181
+ for k in range(len(prose) - 2, -1, -1):
182
+ word_after[k] = word_after[k + 1] or bool(_WORD_RE.search(prose[k + 1]))
183
+ for position, i in enumerate(group):
184
+ head = prose[position]
185
+ if "|" in lines[i]:
186
+ # A table row: keep the cell-by-cell scan, and never let a
187
+ # neighbouring row lend it context.
188
+ hit = any(_segment_separates(cell) for cell in head.split("|"))
189
+ else:
190
+ hit = _separating_dash_in(
191
+ head,
192
+ words_before=word_before[position],
193
+ words_after=word_after[position],
194
+ tail_before=prose[position - 1] if position else "",
195
+ head_after=(prose[position + 1]
196
+ if position + 1 < len(prose) else ""),
197
+ )
198
+ if hit:
199
+ findings.append({"line": i + 1, "context": lines[i].strip()})
200
+ index = following
201
+ return findings
202
+
203
+
204
+ # Authored site prose (#338). site/VOICE.md has banned em-dashes as sentence
205
+ # separators since 2026-07-02, and nothing enforced it: this predicate covered
206
+ # repo-root docs and docs/**, never site/. A rule with no gate, violated in 16
207
+ # of its own 36 authored files, is worse than no rule - reviewers cite it and it
208
+ # is wrong.
209
+ #
210
+ # Scope is AUTHORED prose only. Everything under content/docs/reference/ is
211
+ # generated on every build (91 of the 128 files there), changelog.md is a
212
+ # verbatim pass-through of the repo CHANGELOG, and site/src/curated/** mirrors
213
+ # plugins/ca bodies - which are framework prose under a different register, and
214
+ # already excluded via plugins/. Flagging any of those would report a finding
215
+ # nobody wrote and nobody can fix in place.
216
+ _SITE_PROSE_ROOT = "site/src/content/docs/"
217
+ _SITE_PROSE_EXCLUDED = ("reference/", "changelog.md")
218
+
219
+
220
+ def in_antislop_doc_scope(rel_path):
221
+ """True for user-facing Markdown the anti-slop bundle governs: repo-root
222
+ community docs, docs/**, and AUTHORED site prose under
223
+ site/src/content/docs/ (#338). Excludes codeArbiter's own framework bodies
224
+ (everything under plugins/), machine-managed .codearbiter/ state, and every
225
+ generated site page."""
226
+ if not rel_path:
227
+ return False
228
+ p = rel_path.replace("\\", "/")
229
+ if p.startswith("./"):
230
+ p = p[2:]
231
+ # `.mdx` counts: the rule is about prose, not about a file extension, and
232
+ # two authored site pages are .mdx.
233
+ if not p.lower().endswith((".md", ".mdx")):
234
+ return False
235
+ if p.startswith("plugins/") or p.startswith(".codearbiter/"):
236
+ return False
237
+ if p.startswith(_SITE_PROSE_ROOT):
238
+ rest = p[len(_SITE_PROSE_ROOT):]
239
+ return not rest.startswith(_SITE_PROSE_EXCLUDED)
240
+ if p.startswith("site/"):
241
+ return False
242
+ if p.startswith("docs/"):
243
+ return True
244
+ return "/" not in p