@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.
- package/LICENSE +661 -0
- package/README.md +558 -0
- package/package.json +35 -0
- package/plugins/ca-pi/CHANGELOG.md +1030 -0
- package/plugins/ca-pi/COMMANDS.md +90 -0
- package/plugins/ca-pi/ORCHESTRATOR.md +159 -0
- package/plugins/ca-pi/SKILLS.md +47 -0
- package/plugins/ca-pi/SPRINT.md +142 -0
- package/plugins/ca-pi/agents/INDEX.md +31 -0
- package/plugins/ca-pi/agents/architecture-drift-reviewer.md +86 -0
- package/plugins/ca-pi/agents/auth-crypto-reviewer.md +60 -0
- package/plugins/ca-pi/agents/backend-author.md +60 -0
- package/plugins/ca-pi/agents/checkpoint-aggregator.md +111 -0
- package/plugins/ca-pi/agents/coverage-auditor.md +71 -0
- package/plugins/ca-pi/agents/decision-challenger.md +116 -0
- package/plugins/ca-pi/agents/dependency-reviewer.md +79 -0
- package/plugins/ca-pi/agents/design-quality-reviewer.md +80 -0
- package/plugins/ca-pi/agents/finding-triage.md +86 -0
- package/plugins/ca-pi/agents/frontend-author.md +64 -0
- package/plugins/ca-pi/agents/grader.md +173 -0
- package/plugins/ca-pi/agents/infra-author.md +64 -0
- package/plugins/ca-pi/agents/map-deps.md +35 -0
- package/plugins/ca-pi/agents/map-structure.md +37 -0
- package/plugins/ca-pi/agents/migration-reviewer.md +65 -0
- package/plugins/ca-pi/agents/scout.md +127 -0
- package/plugins/ca-pi/agents/security-reviewer.md +72 -0
- package/plugins/ca-pi/agents/tribunal-lens-reviewer.md +65 -0
- package/plugins/ca-pi/extensions/codearbiter-child.js +1885 -0
- package/plugins/ca-pi/extensions/codearbiter.js +9802 -0
- package/plugins/ca-pi/generated/command-catalog.json +197 -0
- package/plugins/ca-pi/generated/roles.json +213 -0
- package/plugins/ca-pi/helpers/windows-supervisor.js +205 -0
- package/plugins/ca-pi/hooks/_activationlib.py +196 -0
- package/plugins/ca-pi/hooks/_arbiterstatelib.py +208 -0
- package/plugins/ca-pi/hooks/_babysitlib.py +76 -0
- package/plugins/ca-pi/hooks/_bashguardlib.py +1667 -0
- package/plugins/ca-pi/hooks/_boxlib.py +131 -0
- package/plugins/ca-pi/hooks/_colorlib.py +304 -0
- package/plugins/ca-pi/hooks/_durabilitylib.py +186 -0
- package/plugins/ca-pi/hooks/_entrylib.py +41 -0
- package/plugins/ca-pi/hooks/_fmtlib.py +161 -0
- package/plugins/ca-pi/hooks/_gitexec.py +45 -0
- package/plugins/ca-pi/hooks/_githooks.py +920 -0
- package/plugins/ca-pi/hooks/_gitlib.py +110 -0
- package/plugins/ca-pi/hooks/_hooklib.py +595 -0
- package/plugins/ca-pi/hooks/_host.py +115 -0
- package/plugins/ca-pi/hooks/_intentlib.py +242 -0
- package/plugins/ca-pi/hooks/_ledgerlib.py +1035 -0
- package/plugins/ca-pi/hooks/_metricslib.py +709 -0
- package/plugins/ca-pi/hooks/_pathnorm.py +74 -0
- package/plugins/ca-pi/hooks/_planfilelib.py +664 -0
- package/plugins/ca-pi/hooks/_previewlib.py +193 -0
- package/plugins/ca-pi/hooks/_protectedlib.py +312 -0
- package/plugins/ca-pi/hooks/_protectedstatelib.py +411 -0
- package/plugins/ca-pi/hooks/_provenancelib.py +971 -0
- package/plugins/ca-pi/hooks/_prunelib.py +1398 -0
- package/plugins/ca-pi/hooks/_prunepolicy.py +235 -0
- package/plugins/ca-pi/hooks/_readinjectlib.py +1080 -0
- package/plugins/ca-pi/hooks/_releaselib.py +2657 -0
- package/plugins/ca-pi/hooks/_scopelib.py +262 -0
- package/plugins/ca-pi/hooks/_segmentslib.py +278 -0
- package/plugins/ca-pi/hooks/_sensitivelib.py +270 -0
- package/plugins/ca-pi/hooks/_sessionlib.py +78 -0
- package/plugins/ca-pi/hooks/_sloplib.py +244 -0
- package/plugins/ca-pi/hooks/_standuplib.py +214 -0
- package/plugins/ca-pi/hooks/_subagentslib.py +219 -0
- package/plugins/ca-pi/hooks/_taskboardlib.py +1088 -0
- package/plugins/ca-pi/hooks/_updatelib.py +278 -0
- package/plugins/ca-pi/hooks/babysit.py +47 -0
- package/plugins/ca-pi/hooks/boardsync.py +129 -0
- package/plugins/ca-pi/hooks/doctor.py +420 -0
- package/plugins/ca-pi/hooks/git-enforce.py +325 -0
- package/plugins/ca-pi/hooks/hostapi.py +460 -0
- package/plugins/ca-pi/hooks/init-codearbiter.py +225 -0
- package/plugins/ca-pi/hooks/metrics.py +62 -0
- package/plugins/ca-pi/hooks/migration-pass.py +129 -0
- package/plugins/ca-pi/hooks/pi-bridge.py +543 -0
- package/plugins/ca-pi/hooks/post-write-edit.py +231 -0
- package/plugins/ca-pi/hooks/pre-bash.py +90 -0
- package/plugins/ca-pi/hooks/pre-edit.py +284 -0
- package/plugins/ca-pi/hooks/pre-read.py +81 -0
- package/plugins/ca-pi/hooks/pre-write.py +217 -0
- package/plugins/ca-pi/hooks/preview.py +69 -0
- package/plugins/ca-pi/hooks/prune-transcript.py +232 -0
- package/plugins/ca-pi/hooks/releasehash.py +216 -0
- package/plugins/ca-pi/hooks/security-pass.py +139 -0
- package/plugins/ca-pi/hooks/session-start.py +1218 -0
- package/plugins/ca-pi/hooks/statusline.py +736 -0
- package/plugins/ca-pi/hooks/taskwrite.py +351 -0
- package/plugins/ca-pi/hooks/update-refresh.py +51 -0
- package/plugins/ca-pi/hooks/wire-statusline.py +435 -0
- package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +55 -0
- package/plugins/ca-pi/includes/anti-slop-design/color.md +43 -0
- package/plugins/ca-pi/includes/anti-slop-design/core.md +244 -0
- package/plugins/ca-pi/includes/anti-slop-design/images.md +32 -0
- package/plugins/ca-pi/includes/anti-slop-design/layout.md +45 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-cli.md +39 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-dataviz.md +43 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-diagram.md +35 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-documents.md +70 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-slides.md +30 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-web.md +39 -0
- package/plugins/ca-pi/includes/anti-slop-design/typography.md +51 -0
- package/plugins/ca-pi/includes/author-tdd-workflow.md +14 -0
- package/plugins/ca-pi/includes/compaction-charter.md +16 -0
- package/plugins/ca-pi/includes/cut-docs.md +16 -0
- package/plugins/ca-pi/includes/dev-mode.md +30 -0
- package/plugins/ca-pi/includes/farm.md +237 -0
- package/plugins/ca-pi/includes/fresh-verification.md +14 -0
- package/plugins/ca-pi/includes/harvest.md +69 -0
- package/plugins/ca-pi/includes/maturity-coverage.md +102 -0
- package/plugins/ca-pi/includes/pi-host-notes.md +69 -0
- package/plugins/ca-pi/includes/redirect.md +69 -0
- package/plugins/ca-pi/includes/reference-map.md +22 -0
- package/plugins/ca-pi/includes/review-matrix.md +14 -0
- package/plugins/ca-pi/includes/reviewer-contract.md +53 -0
- package/plugins/ca-pi/includes/routing-table.md +47 -0
- package/plugins/ca-pi/includes/security-gate-record.md +22 -0
- package/plugins/ca-pi/includes/smarts/core.md +90 -0
- package/plugins/ca-pi/includes/smarts/decision-log-format.md +56 -0
- package/plugins/ca-pi/routines/INDEX.md +32 -0
- package/plugins/ca-pi/routines/brainstorming/SKILL.md +122 -0
- package/plugins/ca-pi/routines/commit-gate/SKILL.md +151 -0
- package/plugins/ca-pi/routines/context-check/SKILL.md +85 -0
- package/plugins/ca-pi/routines/context-creation/SKILL.md +171 -0
- package/plugins/ca-pi/routines/crypto-compliance/SKILL.md +41 -0
- package/plugins/ca-pi/routines/debug/SKILL.md +99 -0
- package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +104 -0
- package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +74 -0
- package/plugins/ca-pi/routines/decision-variance/SKILL.md +147 -0
- package/plugins/ca-pi/routines/decompose/SKILL.md +168 -0
- package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +76 -0
- package/plugins/ca-pi/routines/executing-plans/SKILL.md +83 -0
- package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +91 -0
- package/plugins/ca-pi/routines/post-merge-cleanup/SKILL.md +233 -0
- package/plugins/ca-pi/routines/refactor/SKILL.md +91 -0
- package/plugins/ca-pi/routines/release/SKILL.md +315 -0
- package/plugins/ca-pi/routines/secret-handling/SKILL.md +67 -0
- package/plugins/ca-pi/routines/security-architecture/SKILL.md +63 -0
- package/plugins/ca-pi/routines/skill-author/SKILL.md +108 -0
- package/plugins/ca-pi/routines/skill-author/references/skill-template.md +58 -0
- package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +149 -0
- package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +145 -0
- package/plugins/ca-pi/routines/tdd/SKILL.md +139 -0
- package/plugins/ca-pi/routines/tribunal/SKILL.md +109 -0
- package/plugins/ca-pi/routines/tribunal/references/ai-markers.md +29 -0
- package/plugins/ca-pi/routines/tribunal/references/cost-and-models.md +64 -0
- package/plugins/ca-pi/routines/tribunal/references/finding-record.md +27 -0
- package/plugins/ca-pi/routines/tribunal/references/issue-filing.md +47 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/appsec.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/architecture.md +23 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/coverage.md +20 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/infra.md +24 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/migration.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/observability.md +21 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/performance.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/reliability.md +23 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/secrets-supply.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/test-fidelity.md +24 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/typesafety.md +21 -0
- package/plugins/ca-pi/routines/tribunal/references/report.md +19 -0
- package/plugins/ca-pi/routines/tribunal/references/schemas.md +58 -0
- package/plugins/ca-pi/routines/tribunal/references/telemetry.md +28 -0
- package/plugins/ca-pi/routines/tribunal/references/triage.md +53 -0
- package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +85 -0
- package/plugins/ca-pi/routines/writing-plans/SKILL.md +129 -0
- package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +50 -0
- package/plugins/ca-pi/skills/ca-add-dep/SKILL.md +88 -0
- package/plugins/ca-pi/skills/ca-adr/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-adr-status/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +36 -0
- package/plugins/ca-pi/skills/ca-audit/SKILL.md +51 -0
- package/plugins/ca-pi/skills/ca-btw/SKILL.md +23 -0
- package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +50 -0
- package/plugins/ca-pi/skills/ca-chore/SKILL.md +58 -0
- package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +55 -0
- package/plugins/ca-pi/skills/ca-commands/SKILL.md +21 -0
- package/plugins/ca-pi/skills/ca-commit/SKILL.md +27 -0
- package/plugins/ca-pi/skills/ca-conflict/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-context-check/SKILL.md +32 -0
- package/plugins/ca-pi/skills/ca-create-context/SKILL.md +32 -0
- package/plugins/ca-pi/skills/ca-debug/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-decompose/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-dev/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-doctor/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-feature/SKILL.md +105 -0
- package/plugins/ca-pi/skills/ca-fix/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-init/SKILL.md +56 -0
- package/plugins/ca-pi/skills/ca-metrics/SKILL.md +80 -0
- package/plugins/ca-pi/skills/ca-new-skill/SKILL.md +34 -0
- package/plugins/ca-pi/skills/ca-override/SKILL.md +72 -0
- package/plugins/ca-pi/skills/ca-pr/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-preview/SKILL.md +86 -0
- package/plugins/ca-pi/skills/ca-prune/SKILL.md +100 -0
- package/plugins/ca-pi/skills/ca-reconcile/SKILL.md +43 -0
- package/plugins/ca-pi/skills/ca-refactor/SKILL.md +43 -0
- package/plugins/ca-pi/skills/ca-release/SKILL.md +57 -0
- package/plugins/ca-pi/skills/ca-review/SKILL.md +63 -0
- package/plugins/ca-pi/skills/ca-spike/SKILL.md +41 -0
- package/plugins/ca-pi/skills/ca-sprint/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-standup/SKILL.md +112 -0
- package/plugins/ca-pi/skills/ca-status/SKILL.md +56 -0
- package/plugins/ca-pi/skills/ca-task/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-threat-model/SKILL.md +45 -0
- package/plugins/ca-pi/skills/ca-tribunal/SKILL.md +44 -0
- 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)
|