@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,595 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter — shared hook helpers (issues #24, #25).
3
+ #
4
+ # Replaces the bash+jq hook layer with a cross-platform, fail-LOUD Python
5
+ # implementation. The bash hooks began with `which jq > /dev/null || exit 0`,
6
+ # so on any machine without jq (e.g. a stock Windows install) EVERY safety gate
7
+ # silently evaporated with no signal — the plugin appeared installed but was
8
+ # inert. Python is already a plugin dependency (statusline, scaffolder), needs
9
+ # no jq, and lets a hook fail loud instead of fail open.
10
+ #
11
+ # Blocking contract: a PreToolUse hook BLOCKS the tool call by exiting 2 (its
12
+ # stderr is fed back to Claude). Exit 0 allows. The prior bash hooks used
13
+ # exit 1 — a NON-blocking error under Claude Code's hook contract — so their
14
+ # "BLOCKED" gates may not have been stopping the tool at all; this port closes
15
+ # that latent gap.
16
+ #
17
+ # Interpreter launch: hooks.json registers every hook TWICE — `python <script>`
18
+ # plus a fallback `python -c "" || python3 <script>`. Preferring `python` avoids
19
+ # activating Windows' python3 MSIX app-execution alias for every hook; POSIX
20
+ # installs that expose only python3 still use the fallback. A single
21
+ # `python x || python3 x` entry would be wrong: when python exists and the
22
+ # script BLOCKS (exit 2), `||` would re-run it against a
23
+ # drained stdin and the rerun's exit 0 would swallow the block. Separate hook
24
+ # entries each receive their own stdin, so the block survives.
25
+ #
26
+ # Public API:
27
+ # get_host() -> Host the process's Host instance (hostapi.load_host(), cached)
28
+ # set_host(host) -> None DI seam (#257): prime the process-cached Host that
29
+ # get_host() returns. Every entry script's run(host) calls
30
+ # this BEFORE main(), so the Host the __main__ guard already
31
+ # loaded is the one get_host() serves — main() no longer
32
+ # triggers a second hostapi.load_host(), and a test calling
33
+ # run(fake_host) genuinely runs against fake_host.
34
+ # utf8_stdio() -> None force UTF-8 on stdout/stderr
35
+ # norm_path(p) -> str normalize path separators to forward-slash
36
+ # frontmatter_enabled(ctx_path) -> tuple[bool, bool] (enabled, malformed)
37
+ # frontmatter_enabled_text(text) -> tuple[bool, bool] same, over content not a path
38
+ # arbiter_active(root) -> bool True iff repo opted in via CONTEXT.md frontmatter
39
+ # read_input() -> dict parse hook JSON from stdin; fail-open on error
40
+ # tool_input(data) -> dict extract tool_input sub-dict from hook payload
41
+ # project_root(payload=None) -> str CLAUDE_PROJECT_DIR, else git repo root, else cwd
42
+ # (memoized per process, keyed on the
43
+ # inputs that could change it — #260)
44
+ # marker_root(payload=None) -> str project_root(payload), escalated to the MAIN
45
+ # checkout when that names a LINKED worktree's
46
+ # own checkout — the root gate MARKERS
47
+ # (.codearbiter/.markers/) live under (#604)
48
+ # repo_rel(fpath, root) -> str repo-relative POSIX path, or "" if outside root
49
+ # line_digest(line) -> str sha256 hex of one diff line (H-09b/H-10b gate)
50
+ # content_digest(text) -> str sha256 hex of a whole file's content (H-14 gate)
51
+ # migration_globs(root) -> tuple[list, list] (includes, excludes) for migration detection
52
+ # scope_globs(root, defaults, decl_re) -> tuple[list, list] generic glob scope resolver
53
+ # path_in_globs(rel, root, defaults, decl_re) -> bool True iff path matches scope
54
+ # is_migration_path(rel, root) -> bool True iff rel is a DB migration (H-14)
55
+ # is_ci_path(rel, root) -> bool True iff rel is a CI/CD workflow (H-15)
56
+ # is_deploy_path(rel, root) -> bool True iff rel is a deployment/IaC manifest (H-16)
57
+ # is_audit_log(rel) -> bool True iff rel is an append-only audit log (H-05)
58
+ # is_tail_append(current, old, new) -> bool True iff old_string is current's exact
59
+ # trailing content AND new_string extends it (H-05)
60
+ # is_decisions_path(rel) -> bool True iff rel is an ADR under decisions/ (H-11)
61
+ # is_context_md(rel) -> bool True iff rel is the CONTEXT.md activation file (#159)
62
+ # is_marker_path(rel) -> bool True iff rel is under .codearbiter/.markers/ (#160)
63
+ # classify_protected(fpath, root) -> set protected classes hit, raw+realpath (#162)
64
+ # is_sensitive_scan_exempt(rel) -> bool True iff rel is exempt from the H-09b/H-10b
65
+ # crypto/secret scan (gate-events.log only, #279)
66
+ # SECURITY_DIFF_GIT_ARGS pinned `git diff` argv suffix (fixed a/ b/
67
+ # prefixes, no external diff) every H-09b/H-10b
68
+ # sensitive-line reader MUST use (#279 review)
69
+ # diff_added_lines(diff_text) -> list[tuple[str|None, str]] path-aware walk of a unified
70
+ # diff's added ('+') lines (from SECURITY_DIFF_GIT_ARGS
71
+ # output), paired with the destination path each
72
+ # belongs to, attributed from `+++ b/<path>` via a
73
+ # fixed-prefix strip within an unspoofable `diff `
74
+ # section (#279 review)
75
+ # sensitive_scan_added_lines(diff_text) -> list[str] diff_added_lines() narrowed to the
76
+ # H-09b/H-10b candidate set (exempt paths dropped)
77
+ # MARKER_FRESHNESS_MINUTES -> int the single H-11 authoring-marker
78
+ # freshness window (30 minutes). Issue
79
+ # #567: this used to be five
80
+ # independent hardcoded `30` literals
81
+ # (pre-write.py, pre-edit.py,
82
+ # _bashguardlib.py, git-enforce.py,
83
+ # _protectedstatelib.py) with no
84
+ # import relationship, so widening or
85
+ # narrowing one silently desynced the
86
+ # window across enforcement flanks.
87
+ # Declared HERE, beside marker_fresh
88
+ # itself (the one freshness
89
+ # implementation every caller already
90
+ # delegates to) — every flank now
91
+ # imports this name rather than
92
+ # restating the literal.
93
+ # marker_fresh(path, minutes) -> bool True iff marker file exists and is recent
94
+ # write_text_atomic(path, text) -> None crash-safe write (temp + os.replace)
95
+ # acquire_lock(path) -> handle|None OS-owned cross-process file lock (#271 C-2);
96
+ # non-blocking + bounded LOCK_WAIT retry spin,
97
+ # fail-soft None on contention/timeout/OSError
98
+ # release_lock(handle) -> None release + close; None handle is a no-op
99
+ # block(tag, msg) -> None BLOCK tool call: print to stderr and exit 2
100
+ # remind(tag, msg) -> None non-blocking nudge to stderr
101
+ # warn(msg) -> None loud degradation breadcrumb to stderr
102
+ # staleness_warning(root, now=None, window_minutes=30) -> list[str]
103
+ # (CONFIRM-09) active-flow audit-log staleness
104
+ # messages, WARN-only, never raises
105
+
106
+ import datetime
107
+ import errno
108
+ import json
109
+ import os
110
+ import re
111
+ import subprocess # noqa: F401 — no longer used directly; kept so any external
112
+ # consumer referencing _hooklib.subprocess keeps resolving
113
+ import sys
114
+ import tempfile
115
+ import threading
116
+ import time
117
+
118
+ import hostapi
119
+
120
+ # Issue #321 - the H-09b/H-10b sensitive-line scan moved to _sensitivelib, the
121
+ # protected-path classifiers to _protectedlib, the H-14/H-15/H-16 scope
122
+ # detection to _scopelib, activation and the host/root caches to
123
+ # _activationlib, and the path primitives to the
124
+ # _pathnorm floor beneath both. Re-exported here, unchanged, so
125
+ # every one of the 59 consuming files keeps importing from _hooklib and the
126
+ # pre-existing suites prove parity without moving. New code SHOULD import from
127
+ # the owning module; this facade exists so the partition costs no caller a diff.
128
+ from _pathnorm import norm_path, repo_rel # noqa: F401
129
+ from _activationlib import ( # noqa: F401
130
+ # `_HOST` is deliberately ABSENT: importing a mutable global binds its VALUE,
131
+ # so a later set_host() would rebind it in _activationlib and leave a stale
132
+ # copy here forever. The accessors below all read and write that module's
133
+ # global, so one cache is shared however a caller reached them. Verified that
134
+ # nothing in the repo reads _hooklib._HOST directly.
135
+ # `_reset_root_cache` is a FUNCTION and has a consumer (test_hooklib calls it
136
+ # through `_hooklib.`), so it is re-exported: a function binding stays live.
137
+ # `_ROOT_CACHE` and `_root_cache_key` have none and are dropped rather than
138
+ # relocated.
139
+ _reset_root_cache,
140
+ ARBITER_RE,
141
+ arbiter_active,
142
+ frontmatter_enabled,
143
+ frontmatter_enabled_text,
144
+ get_host,
145
+ marker_root,
146
+ project_root,
147
+ reset_host,
148
+ set_host,
149
+ )
150
+ from _scopelib import ( # noqa: F401
151
+ # The private names are re-exported too, deliberately. `_read_controls`,
152
+ # `_glob_to_re` and the precompiled default tuples have real consumers
153
+ # (test_hooklib reaches them through `_hooklib.`), and `_CONTROLS_CACHE` is
154
+ # mutated in place rather than rebound, so sharing the binding shares the
155
+ # cache correctly. Slice 1 taught this: an underscore prefix means "not
156
+ # public", never "unused".
157
+ _CI_DECL_RE,
158
+ _CI_DEFAULT_RES,
159
+ _CONTROLS_CACHE,
160
+ _DEFAULT_RES_BY_GLOBS,
161
+ _DEPLOY_DECL_RE,
162
+ _DEPLOY_DEFAULT_RES,
163
+ _MIG_DECL_RE,
164
+ _MIGRATION_DEFAULT_RES,
165
+ _controls_mtime,
166
+ _custom_re_cache,
167
+ _glob_to_re,
168
+ _read_controls,
169
+ _scope_res,
170
+ CI_DEFAULT_GLOBS,
171
+ DEPLOY_DEFAULT_GLOBS,
172
+ MIGRATION_DEFAULT_GLOBS,
173
+ is_ci_path,
174
+ is_deploy_path,
175
+ is_migration_path,
176
+ migration_globs,
177
+ path_in_globs,
178
+ scope_globs,
179
+ )
180
+ from _protectedlib import ( # noqa: F401
181
+ AUDIT_LOG_BASENAMES,
182
+ AUDIT_LOG_FLAT_BASENAMES,
183
+ AUDIT_LOG_NAMES,
184
+ AUDIT_LOG_RE,
185
+ CONTEXT_MD_RE,
186
+ DECISIONS_DIR_RE,
187
+ DECISIONS_PATH_RE,
188
+ DECISION_LOG_BASENAME,
189
+ DECISION_LOG_RE,
190
+ GATE_MARKER_NAMES,
191
+ MARKERS_RE,
192
+ classify_protected,
193
+ is_audit_log,
194
+ is_context_md,
195
+ is_decisions_path,
196
+ is_marker_path,
197
+ is_tail_append,
198
+ )
199
+ from _sensitivelib import ( # noqa: F401
200
+ CRYPTO_RE,
201
+ SECRET_RE,
202
+ SECURITY_DIFF_GIT_ARGS,
203
+ SENSITIVE_SCAN_EXEMPT_RELPATHS,
204
+ content_digest,
205
+ diff_added_lines,
206
+ is_sensitive_scan_exempt,
207
+ line_digest,
208
+ sensitive_scan_added_lines,
209
+ )
210
+
211
+
212
+ # Serialize same-process Windows writers before taking the cross-process lock.
213
+ _GATE_EVENTS_WINDOWS_LOCK = threading.Lock()
214
+ _WINDOWS_LOCK_TIMEOUT_SECONDS = 5.0
215
+ _WINDOWS_LOCK_RETRY_SECONDS = 0.01
216
+ _WINDOWS_CRT_EDEADLK = 36
217
+
218
+ # Bounded best-effort wait for another cross-process writer to release
219
+ # acquire_lock()'s sidecar lock file (#271 C-2). Originally _ledgerlib-private
220
+ # (the statusline's cost/token ledger); hoisted here so taskwrite.py's board
221
+ # writer (a second, genuinely different caller) can share ONE lock
222
+ # implementation instead of a second hand-rolled copy. _ledgerlib re-exports
223
+ # this name (`from _hooklib import LOCK_WAIT`) so its own module-level
224
+ # mock.patch.object(L, "LOCK_WAIT", ...) test seam keeps working unchanged.
225
+ LOCK_WAIT = 0.2
226
+
227
+
228
+ def _is_lock_contention(exc):
229
+ """True only for CRT/Windows byte-range lock conflict errors."""
230
+ return (getattr(exc, "errno", None) in (errno.EACCES, _WINDOWS_CRT_EDEADLK) or
231
+ getattr(exc, "winerror", None) in (32, 33))
232
+
233
+
234
+
235
+
236
+
237
+
238
+
239
+
240
+
241
+
242
+
243
+
244
+
245
+
246
+
247
+
248
+ def utf8_stdio():
249
+ """Force UTF-8 on stdout/stderr. Windows pipes default to the locale code
250
+ page (cp1252), and ORCHESTRATOR.md contains non-cp1252 glyphs — without this
251
+ the SessionStart injection dies with UnicodeEncodeError."""
252
+ for s in (sys.stdout, sys.stderr):
253
+ try:
254
+ s.reconfigure(encoding="utf-8", errors="replace")
255
+ except Exception: # noqa: BLE001
256
+ pass
257
+
258
+
259
+
260
+
261
+
262
+
263
+ def read_input():
264
+ """Parse the hook JSON from stdin.
265
+
266
+ Deliberately fail-open on unreadable input: a malformed stdin payload must
267
+ NOT brick the session by blocking every subsequent tool call. This is an
268
+ explicit, documented exception to the fail-loud principle — the correct
269
+ behaviour here is warn + allow, not warn + block.
270
+
271
+ "Unreadable" covers a malformed SHAPE as well as malformed syntax
272
+ (ADR-0020). A payload that is valid JSON but not an object — `[]`, `3`,
273
+ `"str"`, `true`, `null` — parses cleanly and never reaches the except
274
+ branch, so it used to be handed downstream as a non-dict: `tool_input()`
275
+ evaluates `(data or {}).get(...)`, which makes the falsy ones accidentally
276
+ safe and raises AttributeError out of the guard on the truthy ones. Both
277
+ are normalized to `{}` here instead, at the one chokepoint that decides it.
278
+
279
+ The distinction between the two failures carries no security content: the
280
+ hook envelope is host-produced (Claude Code, Codex, Pi), not
281
+ model-produced. A model can place hostile content INSIDE `tool_input`, but
282
+ cannot make the top-level object a list — so a non-dict envelope means host
283
+ misbehaviour or version drift, the same compatibility event the parse-error
284
+ branch already rules on.
285
+ """
286
+ try:
287
+ raw = sys.stdin.read()
288
+ data = json.loads(raw) if raw.strip() else {}
289
+ except Exception as e: # noqa: BLE001 — any malformed input
290
+ warn(f"hook input unparseable ({e}); proceeding without enforcement")
291
+ return {}
292
+ if not isinstance(data, dict):
293
+ warn(f"hook input is a JSON {type(data).__name__}, not an object; "
294
+ "proceeding without enforcement")
295
+ return {}
296
+ return data
297
+
298
+
299
+ def tool_input(data):
300
+ return (data or {}).get("tool_input", {}) or {}
301
+
302
+
303
+
304
+
305
+
306
+
307
+
308
+
309
+
310
+
311
+ def write_text_atomic(path, text, newline=None):
312
+ """Write `text` to `path` atomically: a sibling temp file in the same dir,
313
+ then os.replace() into place (atomic on POSIX; a same-volume rename on
314
+ Windows). A crash between open() and the rename never leaves a half-written
315
+ file at `path`. The gate-marker writers (migration-pass / security-pass) rely
316
+ on this so a partial digest set can't be read back as an unrecognized token
317
+ and force a spurious gate re-run (migration-002). On any failure the temp
318
+ file is cleaned up and the original `path` is left untouched.
319
+
320
+ `newline` is passed through to open()/fdopen() unchanged (default None keeps
321
+ the prior text-mode translation behaviour for existing callers); pass "\\n"
322
+ to force LF output regardless of platform (e.g. for a canonical-EOL file)."""
323
+ d = os.path.dirname(path) or "."
324
+ fd, tmp = tempfile.mkstemp(dir=d, prefix=os.path.basename(path) + ".", suffix=".tmp")
325
+ try:
326
+ with os.fdopen(fd, "w", encoding="utf-8", newline=newline) as f:
327
+ f.write(text)
328
+ os.replace(tmp, path)
329
+ except Exception:
330
+ try:
331
+ os.remove(tmp)
332
+ except OSError:
333
+ pass
334
+ raise
335
+
336
+
337
+ def acquire_lock(path):
338
+ """Acquire an OS-owned cross-process file lock keyed on `path`; process
339
+ death releases it automatically (#271 C-2 — hoisted from the
340
+ statusline-ledger-only `_ledgerlib._acquire_lock`, now shared with
341
+ taskwrite.py's board writer).
342
+
343
+ Sidecar lock file `f"{abspath(path)}.lock"`, opened `"a+b"` and seeded
344
+ with one byte so the OS byte-range lock has a byte to lock (an empty file
345
+ has no range to range-lock). Non-blocking (`msvcrt.locking(..., LK_NBLCK,
346
+ 1)` on Windows, `fcntl.flock(..., LOCK_EX | LOCK_NB)` elsewhere) with a
347
+ bounded `LOCK_WAIT`-second retry spin; any `OSError` opening the lock file,
348
+ or exhausting the deadline still contended, is FAIL-SOFT: returns `None`
349
+ rather than raising or blocking indefinitely. Callers decide what
350
+ "fail-soft" means for them — `_ledgerlib.ledger_update`/`persist_sess_start`
351
+ treat `None` as a disposable no-op (a statusline render is throwaway), but
352
+ `taskwrite.py` treats it as a hard error (a board write is NOT disposable;
353
+ see its module docstring)."""
354
+ lock_path = f"{os.path.abspath(path)}.lock"
355
+ parent = os.path.dirname(lock_path)
356
+ try:
357
+ os.makedirs(parent, exist_ok=True)
358
+ handle = open(lock_path, "a+b")
359
+ handle.seek(0, os.SEEK_END)
360
+ if handle.tell() == 0:
361
+ handle.write(b"\0")
362
+ handle.flush()
363
+ except OSError:
364
+ return None
365
+ deadline = time.monotonic() + LOCK_WAIT
366
+ while True:
367
+ try:
368
+ handle.seek(0)
369
+ if os.name == "nt":
370
+ import msvcrt
371
+ msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
372
+ else:
373
+ import fcntl
374
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
375
+ return handle
376
+ except (OSError, BlockingIOError):
377
+ if time.monotonic() >= deadline:
378
+ handle.close()
379
+ return None
380
+ time.sleep(0.005)
381
+
382
+
383
+ def release_lock(handle):
384
+ """Release + close a handle from acquire_lock(). None is a no-op —
385
+ callers that never got the lock don't need to guard the release call."""
386
+ if handle is None:
387
+ return
388
+ try:
389
+ handle.seek(0)
390
+ if os.name == "nt":
391
+ import msvcrt
392
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
393
+ else:
394
+ import fcntl
395
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
396
+ except OSError:
397
+ pass
398
+ finally:
399
+ try:
400
+ handle.close()
401
+ except OSError:
402
+ pass
403
+
404
+
405
+ # The H-11 authoring-marker freshness window (issue #567) — the single
406
+ # declaration every `marker_fresh(marker, minutes)` call site resolves by
407
+ # import. Previously five independent `30` literals (pre-write.py,
408
+ # pre-edit.py, _bashguardlib.py, git-enforce.py, _protectedstatelib.py) with
409
+ # no shared source, so a change to one never propagated to the others and
410
+ # nothing went red. All five call sites use this exact marker-freshness
411
+ # shape (a marker file minted immediately before a gated action and checked
412
+ # fresh at the point of use) with no documented reason to diverge, so this is
413
+ # ONE window, not several disguised as one.
414
+ MARKER_FRESHNESS_MINUTES = 30
415
+
416
+
417
+ def marker_fresh(path, minutes):
418
+ """True if the marker file exists and was touched within `minutes`."""
419
+ try:
420
+ return os.path.isfile(path) and (time.time() - os.path.getmtime(path)) < minutes * 60
421
+ except Exception: # noqa: BLE001
422
+ return False
423
+
424
+
425
+ def _log_gate_event(kind, tag, msg):
426
+ """Best-effort durable append of one gate decision to
427
+ .codearbiter/gate-events.log (observability-001, issue #186) — the durable
428
+ sink block()/remind()/warn() funnel every BLOCK/REMIND/WARN through, so a
429
+ decision is no longer visible ONLY in the ephemeral per-turn stderr
430
+ transcript.
431
+
432
+ One line per event: `[ISO-8601Z] KIND [tag] host=<host> hook=<script> | msg`.
433
+ `tag` may be None (warn() carries no tag) — the bracket is simply omitted
434
+ then. `hook` is the invoking script's basename (`sys.argv[0]`), the one
435
+ "which hook fired this" signal available at this shared layer without
436
+ threading a new parameter through all 21 call sites across the 16 entry
437
+ hooks. `host` is `get_host().name` ("claude"/"codex"/"unknown") — added
438
+ for observability-001/ADR-0012: with three hosts now sharing one
439
+ gate-events.log (ADR-0011), a line could not be attributed to the host
440
+ that wrote it. Placed BEFORE `hook=` (both are `key=value` tokens with no
441
+ internal whitespace, so the line stays trivially greppable/parseable by
442
+ either field, and existing `hook=<script>` substring matches are
443
+ unaffected).
444
+
445
+ FAIL-OPEN BY CONTRACT (AC-2): this function must NEVER raise and must
446
+ NEVER be allowed to change the caller's exit code or suppress its stderr
447
+ output. A missing `.codearbiter/` dir, an unwritable/locked/missing log
448
+ file, project_root() itself misbehaving, or host resolution itself
449
+ misbehaving are ALL swallowed silently here — the ONE deliberate
450
+ exception to this module's fail-loud discipline, mirroring the documented
451
+ fail-open exception in read_input()."""
452
+ try:
453
+ root = project_root()
454
+ cad = os.path.join(root, ".codearbiter")
455
+ if not os.path.isdir(cad):
456
+ return # repo never opted in (no .codearbiter/) — nothing to append to
457
+ ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
458
+ hook = os.path.basename(sys.argv[0]) if sys.argv and sys.argv[0] else "-"
459
+ try:
460
+ host = get_host().name
461
+ except Exception: # noqa: BLE001 — host resolution must never break the sink
462
+ host = "unknown"
463
+ tag_part = f"[{tag}] " if tag else ""
464
+ line = f"[{ts}] {kind} {tag_part}host={host} hook={hook} | {msg}\n"
465
+ flags = os.O_APPEND | os.O_CREAT | os.O_WRONLY
466
+ if hasattr(os, "O_BINARY"):
467
+ flags |= os.O_BINARY
468
+ process_lock_acquired = False
469
+ if os.name == "nt":
470
+ _GATE_EVENTS_WINDOWS_LOCK.acquire()
471
+ process_lock_acquired = True
472
+ fd = None
473
+ os_lock_acquired = False
474
+ try:
475
+ fd = os.open(os.path.join(cad, "gate-events.log"), flags, 0o600)
476
+ if os.name == "nt":
477
+ import msvcrt
478
+ os.lseek(fd, 0, os.SEEK_SET)
479
+ lock_mode = getattr(msvcrt, "LK_NBLCK", msvcrt.LK_LOCK)
480
+ lock_deadline = time.monotonic() + _WINDOWS_LOCK_TIMEOUT_SECONDS
481
+ while True:
482
+ try:
483
+ msvcrt.locking(fd, lock_mode, 1)
484
+ os_lock_acquired = True
485
+ break
486
+ except OSError as exc:
487
+ if not _is_lock_contention(exc):
488
+ raise
489
+ if time.monotonic() >= lock_deadline:
490
+ raise
491
+ time.sleep(_WINDOWS_LOCK_RETRY_SECONDS)
492
+ os.write(fd, line.encode("utf-8"))
493
+ finally:
494
+ if os.name == "nt" and os_lock_acquired:
495
+ try:
496
+ os.lseek(fd, 0, os.SEEK_SET)
497
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
498
+ except Exception: # noqa: BLE001 — outer sink remains fail-open
499
+ pass
500
+ if process_lock_acquired:
501
+ _GATE_EVENTS_WINDOWS_LOCK.release()
502
+ if fd is not None:
503
+ os.close(fd)
504
+ except Exception: # noqa: BLE001 — fail-open: the sink must never affect the gate
505
+ pass
506
+
507
+
508
+ def block(tag, msg):
509
+ """BLOCK the tool call: stderr is surfaced to Claude, exit 2."""
510
+ _log_gate_event("BLOCK", tag, msg)
511
+ print(f"BLOCKED [{tag}]: {msg}", file=sys.stderr)
512
+ sys.exit(2)
513
+
514
+
515
+ def remind(tag, msg):
516
+ """Non-blocking nudge to stderr."""
517
+ _log_gate_event("REMIND", tag, msg)
518
+ print(f"REMINDER [{tag}]: {msg}", file=sys.stderr)
519
+
520
+
521
+ def warn(msg):
522
+ """Loud degradation/diagnostic breadcrumb — never silent."""
523
+ _log_gate_event("WARN", None, msg)
524
+ print(f"codeArbiter hook: {msg}", file=sys.stderr)
525
+
526
+
527
+ # --- CONFIRM-09: audit-trail completeness staleness-warn ---------------------
528
+ # The H-05 guards above are INTEGRITY controls (a written audit line can't be
529
+ # rewritten/deleted) — they don't compel a write in the first place. This is
530
+ # the accepted-strategy (a) completeness half (security-controls.md § Audit
531
+ # trail, 2026-07-02): a lightweight WARN, never a gate, surfaced when an
532
+ # active long-running flow's marker has sat around past `window_minutes` with
533
+ # no matching activity in its expected audit log.
534
+ #
535
+ # Only /dev and /sprint have a persistent "in-progress" marker today
536
+ # (.codearbiter/.markers/dev-active and .codearbiter/sprint-active — the same
537
+ # state _arbiterstatelib.dev_active()/arbiter_state() already read). /override
538
+ # is a single synchronous action (announce-then-log in one turn, per
539
+ # override.md) with no analogous "still in progress" marker anywhere in the
540
+ # framework, so per CONFIRM-09's own "do not invent new state" constraint it
541
+ # is not tracked here — there is no existing signal to detect it from.
542
+ #
543
+ # #271 C-5: this staleness WARN is presence + age based (marker mtime vs. an
544
+ # audit-log write), which is unaffected by session-start.py's newer
545
+ # session-scoped CLEARING decision for the SAME dev-active marker — the two
546
+ # consumers ask different questions ("has this sat around too long with no
547
+ # matching log activity?" vs. "am I sure enough this belongs to nobody live
548
+ # right now that I should force-close it?") and neither needs to agree with
549
+ # the other's answer. A dev marker owned by a still-live different session
550
+ # can legitimately trip THIS warning (it really has been open a while) even
551
+ # though session-start.py correctly declines to clobber it.
552
+ _STALE_FLOWS = (
553
+ # (flow name, marker path parts, expected-log path parts)
554
+ ("dev", (".markers", "dev-active"), ("overrides.log",)),
555
+ ("sprint", ("sprint-active",), ("sprint-log.md",)),
556
+ )
557
+
558
+
559
+ def staleness_warning(root, now=None, window_minutes=30):
560
+ """(CONFIRM-09) One WARN message per active flow (see _STALE_FLOWS) whose
561
+ marker has existed for at least `window_minutes` with no audit-log
562
+ activity (marker touch OR log write) inside that same window. Returns []
563
+ when nothing is stale (including when no flow is active at all).
564
+
565
+ WARN-ONLY BY CONTRACT: this function only computes strings — it has no
566
+ side effects, never calls warn()/block() itself, and can NEVER raise (any
567
+ per-flow stat failure just skips that flow, exactly like marker_fresh's
568
+ own degrade-to-False). The caller decides whether to surface the result,
569
+ typically via warn(), which is itself non-blocking."""
570
+ now = time.time() if now is None else now
571
+ cad = os.path.join(root, ".codearbiter")
572
+ messages = []
573
+ for name, marker_parts, log_parts in _STALE_FLOWS:
574
+ try:
575
+ marker = os.path.join(cad, *marker_parts)
576
+ if not os.path.isfile(marker):
577
+ continue
578
+ marker_mtime = os.path.getmtime(marker)
579
+ if now - marker_mtime < window_minutes * 60:
580
+ continue # flow started too recently to call it stale yet
581
+ log_path = os.path.join(cad, *log_parts)
582
+ try:
583
+ log_mtime = os.path.getmtime(log_path)
584
+ except OSError:
585
+ log_mtime = 0 # log never written at all -> definitely stale
586
+ last_activity = max(marker_mtime, log_mtime)
587
+ if now - last_activity >= window_minutes * 60:
588
+ messages.append(
589
+ f"/{name} has been active for over {window_minutes} min with no "
590
+ f"matching {os.path.basename(log_path)} entry since — confirm the "
591
+ f"expected audit line landed (CONFIRM-09)."
592
+ )
593
+ except Exception: # noqa: BLE001 — never raise; skip this flow, not the caller
594
+ continue
595
+ return messages