@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,131 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter — statusline box-drawing layout (extracted from statusline.py,
|
|
3
|
+
# architecture-004).
|
|
4
|
+
#
|
|
5
|
+
# Owns the sectioned box shell (top/row/sep/bottom) and the two small layout
|
|
6
|
+
# helpers (left+right justify, equal-width columns) segments use to lay content
|
|
7
|
+
# out inside a row. Carries no segment CONTENT — purely the box structure and
|
|
8
|
+
# column math, built on the ANSI/width-aware primitives in _colorlib.
|
|
9
|
+
#
|
|
10
|
+
# Design principles (mirroring _ledgerlib.py):
|
|
11
|
+
# - Stdlib only; no third-party imports ever.
|
|
12
|
+
# - Zero side effects at import time.
|
|
13
|
+
#
|
|
14
|
+
# Public API:
|
|
15
|
+
# Box(width) the box shell: .top/.row/.sep/.bottom/.render
|
|
16
|
+
# lr(left, right, inner) -> str left+right justified within inner
|
|
17
|
+
# cols(cells, inner) -> (str, list) equal-column layout with tee-aligned separators
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
import _colorlib
|
|
21
|
+
_RESET = _colorlib.RESET
|
|
22
|
+
_V0 = _colorlib.V0
|
|
23
|
+
_TL, _TR, _BL, _BR = _colorlib.TL, _colorlib.TR, _colorlib.BL, _colorlib.BR
|
|
24
|
+
_H, _V, _LT, _RT = _colorlib.H, _colorlib.V, _colorlib.LT, _colorlib.RT
|
|
25
|
+
_TD, _TU, _DOTH = _colorlib.TD, _colorlib.TU, _colorlib.DOTH
|
|
26
|
+
_pad, _vlen, _clip, _gradient_h = (_colorlib.pad, _colorlib.vlen,
|
|
27
|
+
_colorlib.clip, _colorlib.gradient_h)
|
|
28
|
+
_box_gradient_h = _colorlib.box_gradient_h
|
|
29
|
+
_ANSI = _colorlib.ANSI
|
|
30
|
+
except Exception: # pragma: no cover — never let an import break the statusline
|
|
31
|
+
_colorlib = None
|
|
32
|
+
_RESET = _V0 = ""
|
|
33
|
+
_TL = _TR = _BL = _BR = _H = _V = _LT = _RT = _TD = _TU = _DOTH = ""
|
|
34
|
+
|
|
35
|
+
def _pad(s, w):
|
|
36
|
+
return s
|
|
37
|
+
|
|
38
|
+
def _vlen(s):
|
|
39
|
+
return len(s)
|
|
40
|
+
|
|
41
|
+
def _clip(s, w):
|
|
42
|
+
return s[:w]
|
|
43
|
+
|
|
44
|
+
def _gradient_h(text, width, c_from=None, c_to=None):
|
|
45
|
+
return text
|
|
46
|
+
|
|
47
|
+
_box_gradient_h = _gradient_h
|
|
48
|
+
|
|
49
|
+
import re as _re
|
|
50
|
+
_ANSI = _re.compile(r"\033\[[0-9;]*m")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def sync_palette():
|
|
54
|
+
"""Refresh captured color exports after _colorlib runtime activation."""
|
|
55
|
+
global _RESET, _V0, _gradient_h, _box_gradient_h
|
|
56
|
+
if _colorlib is not None:
|
|
57
|
+
_RESET, _V0 = _colorlib.RESET, _colorlib.V0
|
|
58
|
+
_gradient_h = _colorlib.gradient_h
|
|
59
|
+
_box_gradient_h = _colorlib.box_gradient_h
|
|
60
|
+
|
|
61
|
+
class Box:
|
|
62
|
+
def __init__(self, width):
|
|
63
|
+
self.W = width
|
|
64
|
+
self.inner = width - 4 # "│ " + content + " │"
|
|
65
|
+
self.lines = []
|
|
66
|
+
|
|
67
|
+
def top(self, title, badge=""):
|
|
68
|
+
left = f"{_V0}{_TL}{_H}{_H}{_RESET} " # 4 visible cols
|
|
69
|
+
b = f" {badge} " if badge else " "
|
|
70
|
+
budget = max(1, self.W - 5 - _vlen(b)) # keep room for ╮ + >=0 fill
|
|
71
|
+
title = _clip(title, budget) if _vlen(title) > budget else title
|
|
72
|
+
t = _gradient_h(_ANSI.sub("", title), _vlen(title))
|
|
73
|
+
used = 3 + _vlen(title) + _vlen(b)
|
|
74
|
+
fillw = max(0, self.W - 1 - used - 1)
|
|
75
|
+
fill = _box_gradient_h(_H * fillw, fillw) if fillw else ""
|
|
76
|
+
self.lines.append(f"{left}{t}{b}{fill}{_V0}{_TR}{_RESET}")
|
|
77
|
+
|
|
78
|
+
def row(self, content):
|
|
79
|
+
self.lines.append(f"{_V0}{_V}{_RESET} {_pad(content, self.inner)} {_V0}{_V}{_RESET}")
|
|
80
|
+
|
|
81
|
+
def sep(self, tees=None):
|
|
82
|
+
mid = [_DOTH] * (self.inner + 2)
|
|
83
|
+
for k in (tees or []):
|
|
84
|
+
if 0 <= k + 1 < len(mid):
|
|
85
|
+
mid[k + 1] = _TD
|
|
86
|
+
self.lines.append(f"{_V0}{_LT}{''.join(mid)}{_RT}{_RESET}")
|
|
87
|
+
|
|
88
|
+
def bottom(self, tees=None):
|
|
89
|
+
mid = [_H] * (self.inner + 2)
|
|
90
|
+
for k in (tees or []):
|
|
91
|
+
if 0 <= k + 1 < len(mid):
|
|
92
|
+
mid[k + 1] = _TU
|
|
93
|
+
self.lines.append(f"{_V0}{_BL}{''.join(mid)}{_BR}{_RESET}")
|
|
94
|
+
|
|
95
|
+
def render(self):
|
|
96
|
+
# No trailing newline: a multi-line statusline that ends in '\n' adds a
|
|
97
|
+
# phantom row, so the host's height accounting drifts on re-render and the
|
|
98
|
+
# bar eventually clears itself. Lines are separated, not terminated.
|
|
99
|
+
return "\n".join(self.lines)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def lr(left, right, inner):
|
|
103
|
+
"""Left + right justified within inner; never exceeds inner (clips left)."""
|
|
104
|
+
right = _clip(right, inner)
|
|
105
|
+
rw = _vlen(right)
|
|
106
|
+
left = _clip(left, max(0, inner - rw - 1))
|
|
107
|
+
gap = max(1, inner - _vlen(left) - rw)
|
|
108
|
+
return left + " " * gap + right
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def cols(cells, inner):
|
|
112
|
+
"""Lay out cells in equal columns with ' │ ' joins. Each cell is clamped to
|
|
113
|
+
its column via pad(), so total visible width == inner exactly and the
|
|
114
|
+
separator tees line up with the content joins. Returns (content, bounds)."""
|
|
115
|
+
n = len(cells)
|
|
116
|
+
if n == 1:
|
|
117
|
+
return _pad(cells[0], inner), []
|
|
118
|
+
seps = n - 1
|
|
119
|
+
avail = inner - seps * 3
|
|
120
|
+
base = avail // n
|
|
121
|
+
widths = [base] * n
|
|
122
|
+
widths[-1] = avail - base * (n - 1)
|
|
123
|
+
parts, bounds, pos = [], [], 0
|
|
124
|
+
for i, c in enumerate(cells):
|
|
125
|
+
parts.append(_pad(c, widths[i]))
|
|
126
|
+
pos += widths[i]
|
|
127
|
+
if i < n - 1:
|
|
128
|
+
parts.append(f" {_V0}{_V}{_RESET} ")
|
|
129
|
+
bounds.append(pos + 1)
|
|
130
|
+
pos += 3
|
|
131
|
+
return "".join(parts), bounds
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter — statusline color palette, box-drawing glyphs, and ANSI/width-aware
|
|
3
|
+
# string primitives (extracted from statusline.py, architecture-004).
|
|
4
|
+
#
|
|
5
|
+
# Owns the neon-violet palette, native-safe glyph set, the ANSI-escape regex, and
|
|
6
|
+
# the terminal-column-width-aware string ops (clip/pad/vlen) the box layout relies
|
|
7
|
+
# on to stay aligned regardless of color codes or wide (CJK/emoji) glyphs. Carries
|
|
8
|
+
# NO rendering-decision logic (no segment content, no box structure) — purely the
|
|
9
|
+
# color/glyph vocabulary and the width primitives built on it.
|
|
10
|
+
#
|
|
11
|
+
# Design principles (mirroring _ledgerlib.py):
|
|
12
|
+
# - Stdlib only; no third-party imports ever.
|
|
13
|
+
# - Zero side effects at import time.
|
|
14
|
+
# - Pure functions, fully testable with synthetic input.
|
|
15
|
+
#
|
|
16
|
+
# Public API:
|
|
17
|
+
# fg(r,g,b) -> str truecolor foreground SGR
|
|
18
|
+
# bg(r,g,b) -> str truecolor background SGR
|
|
19
|
+
# vlen(s) -> int visible terminal-column width (ANSI-aware, wide-glyph-aware)
|
|
20
|
+
# clip(s, w) -> str truncate to <= w visible columns (ANSI-preserving)
|
|
21
|
+
# pad(s, w) -> str pad/clip to exactly w visible columns
|
|
22
|
+
# gradient_h(text, width, c_from, c_to) -> str per-character violet sheen
|
|
23
|
+
# strip_control(s) -> str strip C0 control bytes + OSC/CSI escape sequences
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import unicodedata
|
|
29
|
+
from dataclasses import dataclass, replace
|
|
30
|
+
from types import MappingProxyType
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class RGB:
|
|
35
|
+
r: int
|
|
36
|
+
g: int
|
|
37
|
+
b: int
|
|
38
|
+
|
|
39
|
+
def __iter__(self):
|
|
40
|
+
return iter((self.r, self.g, self.b))
|
|
41
|
+
|
|
42
|
+
def __getitem__(self, index):
|
|
43
|
+
return (self.r, self.g, self.b)[index]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Palette:
|
|
48
|
+
accent_deep: RGB
|
|
49
|
+
accent_mid: RGB
|
|
50
|
+
accent_primary: RGB
|
|
51
|
+
accent_bright: RGB
|
|
52
|
+
text_muted: RGB
|
|
53
|
+
text_normal: RGB
|
|
54
|
+
text_on_accent: RGB
|
|
55
|
+
semantic_ok: RGB
|
|
56
|
+
semantic_warn: RGB
|
|
57
|
+
semantic_danger: RGB
|
|
58
|
+
gradient_from: RGB
|
|
59
|
+
gradient_to: RGB
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _palette(values):
|
|
63
|
+
return Palette(*(RGB(*value) for value in values))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
BUILTIN_PALETTES = MappingProxyType({
|
|
67
|
+
"violet": _palette(((108, 70, 180), (150, 92, 230), (178, 102, 255), (208, 140, 255),
|
|
68
|
+
(150, 150, 162), (232, 232, 240), (18, 14, 26),
|
|
69
|
+
(120, 220, 150), (255, 184, 76), (255, 86, 110),
|
|
70
|
+
(120, 80, 200), (205, 140, 255))),
|
|
71
|
+
"blue": _palette(((35, 91, 155), (52, 130, 205), (69, 155, 255), (125, 190, 255),
|
|
72
|
+
(145, 158, 174), (232, 240, 248), (8, 22, 36),
|
|
73
|
+
(107, 214, 158), (255, 187, 79), (255, 91, 109),
|
|
74
|
+
(40, 105, 190), (113, 190, 255))),
|
|
75
|
+
"green": _palette(((31, 112, 78), (42, 157, 103), (55, 201, 132), (111, 231, 169),
|
|
76
|
+
(142, 164, 153), (230, 242, 235), (7, 30, 20),
|
|
77
|
+
(83, 219, 139), (255, 190, 76), (255, 91, 105),
|
|
78
|
+
(33, 126, 86), (102, 225, 159))),
|
|
79
|
+
"amber": _palette(((145, 87, 20), (199, 123, 29), (240, 157, 43), (255, 199, 104),
|
|
80
|
+
(165, 154, 139), (244, 237, 226), (35, 22, 7),
|
|
81
|
+
(100, 210, 137), (255, 184, 76), (255, 83, 98),
|
|
82
|
+
(166, 96, 20), (255, 190, 76))),
|
|
83
|
+
"mono": _palette(((104, 104, 110), (145, 145, 152), (190, 190, 198), (226, 226, 232),
|
|
84
|
+
(145, 145, 152), (232, 232, 236), (18, 18, 20),
|
|
85
|
+
(199, 224, 205), (235, 213, 174), (239, 174, 181),
|
|
86
|
+
(112, 112, 118), (218, 218, 224))),
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
MAX_THEME_BYTES = 16 * 1024
|
|
90
|
+
_CUSTOM_FIELDS = MappingProxyType({
|
|
91
|
+
"accent": MappingProxyType({"deep": "accent_deep", "mid": "accent_mid",
|
|
92
|
+
"primary": "accent_primary", "bright": "accent_bright"}),
|
|
93
|
+
"text": MappingProxyType({"muted": "text_muted", "normal": "text_normal",
|
|
94
|
+
"on_accent": "text_on_accent"}),
|
|
95
|
+
"semantic": MappingProxyType({"ok": "semantic_ok", "warn": "semantic_warn",
|
|
96
|
+
"danger": "semantic_danger"}),
|
|
97
|
+
"gradient": MappingProxyType({"from": "gradient_from", "to": "gradient_to"}),
|
|
98
|
+
})
|
|
99
|
+
_HEX = re.compile(r"^#[0-9a-fA-F]{6}$")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _read_custom(path):
|
|
103
|
+
"""Read one bounded local JSON object; return None for every failure mode."""
|
|
104
|
+
try:
|
|
105
|
+
path = os.path.expanduser(os.path.expandvars(path))
|
|
106
|
+
if os.path.getsize(path) > MAX_THEME_BYTES:
|
|
107
|
+
return None
|
|
108
|
+
with open(path, "rb") as handle:
|
|
109
|
+
raw = handle.read(MAX_THEME_BYTES + 1)
|
|
110
|
+
if len(raw) > MAX_THEME_BYTES:
|
|
111
|
+
return None
|
|
112
|
+
value = json.loads(raw.decode("utf-8"))
|
|
113
|
+
return value if isinstance(value, dict) else None
|
|
114
|
+
except (OSError, UnicodeError, ValueError, TypeError, RecursionError):
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _resolve_palette(theme_name=None, custom_path=None):
|
|
119
|
+
"""Return (effective theme name, palette), falling back wholly to violet."""
|
|
120
|
+
violet = BUILTIN_PALETTES["violet"]
|
|
121
|
+
name = str(theme_name or "violet").strip().lower()
|
|
122
|
+
if name != "custom":
|
|
123
|
+
return (name, BUILTIN_PALETTES[name]) if name in BUILTIN_PALETTES else ("violet", violet)
|
|
124
|
+
path = custom_path or os.environ.get("CODEARBITER_THEME_FILE") or os.path.join(
|
|
125
|
+
"~", ".codearbiter", "statusline-theme.json")
|
|
126
|
+
data = _read_custom(path)
|
|
127
|
+
if data is None:
|
|
128
|
+
return "violet", violet
|
|
129
|
+
updates = {}
|
|
130
|
+
for group_name, fields in _CUSTOM_FIELDS.items():
|
|
131
|
+
group = data.get(group_name)
|
|
132
|
+
if not isinstance(group, dict):
|
|
133
|
+
continue
|
|
134
|
+
for key, field_name in fields.items():
|
|
135
|
+
value = group.get(key)
|
|
136
|
+
if isinstance(value, str) and _HEX.fullmatch(value):
|
|
137
|
+
updates[field_name] = RGB(int(value[1:3], 16), int(value[3:5], 16), int(value[5:7], 16))
|
|
138
|
+
return "custom", replace(violet, **updates)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def resolve_palette(theme_name=None, custom_path=None):
|
|
142
|
+
"""Resolve a built-in or partial custom palette, always falling back to violet."""
|
|
143
|
+
return _resolve_palette(theme_name, custom_path)[1]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def fg(r, g, b):
|
|
147
|
+
return f"\033[38;2;{r};{g};{b}m"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
RESET, BOLD, DIM, ITAL = "\033[0m", "\033[1m", "\033[2m", "\033[3m"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def bg(r, g, b):
|
|
154
|
+
return f"\033[48;2;{r};{g};{b}m"
|
|
155
|
+
|
|
156
|
+
# neon-violet ramp (dark -> bright) — used for the box sheen and accents
|
|
157
|
+
ACTIVE_THEME_NAME = str(os.environ.get("CODEARBITER_THEME") or "violet").strip().lower()
|
|
158
|
+
if ACTIVE_THEME_NAME not in (*BUILTIN_PALETTES, "custom"):
|
|
159
|
+
ACTIVE_THEME_NAME = "violet"
|
|
160
|
+
ACTIVE_PALETTE = (BUILTIN_PALETTES["violet"] if ACTIVE_THEME_NAME == "custom"
|
|
161
|
+
else resolve_palette(ACTIVE_THEME_NAME))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _project_palette(name, palette):
|
|
165
|
+
global ACTIVE_THEME_NAME, ACTIVE_PALETTE
|
|
166
|
+
global V0, V1, V2, V3, GREY, WHITE, OK, WARN, DANGER, PILL_FG
|
|
167
|
+
ACTIVE_THEME_NAME, ACTIVE_PALETTE = name, palette
|
|
168
|
+
V0 = fg(*palette.accent_deep)
|
|
169
|
+
V1 = fg(*palette.accent_mid)
|
|
170
|
+
V2 = fg(*palette.accent_primary)
|
|
171
|
+
V3 = fg(*palette.accent_bright)
|
|
172
|
+
GREY = fg(*palette.text_muted)
|
|
173
|
+
WHITE = fg(*palette.text_normal)
|
|
174
|
+
OK = fg(*palette.semantic_ok)
|
|
175
|
+
WARN = fg(*palette.semantic_warn)
|
|
176
|
+
DANGER = fg(*palette.semantic_danger)
|
|
177
|
+
PILL_FG = fg(*palette.text_on_accent)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def activate_palette(theme_name=None, custom_path=None):
|
|
181
|
+
"""Resolve and project the process palette at render time.
|
|
182
|
+
|
|
183
|
+
Custom themes may touch the filesystem, so callers must invoke this from a
|
|
184
|
+
runtime path rather than relying on module import. Repeated calls re-resolve
|
|
185
|
+
the current environment deterministically.
|
|
186
|
+
"""
|
|
187
|
+
name = str(theme_name if theme_name is not None else
|
|
188
|
+
(os.environ.get("CODEARBITER_THEME") or "violet")).strip().lower()
|
|
189
|
+
if name not in (*BUILTIN_PALETTES, "custom"):
|
|
190
|
+
name = "violet"
|
|
191
|
+
effective_name, palette = _resolve_palette(name, custom_path)
|
|
192
|
+
_project_palette(effective_name, palette)
|
|
193
|
+
return palette
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# Built-ins are pure data and retain legacy import-time exports. Custom stays
|
|
197
|
+
# violet until the explicit render-time activation above.
|
|
198
|
+
_project_palette(ACTIVE_THEME_NAME, ACTIVE_PALETTE)
|
|
199
|
+
|
|
200
|
+
# native-safe glyphs only
|
|
201
|
+
TL, TR, BL, BR = "╭", "╮", "╰", "╯"
|
|
202
|
+
H, V, LT, RT = "─", "│", "├", "┤"
|
|
203
|
+
TD, TU = "┬", "┴" # tee down / up (column joins)
|
|
204
|
+
DOTH = "┄" # dotted horizontal (section separators)
|
|
205
|
+
DOT = "●"
|
|
206
|
+
SUBDOT = "◦"
|
|
207
|
+
ELL = "…"
|
|
208
|
+
BFULL, BEMPTY = "█", "░"
|
|
209
|
+
DN, UP = "↓", "↑"
|
|
210
|
+
ARR = "▸"
|
|
211
|
+
BOLT = "↯"
|
|
212
|
+
SPARK = "▁▂▃▄▅▆▇█"
|
|
213
|
+
|
|
214
|
+
ANSI = re.compile(r"\033\[[0-9;]*m")
|
|
215
|
+
|
|
216
|
+
# Host-controlled strings (a model display name, a subagent's derived label)
|
|
217
|
+
# reach the render path verbatim. Neither the host payload nor a subagent
|
|
218
|
+
# transcript is a trusted source, so any C0 control byte or OSC/CSI escape
|
|
219
|
+
# sequence embedded in one is terminal-escape injection into the user's
|
|
220
|
+
# prompt line — strip it before the string is ever composed into a row.
|
|
221
|
+
_OSC_RE = re.compile(r"\033\][^\007\033]*(?:\007|\033\\)?")
|
|
222
|
+
_CSI_RE = re.compile(r"\033\[[0-?]*[ -/]*[@-~]?")
|
|
223
|
+
_C0_RE = re.compile(r"[\000-\037]")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def strip_control(s):
|
|
227
|
+
"""Strip OSC/CSI escape sequences and any remaining C0 control byte
|
|
228
|
+
(0x00-0x1f) from a host- or transcript-derived string. Non-string input
|
|
229
|
+
passes through unchanged; this must never raise."""
|
|
230
|
+
if not isinstance(s, str):
|
|
231
|
+
return s
|
|
232
|
+
s = _OSC_RE.sub("", s)
|
|
233
|
+
s = _CSI_RE.sub("", s)
|
|
234
|
+
return _C0_RE.sub("", s)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _cw(ch):
|
|
238
|
+
"""Terminal column width of one character: 0 for a combining mark, 2 for an
|
|
239
|
+
East-Asian Wide/Fullwidth glyph (CJK, many emoji), 1 otherwise. Ambiguous-
|
|
240
|
+
width glyphs (box-drawing, blocks, arrows used in this bar) render as 1 in a
|
|
241
|
+
non-CJK terminal, so they stay 1 — only genuinely wide content counts as 2."""
|
|
242
|
+
if unicodedata.combining(ch):
|
|
243
|
+
return 0
|
|
244
|
+
return 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def vlen(s):
|
|
248
|
+
"""Visible terminal-column width of s, ignoring ANSI codes and counting wide
|
|
249
|
+
glyphs as 2 columns — so the box never overflows on CJK/emoji content."""
|
|
250
|
+
return sum(_cw(c) for c in ANSI.sub("", s))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def clip(s, w):
|
|
254
|
+
"""Truncate a (possibly ANSI-colored) string to <= w visible columns,
|
|
255
|
+
preserving color codes and appending an ellipsis + RESET when cut."""
|
|
256
|
+
if w <= 0:
|
|
257
|
+
return ""
|
|
258
|
+
if vlen(s) <= w:
|
|
259
|
+
return s
|
|
260
|
+
out, vis, i, n = [], 0, 0, len(s)
|
|
261
|
+
limit = w - 1 # leave one column for the ellipsis
|
|
262
|
+
while i < n:
|
|
263
|
+
m = ANSI.match(s, i)
|
|
264
|
+
if m:
|
|
265
|
+
out.append(m.group(0))
|
|
266
|
+
i = m.end()
|
|
267
|
+
continue
|
|
268
|
+
cw = _cw(s[i])
|
|
269
|
+
if vis + cw > limit: # a wide glyph that would cross the limit stops here
|
|
270
|
+
break
|
|
271
|
+
out.append(s[i])
|
|
272
|
+
vis += cw
|
|
273
|
+
i += 1
|
|
274
|
+
return "".join(out) + ELL + RESET
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def pad(s, w):
|
|
278
|
+
"""Return s at exactly w visible columns: clip if over, space-pad if under."""
|
|
279
|
+
v = vlen(s)
|
|
280
|
+
if v > w:
|
|
281
|
+
return clip(s, w)
|
|
282
|
+
return s + " " * (w - v)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def gradient_h(text, width, c_from=None, c_to=None):
|
|
286
|
+
"""Per-character violet sheen across `width` columns (dark->bright)."""
|
|
287
|
+
c_from = ACTIVE_PALETTE.gradient_from if c_from is None else c_from
|
|
288
|
+
c_to = ACTIVE_PALETTE.gradient_to if c_to is None else c_to
|
|
289
|
+
out = []
|
|
290
|
+
n = max(1, width - 1)
|
|
291
|
+
for i, ch in enumerate(text):
|
|
292
|
+
t = i / n
|
|
293
|
+
r = int(c_from[0] + (c_to[0] - c_from[0]) * t)
|
|
294
|
+
g = int(c_from[1] + (c_to[1] - c_from[1]) * t)
|
|
295
|
+
b = int(c_from[2] + (c_to[2] - c_from[2]) * t)
|
|
296
|
+
out.append(f"\033[38;2;{r};{g};{b}m{ch}")
|
|
297
|
+
return "".join(out) + RESET
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def box_gradient_h(text, width):
|
|
301
|
+
"""Header-border sheen, retaining the original violet bytes by default."""
|
|
302
|
+
if ACTIVE_THEME_NAME == "violet":
|
|
303
|
+
return gradient_h(text, width, (90, 60, 150), (170, 110, 240))
|
|
304
|
+
return gradient_h(text, width)
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter — durable-vs-ephemeral install-root detection.
|
|
3
|
+
#
|
|
4
|
+
# WHY THIS EXISTS (found in-session on 2026-07-25, after it broke the
|
|
5
|
+
# maintainer's statusline three times in one day):
|
|
6
|
+
#
|
|
7
|
+
# A plugin cannot own a statusLine and ${CLAUDE_PLUGIN_ROOT} is NOT expanded
|
|
8
|
+
# inside settings.json, so codeArbiter has to write an ABSOLUTE, version-pinned
|
|
9
|
+
# renderer path into the user's GLOBAL ~/.claude/settings.json — and re-point it
|
|
10
|
+
# on every SessionStart so a plugin update does not leave the old version wired.
|
|
11
|
+
# That refresh had no notion of a root that will not survive the session. When a
|
|
12
|
+
# session starts inside a git worktree (subagents routinely run in
|
|
13
|
+
# <repo>/.claude/worktrees/<id>/), the plugin root IS the worktree's plugins/ca,
|
|
14
|
+
# and the heal happily pinned the user's global config there:
|
|
15
|
+
#
|
|
16
|
+
# "...\.claude\worktrees\wf_58ee3fa6-de1-8\plugins\ca\hooks\statusline.py"
|
|
17
|
+
#
|
|
18
|
+
# The worktree is then pruned — that is what worktrees are FOR — and the
|
|
19
|
+
# statusline renders nothing until someone notices and re-runs /ca:statusline.
|
|
20
|
+
#
|
|
21
|
+
# This module answers exactly one question, for callers about to write a path
|
|
22
|
+
# into a long-lived config: may this path be trusted to still be there?
|
|
23
|
+
#
|
|
24
|
+
# TWO INDEPENDENT SIGNALS, because each alone has a blind spot:
|
|
25
|
+
# 1. LEXICAL (has_worktree_segment) — a `worktrees` or `*-worktrees` path
|
|
26
|
+
# segment. Free, no I/O at all, works even when the checkout is already
|
|
27
|
+
# gone. Covers the confirmed `.claude/worktrees/<id>/` case and the
|
|
28
|
+
# `../.codearbiter-worktrees/<slug>` layout the using-git-worktrees skill
|
|
29
|
+
# creates.
|
|
30
|
+
# 2. GIT (in_linked_worktree) — the path lives in a LINKED worktree, read
|
|
31
|
+
# straight off git's own on-disk marker. Generalizes to a worktree parked
|
|
32
|
+
# anywhere under any name, which the lexical rule cannot see.
|
|
33
|
+
#
|
|
34
|
+
# NO SUBPROCESS, DELIBERATELY. This predicate is consulted on EVERY SessionStart,
|
|
35
|
+
# before the dormant gate, in every repo on the machine. An earlier draft shelled
|
|
36
|
+
# out to `git rev-parse --git-dir --git-common-dir`; that probe fails by design
|
|
37
|
+
# in the ordinary plugin-cache install (which is not a git repository at all), so
|
|
38
|
+
# it bought nothing in the common case while charging every session a process
|
|
39
|
+
# spawn plus a timeout hazard. Git writes the answer to disk anyway: a LINKED
|
|
40
|
+
# worktree's `.git` is a FILE reading `gitdir: <common>/.git/worktrees/<name>`,
|
|
41
|
+
# while a normal checkout's `.git` is a DIRECTORY. A handful of stat() calls
|
|
42
|
+
# gives the same answer, with no dependency on git being installed at all.
|
|
43
|
+
#
|
|
44
|
+
# FAILURE DIRECTION IS "DURABLE", DELIBERATELY. Anything unreadable, unparseable
|
|
45
|
+
# or absent reads as durable. Reading uncertainty as "ephemeral" would silently
|
|
46
|
+
# disable the self-heal for users whose installs are perfectly fine — a far worse
|
|
47
|
+
# outcome than the residual it protects against.
|
|
48
|
+
#
|
|
49
|
+
# The lexical rule is deliberately a little broad (any `worktrees` segment, not
|
|
50
|
+
# only one under `.claude`). The asymmetry justifies it: a false positive costs
|
|
51
|
+
# an un-refreshed pin the user fixes with one explicit /ca:statusline run, while
|
|
52
|
+
# a false negative corrupts their global config.
|
|
53
|
+
#
|
|
54
|
+
# Design principles (mirroring _gitexec.py / _gitlib.py):
|
|
55
|
+
# - Stdlib only; no third-party imports ever (ADR-0004).
|
|
56
|
+
# - Zero side effects at import time.
|
|
57
|
+
# - NEVER raises. This sits on the SessionStart path; it must not be the thing
|
|
58
|
+
# that crashes startup, whatever it is handed.
|
|
59
|
+
#
|
|
60
|
+
# Public API:
|
|
61
|
+
# has_worktree_segment(path) -> bool lexical signal, no I/O
|
|
62
|
+
# in_linked_worktree(path) -> bool git's on-disk marker, no subprocess
|
|
63
|
+
# is_ephemeral_path(path) -> bool either signal
|
|
64
|
+
|
|
65
|
+
import os
|
|
66
|
+
import re
|
|
67
|
+
|
|
68
|
+
# The walk from a plugin root to the filesystem root is a handful of levels; the
|
|
69
|
+
# bound exists only so a pathological path can never spin this on the hot path.
|
|
70
|
+
MAX_ANCESTOR_WALK = 64
|
|
71
|
+
|
|
72
|
+
# A `.git` FILE is at most a line or two. Reading a bounded prefix means a
|
|
73
|
+
# directory entry that is unexpectedly enormous cannot stall startup.
|
|
74
|
+
GITFILE_READ_BYTES = 4096
|
|
75
|
+
|
|
76
|
+
_SEP_RE = re.compile(r"[\\/]+")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _segments(path):
|
|
80
|
+
"""Path segments of `path`, split on BOTH separators (a Windows path may be
|
|
81
|
+
read from a POSIX host and vice versa). Empty tuple on any non-string."""
|
|
82
|
+
if not isinstance(path, str) or not path:
|
|
83
|
+
return ()
|
|
84
|
+
return tuple(part for part in _SEP_RE.split(path) if part)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def has_worktree_segment(path):
|
|
88
|
+
"""True iff `path` contains a segment named `worktrees` or `*-worktrees`
|
|
89
|
+
(case-insensitive). Purely lexical — no filesystem access, no subprocess.
|
|
90
|
+
|
|
91
|
+
Matches `<repo>/.claude/worktrees/<id>/...` (Claude Code's own subagent
|
|
92
|
+
worktrees) and `../.codearbiter-worktrees/<slug>/...` (the
|
|
93
|
+
using-git-worktrees skill). Compares whole SEGMENTS, never substrings: a
|
|
94
|
+
directory literally called `worktrees-archive` is a name of its own, not a
|
|
95
|
+
container of worktrees, and matching it would decline to heal a perfectly
|
|
96
|
+
durable root."""
|
|
97
|
+
for part in _segments(path):
|
|
98
|
+
low = part.lower()
|
|
99
|
+
if low == "worktrees" or low.endswith("-worktrees"):
|
|
100
|
+
return True
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _ancestors(path):
|
|
105
|
+
"""`path` (or its directory, if it names a file) and every parent above it,
|
|
106
|
+
nearest first, bounded by MAX_ANCESTOR_WALK. Yields nothing for junk."""
|
|
107
|
+
if not isinstance(path, str) or not path:
|
|
108
|
+
return
|
|
109
|
+
try:
|
|
110
|
+
cur = os.path.abspath(path)
|
|
111
|
+
if not os.path.isdir(cur):
|
|
112
|
+
cur = os.path.dirname(cur)
|
|
113
|
+
except Exception: # noqa: BLE001 — a probe must never crash its caller
|
|
114
|
+
return
|
|
115
|
+
for _ in range(MAX_ANCESTOR_WALK):
|
|
116
|
+
if not cur:
|
|
117
|
+
return
|
|
118
|
+
yield cur
|
|
119
|
+
parent = os.path.dirname(cur)
|
|
120
|
+
if parent == cur: # filesystem root: dirname is a fixed point
|
|
121
|
+
return
|
|
122
|
+
cur = parent
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _gitfile_points_at_worktree(gitfile):
|
|
126
|
+
"""True iff the `.git` FILE at `gitfile` is a LINKED WORKTREE pointer.
|
|
127
|
+
|
|
128
|
+
Git writes `gitdir: <path>` into `.git` for both linked worktrees and
|
|
129
|
+
submodules, and the two must not be confused: a worktree points into
|
|
130
|
+
`<common>/.git/worktrees/<name>` (disposable), a submodule into
|
|
131
|
+
`<super>/.git/modules/<name>` (an ordinary long-lived checkout). The
|
|
132
|
+
`worktrees` segment in the TARGET is what separates them."""
|
|
133
|
+
try:
|
|
134
|
+
with open(gitfile, "rb") as f:
|
|
135
|
+
head = f.read(GITFILE_READ_BYTES)
|
|
136
|
+
lines = head.decode("utf-8", "replace").splitlines()
|
|
137
|
+
if not lines:
|
|
138
|
+
return False
|
|
139
|
+
first = lines[0].strip()
|
|
140
|
+
if not first.lower().startswith("gitdir:"):
|
|
141
|
+
return False
|
|
142
|
+
# split(":", 1) — the FIRST colon only, so a Windows target like
|
|
143
|
+
# `C:/Users/...` survives intact.
|
|
144
|
+
return has_worktree_segment(first.split(":", 1)[1].strip())
|
|
145
|
+
except Exception: # noqa: BLE001 — unreadable reads as durable, by design
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def in_linked_worktree(path):
|
|
150
|
+
"""True iff `path` sits inside a LINKED git worktree.
|
|
151
|
+
|
|
152
|
+
Walks up from `path` to the first `.git` it meets — the boundary of the
|
|
153
|
+
repository the path actually belongs to — and decides there:
|
|
154
|
+
- `.git` is a DIRECTORY -> the main worktree. Durable. Stop.
|
|
155
|
+
- `.git` is a FILE -> a linked worktree or a submodule; the pointer's
|
|
156
|
+
target says which. Stop either way.
|
|
157
|
+
- no `.git` at any level -> not in a repository at all (the ordinary
|
|
158
|
+
plugin-cache install). Durable.
|
|
159
|
+
Stopping at the NEAREST boundary is load-bearing: a real checkout nested
|
|
160
|
+
below some unrelated worktree marker must read as durable.
|
|
161
|
+
|
|
162
|
+
`path` may name a file (its directory is walked) or a directory, and need
|
|
163
|
+
not exist. Never raises."""
|
|
164
|
+
try:
|
|
165
|
+
for cur in _ancestors(path):
|
|
166
|
+
gitpath = os.path.join(cur, ".git")
|
|
167
|
+
if os.path.isdir(gitpath):
|
|
168
|
+
return False
|
|
169
|
+
if os.path.isfile(gitpath):
|
|
170
|
+
return _gitfile_points_at_worktree(gitpath)
|
|
171
|
+
return False
|
|
172
|
+
except Exception: # noqa: BLE001 — a probe must never crash its caller
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def is_ephemeral_path(path):
|
|
177
|
+
"""True iff `path` must NOT be pinned into a long-lived global config.
|
|
178
|
+
|
|
179
|
+
Checks the free lexical signal FIRST and short-circuits on it, so the
|
|
180
|
+
confirmed production case is decided with zero I/O. Never raises."""
|
|
181
|
+
try:
|
|
182
|
+
if has_worktree_segment(path):
|
|
183
|
+
return True
|
|
184
|
+
return in_linked_worktree(path)
|
|
185
|
+
except Exception: # noqa: BLE001 — a probe must never crash its caller
|
|
186
|
+
return False
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter — shared entry-point dispatch helper (jscpd dedup, #320/#334 item 2).
|
|
3
|
+
#
|
|
4
|
+
# Every hook/tool entry file (doctor.py, pre-edit.py, pre-write.py, pre-read.py,
|
|
5
|
+
# git-enforce.py, post-write-edit.py, migration-pass.py, security-pass.py,
|
|
6
|
+
# statusline.py, boardsync.py, metrics.py, preview.py, taskwrite.py, babysit.py,
|
|
7
|
+
# init-codearbiter.py, ...) carries the identically-shaped `run(host, argv=None)`
|
|
8
|
+
# body: wire the process-cached Host live (#257, ADR-0011) via a `set_host`
|
|
9
|
+
# callable BEFORE `main` runs, invoke `main` (with or without `argv`), and either
|
|
10
|
+
# discard its return (always `return 0`) or propagate it as the exit code —
|
|
11
|
+
# jscpd flags this near-duplicate boilerplate across ~15 files. This module
|
|
12
|
+
# hoists the shared BODY only; each caller keeps its own `run(host, argv=None)`
|
|
13
|
+
# signature/docstring and passes its own `main`/`set_host` callables plus the
|
|
14
|
+
# two booleans that capture its file's variant behavior (verbatim-equivalent —
|
|
15
|
+
# behavior is unchanged, not "improved").
|
|
16
|
+
#
|
|
17
|
+
# Zero import-time side effects; pure function; no filesystem/git access.
|
|
18
|
+
#
|
|
19
|
+
# Public API:
|
|
20
|
+
# dispatch(host, argv, main_fn, set_host_fn, pass_argv, propagate_result) -> int
|
|
21
|
+
# Wire `host` via set_host_fn(host), call main_fn() or main_fn(argv)
|
|
22
|
+
# (per pass_argv), then return that result or 0 (per propagate_result).
|
|
23
|
+
|
|
24
|
+
def dispatch(host, argv, main_fn, set_host_fn, pass_argv, propagate_result):
|
|
25
|
+
"""Shared body of an entry file's `run(host, argv=None)`.
|
|
26
|
+
|
|
27
|
+
set_host_fn(host) -- primes the process-cached Host (#257) BEFORE
|
|
28
|
+
main_fn runs, so any get_host() call downstream
|
|
29
|
+
resolves to the SAME instance the caller passed
|
|
30
|
+
here (no second hostapi.load_host()).
|
|
31
|
+
main_fn()/main_fn(argv) -- per `pass_argv`: some entry files' main() takes
|
|
32
|
+
no arguments, others take `argv=None`.
|
|
33
|
+
return result or 0 -- per `propagate_result`: some callers discard
|
|
34
|
+
main_fn's return value (always exit 0, matching
|
|
35
|
+
the old bare `main()` guard); others propagate
|
|
36
|
+
main_fn's return value as the exit code
|
|
37
|
+
(matching the old `sys.exit(main())` guard).
|
|
38
|
+
"""
|
|
39
|
+
set_host_fn(host)
|
|
40
|
+
result = main_fn(argv) if pass_argv else main_fn()
|
|
41
|
+
return result if propagate_result else 0
|