@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,411 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter - protected-state registry: which project-state files are
3
+ # guarded, and under what POLICY (issue #564, workstream B1).
4
+ #
5
+ # Three consumers need materially different write semantics, so a flat
6
+ # marker-gated registry (the H-11 ADR-authoring pattern) is wrong for two of
7
+ # them. Each registry entry will carry exactly one of:
8
+ #
9
+ # marker-gated - a Write/Edit/shell mutation is admitted only under a
10
+ # fresh authoring marker (the H-11 pattern: a
11
+ # `<stem>-authoring` marker under .codearbiter/.markers/,
12
+ # minted immediately before the write and removed at lane
13
+ # exit). First consumer: release-targets.md.
14
+ # helper-only - Write, Edit, and shell naming the file are hard-blocked
15
+ # with NO marker path at all. The sanctioned helper's own
16
+ # Python file I/O is the only route - its argv never
17
+ # lexically names the file, so it is invisible to all three
18
+ # flanks by construction. Consumer: open-tasks.md via
19
+ # taskwrite.py.
20
+ # append-only - mutation is admitted only via the helper's append verb.
21
+ # Consumer: done-tasks.md via the archive verb.
22
+ #
23
+ # THE REGISTRY IS THE DELIVERABLE, NOT THE ENTRIES (spec B1). This module is
24
+ # generic machinery over a policy-tagged path->policy map, not hardcoded
25
+ # per-consumer logic. release-targets.md, open-tasks.md, and done-tasks.md
26
+ # were enrolled by their own tasks (B-13/B-14/B-15) below, so no consumer is
27
+ # special-cased in the machinery itself.
28
+ #
29
+ # Library design invariants (mirrors every other _*lib.py, coding-standards.md):
30
+ # - Zero side effects at import time - no git calls, no file I/O on import.
31
+ # - Pure functions, testable with synthetic input; filesystem access will be
32
+ # isolated to `_hooklib.marker_fresh`, the one named reader this module
33
+ # calls (reused rather than re-implemented, per house rule).
34
+ #
35
+ # Public API (filled in task-by-task; see history for the T-01/T-04/T-05
36
+ # slices):
37
+ # ProtectedPolicy enum: MARKER_GATED / HELPER_ONLY /
38
+ # APPEND_ONLY, str-valued
39
+ # ("marker-gated"/"helper-only"/
40
+ # "append-only"). Constructing from an
41
+ # unknown value raises ValueError
42
+ # (internal error, not user input).
43
+ # REGISTRY -> dict[str, ProtectedPolicy] the live path->policy map.
44
+ # Three consumers are registered below
45
+ # (release-targets.md, open-tasks.md,
46
+ # done-tasks.md); a future consumer adds
47
+ # its own entry rather than being
48
+ # hardcoded in this module.
49
+ # lookup_policy(rel_path, registry=None) -> ProtectedPolicy | None
50
+ # the policy registered for a
51
+ # repo-relative path. Both the query
52
+ # path and every registry key are
53
+ # separator-normalized before
54
+ # comparison, or None if the path
55
+ # carries no policy. `registry` defaults
56
+ # to the module-level REGISTRY; a caller
57
+ # (a test, or a future flank) may pass a
58
+ # synthetic dict instead. Reached only
59
+ # through `classify_protected`
60
+ # (_protectedlib.py) in the wired path -
61
+ # never called directly with an ad hoc
62
+ # dict, which would drop the
63
+ # raw-and-realpath symlink coverage that
64
+ # dispatch provides.
65
+ # resolve_registered_path(fpath, root, registry=None)
66
+ # -> (rel_path, ProtectedPolicy) | (None,
67
+ # None). The T-06/T-07 flank helper: once
68
+ # classify_protected has already reported
69
+ # "state" for `fpath`, this resolves
70
+ # WHICH registered path matched and WHICH
71
+ # policy it carries, trying both the raw
72
+ # normalized path and its
73
+ # realpath-resolved repo-relative form -
74
+ # the SAME two forms classify_protected
75
+ # itself tries (#162) - so the flank
76
+ # resolves the identical entry
77
+ # classify_protected saw rather than
78
+ # re-deriving membership through an
79
+ # independent check.
80
+ # MARKER_FRESHNESS_MINUTES -> int re-exported from _hooklib (issue #567):
81
+ # the single H-11 marker window (30),
82
+ # now declared exactly once and
83
+ # imported by every flank rather than
84
+ # independently hardcoded.
85
+ # marker_name_for(rel_path) -> str a marker basename for a marker-gated
86
+ # path, encoding the path BELOW the
87
+ # repo's .codearbiter/ project-state
88
+ # root (not just the basename) so
89
+ # same-named files in different
90
+ # sub-directories never share a marker
91
+ # (e.g. ".codearbiter/release-targets.md"
92
+ # -> "release-targets-authoring", the
93
+ # pinned literal for the sole current
94
+ # consumer; "docs/release-targets.md"
95
+ # -> "docs__release-targets-authoring").
96
+ # Always a single flat, traversal-safe
97
+ # filename; degenerate input (empty,
98
+ # None, a bare directory) never raises
99
+ # and never collides with a real path's
100
+ # derived name.
101
+ # marker_gated_write_admitted(rel_path, root,
102
+ # minutes=MARKER_FRESHNESS_MINUTES) -> bool
103
+ # True iff a fresh `<stem>-authoring`
104
+ # marker exists under
105
+ # .codearbiter/.markers/ for `rel_path`.
106
+ # False on an absent marker AND on a
107
+ # stale one - both cases delegate
108
+ # entirely to _hooklib.marker_fresh, so
109
+ # there is exactly one freshness
110
+ # implementation in the codebase. For
111
+ # `marker-gated` only; the flank wiring
112
+ # (pre-write.py, pre-edit.py,
113
+ # _bashguardlib.py per-class dispatch)
114
+ # is hook ID H-22 (H-21 is taken),
115
+ # built in later tasks of this slice -
116
+ # this function supplies the check the
117
+ # flanks will share, and does not
118
+ # itself call block()/remind().
119
+
120
+ from __future__ import annotations
121
+
122
+ import os
123
+ from enum import Enum
124
+
125
+ from _hooklib import MARKER_FRESHNESS_MINUTES, marker_fresh
126
+ from _pathnorm import norm_path, raw_repo_rel, repo_rel
127
+
128
+
129
+ class ProtectedPolicy(str, Enum):
130
+ """The three write-admission policies a protected-state registry entry
131
+ can carry (spec B1). str-valued so a member compares equal to its plain
132
+ string value (`ProtectedPolicy.MARKER_GATED == "marker-gated"`) and
133
+ round-trips through JSON without a second mapping layer.
134
+
135
+ Interpolation caveat: use `.value`, not the member itself. `str(member)` /
136
+ an f-string / `%s` on a bare member yields `"ProtectedPolicy.MARKER_GATED"`
137
+ on Python 3.11+ (and the plain value on <=3.10) - the mixin does not make
138
+ those two forms agree across interpreters. Only `.value` (or the
139
+ already-proven `==` against a plain string) is the stable contract.
140
+
141
+ Constructing an unknown value (`ProtectedPolicy("bogus")`) raises
142
+ ValueError via the stdlib Enum machinery - deliberately NOT caught here.
143
+ An unrecognized policy string reaching this constructor is a typo'd
144
+ registry entry - an internal programming error, not malformed user
145
+ input - so it is correct to raise rather than degrade."""
146
+
147
+ MARKER_GATED = "marker-gated"
148
+ HELPER_ONLY = "helper-only"
149
+ APPEND_ONLY = "append-only"
150
+
151
+
152
+ # The live protected-state registry: repo-relative path (separator-
153
+ # normalized) -> ProtectedPolicy. B1 (this module) built the registry
154
+ # mechanism; release-targets.md/open-tasks.md/done-tasks.md were added by
155
+ # their own later tasks (B-13/B-14/B-15), each a one-line entry, which is the
156
+ # whole point of building this as a registry instead of a per-file hook
157
+ # branch. A future consumer adds one more entry here, not a new branch.
158
+ REGISTRY: dict[str, ProtectedPolicy] = {
159
+ # B-13/T-33 (spec 2.6). The declared release-target file carries
160
+ # per-row `pre-tag`, `rebuild`, and `generate` shell commands that
161
+ # `/ca:release` executes before composing a tag, on a lane that later
162
+ # holds `contents: write`. Planting a command in it is therefore a
163
+ # code-execution path, which is why writing it costs a fresh authoring
164
+ # marker rather than being an ordinary edit (ADR-0024, DECISION-0035).
165
+ #
166
+ # MARKER_GATED, not HELPER_ONLY: unlike `open-tasks.md` -- whose sole
167
+ # blessed writer is `taskwrite.py`, writing through Python file I/O
168
+ # whose argv never names the file and is therefore invisible to every
169
+ # flank by construction -- this file has THREE sanctioned authors
170
+ # (`context-creation`, the release skill's back-fill lane, and its
171
+ # row-edit path), all of which mint the marker. A hard block would
172
+ # leave them no route; the marker is the route.
173
+ ".codearbiter/release-targets.md": ProtectedPolicy.MARKER_GATED,
174
+
175
+ # B-14/T-66. HELPER_ONLY, with NO marker path at all — deliberately
176
+ # unlike release-targets.md above.
177
+ #
178
+ # `taskwrite.py` is the board's sole blessed writer and writes through
179
+ # Python file I/O whose argv never names the file, so it is invisible
180
+ # to every lexical flank by construction. A marker would therefore add
181
+ # nothing for the helper while ADMITTING an agent that hand-composes
182
+ # board markdown under it — the inversion of the goal.
183
+ #
184
+ # Enrolment is non-circular, but NOT for the reason this comment used
185
+ # to give. It claimed the guard "never sees the helper's write", which
186
+ # is true of the write and irrelevant to the guard: the guard reads the
187
+ # COMMAND LINE, and the helper's own launcher is `python3
188
+ # ".../taskwrite.py" <verb> …`. The interpreter leg matched that
189
+ # `python3` and blocked every invocation whose free-text argv named an
190
+ # enrolled basename — including `done`/`archive` on an ID-less task
191
+ # whose title names one, where the title IS the target and rewording it
192
+ # would itself require a board write. The workstream-B adversary drove
193
+ # the real `pre-bash.py` and confirmed it.
194
+ #
195
+ # What actually makes it non-circular is the interpreter leg's
196
+ # inline-code requirement (`_INTERP_INLINE_CODE` in `_bashguardlib`):
197
+ # running a FILE and passing the basename as argv is data, executing a
198
+ # STRING that names it is a write. Pinned by
199
+ # `test_filename_as_helper_argv_data_passes` and
200
+ # `test_helper_write_verbs_pass_when_the_title_names_the_file`, both on
201
+ # the real invocation shape — the prior pins asserted a bare
202
+ # `taskwrite add …` that no surface emits.
203
+ ".codearbiter/open-tasks.md": ProtectedPolicy.HELPER_ONLY,
204
+
205
+ # B-15/T-65. APPEND_ONLY: a completed task has exactly one permanent
206
+ # record, so mutation is admitted through an append verb alone. The
207
+ # archival sweep only ever appends here — `taskwrite archive` writes
208
+ # done-tasks BEFORE removing from open-tasks, so an interrupted run
209
+ # leaves a duplicate the next run dedups, never a lost record.
210
+ ".codearbiter/done-tasks.md": ProtectedPolicy.APPEND_ONLY,
211
+ }
212
+
213
+
214
+ def _canon(rel_path):
215
+ """Canonical comparison form of a repo-relative path: separator-
216
+ normalized (`norm_path`), then whitespace-stripped, `./`-prefix-
217
+ stripped (repeatable — "././x" too), doubled-slash-collapsed,
218
+ trailing-slash-stripped, and finally case-folded.
219
+
220
+ Applied to BOTH sides of every `lookup_policy` comparison (the query
221
+ path AND every registry key) so a spelling difference on either side
222
+ degrades to "still matches" rather than "silently matches nothing" —
223
+ the same "a malformed key degrades the way a malformed query path does"
224
+ principle `lookup_policy`'s own docstring states, extended to cover the
225
+ specific spellings its docstring already promised but the OLD
226
+ norm_path-only comparison silently missed (#564 follow-up, finding F2):
227
+ a leading `./`, a trailing slash, a doubled slash, and a leading space.
228
+
229
+ Case-folded — not merely separator-normalized — for a second,
230
+ independent reason (finding F1): `_bashguardlib.py`'s H-22 shell-flank
231
+ regexes compile with `re.I` (`_state_write_res`), so a case-sensitive
232
+ equality check here would let the two flanks disagree on whether a
233
+ differently-cased spelling of a registered path is protected. On a
234
+ case-preserving-but-insensitive filesystem (default macOS/APFS,
235
+ Windows/NTFS) that disagreement is a live fail-open: `_protectedlib.
236
+ classify_protected` resolves through `os.path.realpath`, which does
237
+ NOT canonicalize case for an EXISTING path on a case-insensitive mount
238
+ (posixpath.realpath never folds case at all; even `nt.realpath`, which
239
+ does resolve an existing file's on-disk case, cannot help a NOT-YET-
240
+ created file — exactly the Write that creates a protected-state file
241
+ for the first time) — so `Write(".codearbiter/Open-Tasks.md")` could
242
+ reach this equality check with a case that never gets folded away
243
+ before comparison. This module deliberately picks ONE fixed rule,
244
+ case-INSENSITIVE, GLOBALLY, rather than "whatever this host's
245
+ filesystem happens to do": matching host behavior is not obviously
246
+ right either (it varies per platform AND per volume on the same
247
+ platform), and a fixed global rule is the only option `_bashguardlib.py`
248
+ can mirror without itself inspecting the filesystem. Choosing
249
+ case-INSENSITIVE (not case-sensitive) only WIDENS what H-22 protects —
250
+ consistent with this codebase's "ambiguity resolves CLOSED" stance
251
+ (module comment, `_bashguardlib.py`) — at the cost of a same-directory
252
+ file whose name differs from a registered path ONLY by case (e.g. a
253
+ genuinely different `OPEN-TASKS.MD`) being treated as protected too; a
254
+ registry entry choosing a name that collides with a real sibling file
255
+ under a case change is expected to be rare enough that this is judged
256
+ the right trade."""
257
+ p = norm_path(rel_path).strip()
258
+ while p.startswith("./"):
259
+ p = p[2:]
260
+ while "//" in p:
261
+ p = p.replace("//", "/")
262
+ p = p.rstrip("/")
263
+ return p.lower()
264
+
265
+
266
+ def lookup_policy(rel_path, registry=None):
267
+ """The ProtectedPolicy registered for `rel_path`, or None if it carries
268
+ no policy. Both `rel_path` and every registry key are canonicalized
269
+ (`_canon`, above) before comparison — separator-normalized, `./`/
270
+ doubled-slash/trailing-slash/leading-space tolerant, and
271
+ case-INSENSITIVE (deliberately, globally — see `_canon`'s docstring for
272
+ why) — so a Windows backslash path, a `./`-prefixed or trailing-slash
273
+ query, or a differently-cased spelling all match a registry entry, AND
274
+ a registry entry that was itself typo'd any of those ways still matches
275
+ rather than silently protecting nothing - a malformed *key* degrades the
276
+ same way a malformed query path does; only a malformed *policy* (see
277
+ ProtectedPolicy) is an internal error worth raising on.
278
+
279
+ `registry` defaults to the module-level REGISTRY; a test (or a future
280
+ caller) may pass a synthetic dict instead, which is what keeps this
281
+ function generic machinery rather than something wired to a specific
282
+ consumer set. This module must be reached through `classify_protected`
283
+ (`_protectedlib.py`), which runs every classifier against both the raw
284
+ and realpath-resolved forms of a path - a flank that calls
285
+ `lookup_policy` directly with its own ad hoc dict re-opens the symlink
286
+ alias this module does not itself guard against."""
287
+ if registry is None:
288
+ registry = REGISTRY
289
+ normalized = _canon(rel_path)
290
+ for key, policy in registry.items():
291
+ if _canon(key) == normalized:
292
+ return policy
293
+ return None
294
+
295
+
296
+ def resolve_registered_path(fpath, root, registry=None):
297
+ """The `(rel_path, policy)` pair a Write/Edit/shell target resolves to,
298
+ once `_protectedlib.classify_protected` has already reported "state" for
299
+ it - or `(None, None)` if it turns out to carry no registry entry after
300
+ all (a caller that checks this before ever consulting
301
+ classify_protected, or a stale class set).
302
+
303
+ Tries BOTH the raw (symlink-unresolved) repo-relative form
304
+ (`raw_repo_rel`) and the realpath-resolved repo-relative form
305
+ (`repo_rel`), in that order - the SAME two-form symlink-safety property
306
+ (#162) classify_protected's four legacy classes get automatically from
307
+ running a regex `.search()` over the raw normalized path text.
308
+
309
+ That automatic coverage does NOT transfer for free to this module's
310
+ EQUALITY-based lookup (finding F3, #564 follow-up): `norm_path(fpath)` -
311
+ almost always an ABSOLUTE path, since every host sends one - is never
312
+ equal to a repo-relative registry key, so trying it as the "raw" leg was
313
+ inert (it could never match anything). Worse, it made symlink coverage
314
+ the WRONG WAY ROUND versus the other four classes: when the REGISTERED
315
+ PATH ITSELF is a symlink pointing somewhere unregistered,
316
+ `os.path.realpath` resolves the ONLY spelling a host actually sends
317
+ (the absolute path) straight through the symlink to that unregistered
318
+ target, and the dead raw leg supplied no alternative route back to the
319
+ registered name — so the write was silently ADMITTED, the opposite of
320
+ the legacy classes' behavior in the equivalent scenario (a regex
321
+ `.search()` still matches the raw path text regardless of where it
322
+ realpaths to). `raw_repo_rel` fixes this: computed by pure lexical
323
+ arithmetic against `root` (no `os.path.realpath` call), it still names
324
+ the registered entry syntactically even when the path is a symlink, so
325
+ that spelling now resolves correctly too - restoring the SAME
326
+ "protected either way you spell it" guarantee the legacy classes
327
+ already had; the realpath leg still exists for the mirror-image case
328
+ (a symlinked DIRECTORY whose visible path lacks the registered prefix
329
+ but resolves into it).
330
+
331
+ So a flank reaching this function resolves the IDENTICAL entry
332
+ classify_protected saw, rather than re-deriving membership through an
333
+ independent check. That independent-check shape is exactly what #564's
334
+ design forbids ("no second, parallel lookup") - this function only ever
335
+ RESOLVES what classify_protected already decided; it never decides
336
+ membership on its own account.
337
+
338
+ `registry` defaults to the module-level REGISTRY, matching
339
+ `lookup_policy`'s own parameter shape, for the same reason: a test (or a
340
+ future caller) may pass a synthetic dict."""
341
+ for p in (raw_repo_rel(fpath, root), repo_rel(fpath, root)):
342
+ if not p:
343
+ continue
344
+ policy = lookup_policy(p, registry)
345
+ if policy is not None:
346
+ return p, policy
347
+ return None, None
348
+
349
+
350
+ def marker_name_for(rel_path):
351
+ """The `<...>-authoring` marker basename for a marker-gated `rel_path`
352
+ (e.g. "release-targets.md" -> "release-targets-authoring") - the pattern
353
+ named in the sprint's pre-run dispositions for every future marker-gated
354
+ consumer, not just the first.
355
+
356
+ Encodes the normalized relative path below the repo's single
357
+ `.codearbiter/` project-state root, not just the basename, so two
358
+ registry entries that merely share a filename in different
359
+ sub-directories (".codearbiter/release-targets.md" vs
360
+ ".codearbiter/nested/release-targets.md", or "a/x.md" vs "b/x.yml") mint
361
+ two distinct markers rather than one shared one - minting the marker for
362
+ one would otherwise admit a write to the other. A single leading
363
+ `.codearbiter` segment is dropped before encoding rather than treated as
364
+ disambiguating structure: every registry entry lives there by
365
+ construction (this module's whole domain), so keeping it out of the
366
+ encoding is what leaves the pinned literal for the sole current
367
+ marker-gated consumer unchanged (`.codearbiter/release-targets.md` ->
368
+ `release-targets-authoring`) while still telling apart two DIFFERENT
369
+ sub-directories. A literal `-` inside a directory segment is escaped
370
+ (doubled) before segments are joined with `-`, so a raw `-` in the
371
+ encoded name always marks a genuine directory boundary - this closes the
372
+ same-string collision a naive join would allow between, e.g., a
373
+ "prefix-name" directory and a "prefix"/"name" nested pair. `.`, `..`, and
374
+ empty segments are dropped before encoding (not merely trusted to
375
+ `os.path.basename`), so a stray `./` prefix, a trailing slash, or a
376
+ crafted `../` segment can never reach the returned name; the result is
377
+ always a single flat filename, never a path, so it stays contained under
378
+ .codearbiter/.markers/. Never raises: an empty/None `rel_path`, or one
379
+ with no real path segment (".", "..", "/"), has no directory or file to
380
+ encode and degrades to the fixed sentinel "-authoring" - a name no real
381
+ (non-empty) rel_path can ever produce, since a real segment always
382
+ contributes a non-empty stem.
383
+
384
+ Residual (accepted): the escaping above closes hyphen/segment-boundary
385
+ ambiguity but is not a fully bijective encoding against adversarially
386
+ crafted underscore runs (e.g. a directory literally named "a_" holding
387
+ "_b.md" can alias "a" holding "__b.md"). The registry's entries are a
388
+ small, curated, human-authored set (spec B1/B2), not attacker-chosen
389
+ directory names, so this is judged out of proportion to close fully
390
+ here."""
391
+ parts = [p for p in norm_path(rel_path).split("/") if p not in ("", ".", "..")]
392
+ if not parts:
393
+ return "-authoring"
394
+ if parts[0] == ".codearbiter" and len(parts) > 1:
395
+ parts = parts[1:]
396
+ stem = os.path.splitext(parts[-1])[0]
397
+ dir_tag = "-".join(p.replace("-", "--") for p in parts[:-1])
398
+ encoded = f"{dir_tag}__{stem}" if dir_tag else stem
399
+ return f"{encoded}-authoring"
400
+
401
+
402
+ def marker_gated_write_admitted(rel_path, root, minutes=MARKER_FRESHNESS_MINUTES):
403
+ """True iff a Write/Edit/shell mutation of a `marker-gated` `rel_path` is
404
+ admitted: a `<stem>-authoring` marker exists under
405
+ .codearbiter/.markers/ and was touched within `minutes` (the H-11
406
+ pattern). False on an absent marker AND on a stale one - both cases
407
+ delegate entirely to _hooklib.marker_fresh (the one filesystem reader
408
+ this module calls), so there is exactly one freshness implementation in
409
+ the codebase."""
410
+ marker = os.path.join(root, ".codearbiter", ".markers", marker_name_for(rel_path))
411
+ return marker_fresh(marker, minutes)