@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,278 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter — update-available notifier: shared cache/compare/fetch logic.
3
+ #
4
+ # codeArbiter ships via a third-party marketplace, which Claude Code does NOT
5
+ # auto-update by default (only official Anthropic marketplaces get that). This
6
+ # module backs a lightweight notifier so a stale install is surfaced instead of
7
+ # running forever unnoticed: it reads the installed plugin.json version, reads
8
+ # a small user-global cache of the latest published GitHub release, and — when
9
+ # the cache says a newer version exists — hands back a single notice line. Both
10
+ # SessionStart and the statusline render from that SAME cache; neither makes a
11
+ # network call on its own hot path (issue #194's constraint).
12
+ #
13
+ # The only network call this module makes (fetch_latest_tag) is invoked from
14
+ # the OFF-hot-path detached refresh (see hooks/update-refresh.py, spawned by
15
+ # session-start.py). refresh_if_stale() gates that call to at most once per
16
+ # day via the cached `checked_at`, and is fail-silent end to end: any network
17
+ # error, timeout, non-200, or unparseable body degrades to "keep the last-known
18
+ # latest" — never a traceback, never a crash of the host hook.
19
+ #
20
+ # Design principles (mirroring _ledgerlib.py / _taskboardlib.py):
21
+ # - Stdlib only (urllib, json) — no third-party dependency, ever (ADR-0004).
22
+ # - HTTPS-only fetch target (ADR-0003); a non-https url is refused outright.
23
+ # - Zero side effects at import time — no network, no file I/O on import.
24
+ # - Never raise on malformed/absent input — degrade to "no notice".
25
+ #
26
+ # Public API:
27
+ # ONE_DAY once-daily refresh interval (seconds)
28
+ # UPDATE_API_URL GitHub Releases API endpoint (module constant)
29
+ # state_path() -> str resolved cache file path (env-overridable)
30
+ # plugin_root(explicit=None) -> str the running plugin's own root directory
31
+ # installed_version(root=None, host=None) -> str|None the version in
32
+ # <root>/<host.manifest_relpath()>
33
+ # (host defaults to _hooklib.get_host())
34
+ # parse_version(s) -> tuple|None numeric-tuple parse; None if malformed/absent
35
+ # version_gt(a, b) -> bool True iff semver a > b (numeric-tuple compare)
36
+ # update_available(installed, latest) -> bool True iff latest > installed
37
+ # notice_line(installed, latest) -> str|None the SessionStart/statusline notice text
38
+ # read_state(path=None) -> dict {latest, checked_at} cache, or {} on any failure
39
+ # write_state(state, path=None) -> None atomic cache write; best-effort, never raises
40
+ # is_stale(checked_at, now, interval=ONE_DAY) -> bool True iff a refresh is due
41
+ # fetch_latest_tag(url=UPDATE_API_URL, timeout=3.0, opener=None) -> str|None HTTPS GET, fail-silent
42
+ # refresh_if_stale(now=None, fetcher=None, path=None) -> dict best-effort cache refresh
43
+
44
+ import json
45
+ import os
46
+ import re
47
+ import sys
48
+ import time
49
+ import urllib.request
50
+
51
+ # Reuse the ONE atomic-write helper defined in _hooklib.py (same rationale as
52
+ # _previewlib.py: _hooklib sits beside this file; mount its dir on sys.path the
53
+ # same way the test harness does before importing by reference).
54
+ _HOOKS_DIR = os.path.dirname(os.path.abspath(__file__))
55
+ if _HOOKS_DIR not in sys.path:
56
+ sys.path.insert(0, _HOOKS_DIR)
57
+ from _hooklib import get_host, write_text_atomic # noqa: E402 — sys.path mount above
58
+ # hostapi is not imported directly here (#257): plugin_root()/installed_version()
59
+ # resolve the Host via _hooklib.get_host() (the DI seam every entry script's
60
+ # run(host) primes via set_host()), never a fresh hostapi.load_host().
61
+
62
+ ONE_DAY = 24 * 60 * 60
63
+
64
+ # The repo's own GitHub Releases API — unauthenticated GET, HTTPS only (ADR-0003).
65
+ # The release tag equals plugin.json's version (an established repo invariant).
66
+ UPDATE_API_URL = "https://api.github.com/repos/arbiterForge/codeArbiter/releases/latest"
67
+
68
+ _VERSION_STRIP_RE = re.compile(r"^[vV]")
69
+
70
+
71
+ def state_path():
72
+ """Resolved cache file path. User-GLOBAL (~/.codearbiter/...), not project-scoped
73
+ — the notice concerns the plugin's own version, not any one project. Env-overridable
74
+ (CODEARBITER_UPDATE_STATE) for tests, mirroring _ledgerlib.ledger_path()."""
75
+ return os.environ.get("CODEARBITER_UPDATE_STATE") or \
76
+ os.path.join(os.path.expanduser("~"), ".codearbiter", "update-state.json")
77
+
78
+
79
+ def plugin_root(explicit=None):
80
+ """The running plugin's own root directory (parent of hooks/). `explicit` wins
81
+ (tests); else CLAUDE_PLUGIN_ROOT; else derived from this file's own location —
82
+ always resolves to the ACTUAL running install, not a stale env pin. The env
83
+ + file-relative resolution lives on the host seam (hostapi, ADR-0011).
84
+ Resolves via get_host() (#257), not a direct hostapi.load_host(), so a
85
+ caller reached from an entry script's run(host) sees the SAME injected
86
+ Host instead of triggering a second disk load."""
87
+ return explicit or get_host().plugin_root()
88
+
89
+
90
+ def installed_version(root=None, host=None):
91
+ """The `version` field from <root>/<host.manifest_relpath()>, or None on any
92
+ failure (missing file, corrupt JSON, missing/blank field). `host` defaults to
93
+ get_host() (#263, reliability-002/observability-003; #257 — get_host(), not
94
+ a direct hostapi.load_host(), so this resolves the SAME injected instance):
95
+ under Claude Code that resolves to `.claude-plugin/plugin.json` exactly as
96
+ before; a plugin whose manifest ships elsewhere (e.g. ca-codex's
97
+ `.codex-plugin/`) resolves the CORRECT path instead of silently reading
98
+ nothing and suppressing the update-available notice forever. No caller in
99
+ this repo threads a host through today (session-start.py calls
100
+ installed_version(plugin) positionally), so resolving it here — rather
101
+ than plumbing it through every call site — keeps the fix local to this
102
+ seam."""
103
+ root = root or plugin_root()
104
+ host = host or get_host()
105
+ try:
106
+ with open(os.path.join(root, host.manifest_relpath()),
107
+ encoding="utf-8") as f:
108
+ data = json.load(f)
109
+ except (OSError, ValueError):
110
+ return None
111
+ v = data.get("version") if isinstance(data, dict) else None
112
+ return v if isinstance(v, str) and v.strip() else None
113
+
114
+
115
+ def parse_version(s):
116
+ """Parse a version string into a numeric tuple, e.g. "v2.10.0+build.5" -> (2, 10, 0).
117
+ A leading 'v', build metadata (+...), and a prerelease suffix (-...) are tolerated
118
+ and stripped. Returns None for anything that isn't a dotted run of digits (AC-6:
119
+ malformed/absent -> None, so the caller yields no notice)."""
120
+ if not isinstance(s, str):
121
+ return None
122
+ s = _VERSION_STRIP_RE.sub("", s.strip())
123
+ s = s.split("+", 1)[0]
124
+ s = s.split("-", 1)[0]
125
+ if not s:
126
+ return None
127
+ parts = s.split(".")
128
+ if not parts or not all(p.isdigit() for p in parts):
129
+ return None
130
+ return tuple(int(p) for p in parts)
131
+
132
+
133
+ def version_gt(a, b):
134
+ """True iff semver `a` > `b`, numeric-tuple compared (2.10.0 > 2.9.0, never a
135
+ lexicographic string compare). Either side malformed/absent -> False (AC-6)."""
136
+ ta, tb = parse_version(a), parse_version(b)
137
+ if ta is None or tb is None:
138
+ return False
139
+ n = max(len(ta), len(tb))
140
+ ta = ta + (0,) * (n - len(ta))
141
+ tb = tb + (0,) * (n - len(tb))
142
+ return ta > tb
143
+
144
+
145
+ def update_available(installed, latest):
146
+ """True iff `latest` is a well-formed version strictly greater than `installed`."""
147
+ return version_gt(latest, installed)
148
+
149
+
150
+ def notice_line(installed, latest):
151
+ """The single-line SessionStart/statusline notice, or None when no update is due
152
+ (AC-1/AC-2): `codeArbiter: update available X -> Y (run /plugin marketplace update
153
+ codearbiter)`. Never multi-line; never emitted for equal, lesser, missing, or
154
+ malformed `latest`."""
155
+ if not update_available(installed, latest):
156
+ return None
157
+ return (f"codeArbiter: update available {installed} -> {latest} "
158
+ f"(run /plugin marketplace update codearbiter)")
159
+
160
+
161
+ def read_state(path=None):
162
+ """The cached `{latest, checked_at}` state, or {} on ANY failure (missing file,
163
+ corrupt JSON, non-dict content) — a corrupt cache degrades to 'no notice', never
164
+ a crash of the host hook."""
165
+ path = path or state_path()
166
+ try:
167
+ with open(path, encoding="utf-8") as f:
168
+ data = json.load(f)
169
+ except (OSError, ValueError):
170
+ return {}
171
+ return data if isinstance(data, dict) else {}
172
+
173
+
174
+ def write_state(state, path=None):
175
+ """Atomically persist `state` to the cache file, creating parent dirs as needed.
176
+ Best-effort: ANY failure (permissions, missing/blocked parent) is swallowed — a
177
+ cache write must never crash the caller (the detached refresh, or a test)."""
178
+ path = path or state_path()
179
+ try:
180
+ os.makedirs(os.path.dirname(path), exist_ok=True)
181
+ write_text_atomic(path, json.dumps(state), newline="\n")
182
+ except Exception: # noqa: BLE001 — best-effort cache write, never raise
183
+ pass
184
+
185
+
186
+ def is_stale(checked_at, now, interval=ONE_DAY):
187
+ """True iff a refresh is due: no prior check, or `interval` seconds have elapsed
188
+ since `checked_at`. A malformed `checked_at` is treated as stale (never crashes,
189
+ never wedges the gate closed)."""
190
+ if checked_at is None:
191
+ return True
192
+ try:
193
+ return (now - float(checked_at)) >= interval
194
+ except (TypeError, ValueError):
195
+ return True
196
+
197
+
198
+ class _HTTPSOnlyRedirectHandler(urllib.request.HTTPRedirectHandler):
199
+ """Refuse to follow any redirect whose target isn't `https://` (ADR-0003,
200
+ defense-in-depth). The pre-connection scheme guard in fetch_latest_tag below
201
+ only covers the INITIAL url — urllib's default opener otherwise follows a
202
+ 3xx transparently, including an https->http downgrade, without ever
203
+ re-checking the scheme. Returning None here means the redirect is NOT
204
+ handled, so urllib's error chain raises the original HTTPError instead of
205
+ silently continuing the chain over a downgraded (or otherwise non-https)
206
+ target; fetch_latest_tag's broad except then degrades that to None, same as
207
+ every other fetch failure (fail-silent, AC-5)."""
208
+
209
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
210
+ if not (isinstance(newurl, str) and newurl.lower().startswith("https://")):
211
+ return None
212
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
213
+
214
+
215
+ def _build_opener():
216
+ """Factory for the HTTPS-only-redirect opener. A thin seam — not called
217
+ directly by fetch_latest_tag's default path only, but exposed as a factory
218
+ (rather than a module-level singleton) so `opener=` injection in tests never
219
+ has to touch real urllib internals."""
220
+ return urllib.request.build_opener(_HTTPSOnlyRedirectHandler())
221
+
222
+
223
+ def fetch_latest_tag(url=UPDATE_API_URL, timeout=3.0, opener=None):
224
+ """GET the GitHub Releases API and return `tag_name`, or None on ANY problem
225
+ (AC-5): non-https url, network error, timeout, non-200, an unparseable/absent
226
+ body, or a redirect to a non-https target. HTTPS-only per ADR-0003 — a
227
+ non-https INITIAL url is refused before any connection is attempted, and a
228
+ non-https REDIRECT target is refused too (via `_HTTPSOnlyRedirectHandler`,
229
+ since urllib's default opener would otherwise follow an https->http
230
+ downgrade transparently). `opener` is injectable (tests); production builds
231
+ the hardened opener via `_build_opener()`."""
232
+ if not isinstance(url, str) or not url.lower().startswith("https://"):
233
+ return None
234
+ try:
235
+ req = urllib.request.Request(url, headers={
236
+ "User-Agent": "codeArbiter-update-check",
237
+ "Accept": "application/vnd.github+json",
238
+ })
239
+ op = opener or _build_opener()
240
+ with op.open(req, timeout=timeout) as resp:
241
+ status = getattr(resp, "status", None) or getattr(resp, "code", None)
242
+ if status != 200:
243
+ return None
244
+ body = resp.read()
245
+ data = json.loads(body.decode("utf-8", "replace"))
246
+ tag = data.get("tag_name") if isinstance(data, dict) else None
247
+ return tag.strip() if isinstance(tag, str) and tag.strip() else None
248
+ except Exception: # noqa: BLE001 — AC-5: fail-silent on any network/parse error
249
+ return None
250
+
251
+
252
+ def refresh_if_stale(now=None, fetcher=None, path=None):
253
+ """Best-effort, once-daily, fail-silent cache refresh (AC-3/AC-4/AC-5).
254
+
255
+ Reads the cache; if `checked_at` is still fresh (is_stale() False), returns it
256
+ UNCHANGED and calls the fetcher NOT AT ALL (AC-4 — at most one fetch per day).
257
+ Otherwise calls `fetcher()` (default fetch_latest_tag): on success the new
258
+ `latest` is cached; on ANY exception or a None/falsy return, the PRIOR `latest`
259
+ is preserved (fail-silent — a network hiccup never blanks a known-good notice)
260
+ and `checked_at` still advances, so a persistently-unreachable network is not
261
+ retried every single session that day. Never raises (AC-3)."""
262
+ now = time.time() if now is None else now
263
+ path = path or state_path()
264
+ state = read_state(path)
265
+ checked_at = state.get("checked_at") if isinstance(state, dict) else None
266
+ if not is_stale(checked_at, now):
267
+ return state
268
+ fetch = fetcher or fetch_latest_tag
269
+ try:
270
+ latest = fetch()
271
+ except Exception: # noqa: BLE001 — AC-3/AC-5: never propagate a fetch failure
272
+ latest = None
273
+ new_state = {
274
+ "latest": latest if latest else state.get("latest"),
275
+ "checked_at": now,
276
+ }
277
+ write_state(new_state, path)
278
+ return new_state
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter — thin entry point for the pr-babysitter config resolver
3
+ # (architecture-006/#179).
4
+ #
5
+ # Wraps _babysitlib.main (the CLI shim over babysit_config) so /ca:pr and
6
+ # /ca:watch invoke a thin, non-underscore entry hook instead of running the
7
+ # underscore library itself as a script. Mirrors doctor.py / taskwrite.py:
8
+ # entry point stays thin, all resolution logic lives in _babysitlib.
9
+ #
10
+ # Invoked by command prose as (interpreter resolved once by presence, never a
11
+ # `python3 X || python X` fold -- #577):
12
+ # "$PY" "<plugin>/hooks/babysit.py" --root "<dir>"
13
+ #
14
+ # Prints one JSON line, e.g. {"enabled": true, "on_red": "propose"}. Fail-safe:
15
+ # _babysitlib.main() itself never raises past its own try/except (any resolver
16
+ # error degrades to the OFF default) — this wrapper adds no additional risk.
17
+
18
+ import os
19
+ import sys
20
+
21
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
22
+ import hostapi # noqa: E402 — host seam (ADR-0011)
23
+ import _hooklib # noqa: E402 — set_host DI seam (#257)
24
+ import _entrylib # noqa: E402 — shared run() dispatch (jscpd dedup)
25
+ import _babysitlib # noqa: E402
26
+
27
+
28
+ def main(argv=None):
29
+ return _babysitlib.main(argv)
30
+
31
+
32
+ def run(host, argv=None):
33
+ """Host-seam entry point (ADR-0011): the __main__ guard calls this with the
34
+ plugin's loaded Host. Delegates to main(argv) and returns its exit code,
35
+ exactly as the old `sys.exit(main())` guard propagated it.
36
+
37
+ Wires `host` live (#257): primes `_hooklib`'s process-cached Host via
38
+ `set_host()` BEFORE main() runs, so any `get_host()` call downstream
39
+ resolves to the SAME instance the caller passed here — no second
40
+ `hostapi.load_host()`, and `run(fake_host)` genuinely exercises
41
+ `fake_host`."""
42
+ return _entrylib.dispatch(host, argv, main, _hooklib.set_host,
43
+ pass_argv=True, propagate_result=True)
44
+
45
+
46
+ if __name__ == "__main__":
47
+ sys.exit(run(hostapi.load_host()) or 0)
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter — board-drift reconcile entrypoint (advisory, read-only sweep).
3
+ #
4
+ # Surfaces drift between task-board state and merged-commit references.
5
+ # Writes nothing — open-tasks.md is never opened for writing by this module.
6
+ #
7
+ # Design invariants:
8
+ # - reconcile_report(board_text, log_text, today) -> str is pure; never raises.
9
+ # - main(argv) is the thin I/O shell: reads board read-only via read_board(),
10
+ # reads merged-commit text from stdin (the caller pipes `git log` output — no
11
+ # subprocess in this module, mirroring how _standuplib stays subprocess-free),
12
+ # then calls reconcile_report and prints the result.
13
+ # - Stdlib only. No subprocess calls. No write paths of any kind.
14
+ #
15
+ # Public API:
16
+ # reconcile_report(board_text, log_text, today) -> str
17
+ # main(argv=None)
18
+
19
+ import argparse
20
+ import datetime
21
+ import os
22
+ import sys
23
+
24
+ # Handles both "run as script" and "imported from the tests/ sub-dir": ensures
25
+ # the hooks package directory is on sys.path so _taskboardlib resolves cleanly
26
+ # regardless of cwd or how Python was invoked.
27
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
28
+ import hostapi # noqa: E402 — host seam (ADR-0011)
29
+ import _hooklib # noqa: E402 — set_host DI seam (#257)
30
+ import _entrylib # noqa: E402 — shared run() dispatch (jscpd dedup)
31
+ import _taskboardlib as tb # noqa: E402
32
+
33
+
34
+ def reconcile_report(board_text, log_text, today):
35
+ """Return a human-readable advisory drift report string.
36
+
37
+ Calls extract_task_ids(log_text), then find_board_drift against board_text,
38
+ and formats the result: drifted tasks (id + state) and unknown ids, or a
39
+ clean 'no drift detected' line when nothing needs attention.
40
+
41
+ Pure — never raises. None/empty inputs degrade to a clean (empty) report.
42
+
43
+ Drifted = task whose board state is not [x] but whose id appeared in the
44
+ merged-commit log (work landed, board not flipped). Unknown = id in the log
45
+ absent from the board entirely (informational only; never treated as drift).
46
+ """
47
+ try:
48
+ merged_ids = tb.extract_task_ids(log_text or "")
49
+ result = tb.find_board_drift(board_text or "", merged_ids, today)
50
+ lines = [f"Board drift sweep — {result.observed.isoformat()}"]
51
+ if not result.drifted and not result.unknown:
52
+ lines.append(" no drift detected")
53
+ else:
54
+ if result.drifted:
55
+ lines.append(" DRIFTED (merged but not [x]):")
56
+ for t in result.drifted:
57
+ lines.append(f" {t.id} state={t.state}")
58
+ if result.unknown:
59
+ lines.append(" UNKNOWN ids (in log but not on board):")
60
+ for uid in result.unknown:
61
+ lines.append(f" {uid}")
62
+ return "\n".join(lines)
63
+ except Exception: # noqa: BLE001
64
+ return "Board drift sweep — error during report (input may be malformed)"
65
+
66
+
67
+ def main(argv=None):
68
+ """Thin I/O entrypoint for the board-drift advisory sweep.
69
+
70
+ Reads the board file read-only via _taskboardlib.read_board (never opens it
71
+ for writing). Reads merged-commit text from stdin. Prints the advisory report
72
+ produced by reconcile_report. Writes nothing.
73
+
74
+ Subcommands:
75
+ reconcile [--board PATH] surface drift; board is never written.
76
+ """
77
+ if argv is None:
78
+ argv = sys.argv[1:]
79
+
80
+ parser = argparse.ArgumentParser(
81
+ prog="boardsync",
82
+ description="Advisory board-drift sweep — never writes open-tasks.md.",
83
+ )
84
+ sub = parser.add_subparsers(dest="command")
85
+
86
+ rec = sub.add_parser("reconcile", help="surface board drift (read-only)")
87
+ rec.add_argument(
88
+ "--board",
89
+ default=".codearbiter/open-tasks.md",
90
+ metavar="PATH",
91
+ help="path to open-tasks.md (default: .codearbiter/open-tasks.md)",
92
+ )
93
+
94
+ args = parser.parse_args(argv)
95
+
96
+ if args.command == "reconcile":
97
+ # Defense-in-depth: honor the module's crash-safe invariant for the I/O
98
+ # path too (read_board / stdin), not just reconcile_report — a malformed
99
+ # --board or a stdin read error degrades to the advisory report, never a
100
+ # traceback. (Security review 2026-06-26, LOW.)
101
+ today = datetime.date.today()
102
+ try:
103
+ board_text = tb.read_board(args.board)
104
+ log_text = sys.stdin.read()
105
+ report = reconcile_report(board_text or "", log_text, today)
106
+ except Exception:
107
+ report = reconcile_report("", "", today)
108
+ print(report)
109
+ else:
110
+ parser.print_help()
111
+
112
+
113
+ def run(host, argv=None):
114
+ """Host-seam entry point (ADR-0011): the __main__ guard calls this with the
115
+ plugin's loaded Host. Wraps main(argv) unchanged — main()'s return value
116
+ stays discarded exactly as the old bare `main()` guard discarded it (so
117
+ the process still exits 0 on a normal fall-through).
118
+
119
+ Wires `host` live (#257): primes `_hooklib`'s process-cached Host via
120
+ `set_host()` BEFORE main() runs, so any `get_host()` call downstream
121
+ resolves to the SAME instance the caller passed here — no second
122
+ `hostapi.load_host()`, and `run(fake_host)` genuinely exercises
123
+ `fake_host`."""
124
+ return _entrylib.dispatch(host, argv, main, _hooklib.set_host,
125
+ pass_argv=True, propagate_result=False)
126
+
127
+
128
+ if __name__ == "__main__":
129
+ sys.exit(run(hostapi.load_host()) or 0)