@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,214 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter — standup hygiene parsers (sprint: session-hygiene, Feature 1).
|
|
3
|
+
#
|
|
4
|
+
# PURE functions only: no I/O, no subprocess, no os.environ. Each function takes
|
|
5
|
+
# the OUTPUT STRING of a git command and returns structured data. The git calls
|
|
6
|
+
# themselves happen elsewhere (a later task); keeping parsing pure makes every
|
|
7
|
+
# branch unit-testable from fixture strings, and keeps this module compliant with
|
|
8
|
+
# the stdlib-only / fail-loud hook posture.
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"parse_porcelain",
|
|
14
|
+
"parse_ahead_behind",
|
|
15
|
+
"merged_branch_candidates",
|
|
16
|
+
"parse_worktrees",
|
|
17
|
+
"parse_stash_count",
|
|
18
|
+
"ff_pull_eligible",
|
|
19
|
+
"stale_worktree_candidates",
|
|
20
|
+
"any_actionable",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_porcelain(text):
|
|
25
|
+
"""Parse `git status --porcelain=v1` output into a summary.
|
|
26
|
+
|
|
27
|
+
Returns {"dirty": bool, "staged": int, "unstaged": int, "untracked": int}.
|
|
28
|
+
|
|
29
|
+
Each status line is `XY <path>` where X is the index (staged) column and Y is
|
|
30
|
+
the worktree (unstaged) column. A space means "no change" in that column.
|
|
31
|
+
Untracked files use the `??` code and are counted separately (not as
|
|
32
|
+
staged/unstaged). A file modified in both index and worktree (e.g. "MM")
|
|
33
|
+
counts toward BOTH staged and unstaged. Empty/whitespace input is clean.
|
|
34
|
+
"""
|
|
35
|
+
staged = unstaged = untracked = 0
|
|
36
|
+
for raw in (text or "").splitlines():
|
|
37
|
+
if not raw.strip():
|
|
38
|
+
continue
|
|
39
|
+
code = raw[:2]
|
|
40
|
+
if code == "??":
|
|
41
|
+
untracked += 1
|
|
42
|
+
continue
|
|
43
|
+
x = code[0:1]
|
|
44
|
+
y = code[1:2]
|
|
45
|
+
if x and x != " " and x != "?":
|
|
46
|
+
staged += 1
|
|
47
|
+
if y and y != " " and y != "?":
|
|
48
|
+
unstaged += 1
|
|
49
|
+
dirty = (staged + unstaged + untracked) > 0
|
|
50
|
+
return {
|
|
51
|
+
"dirty": dirty,
|
|
52
|
+
"staged": staged,
|
|
53
|
+
"unstaged": unstaged,
|
|
54
|
+
"untracked": untracked,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_ahead_behind(text):
|
|
59
|
+
"""Parse `git rev-list --left-right --count <upstream>...HEAD` output.
|
|
60
|
+
|
|
61
|
+
The single output line is `<behind>\\t<ahead>` (left side = upstream-only
|
|
62
|
+
commits = behind; right side = HEAD-only commits = ahead). Returns
|
|
63
|
+
(behind, ahead). Empty or malformed (wrong field count, non-numeric) → (0, 0).
|
|
64
|
+
"""
|
|
65
|
+
line = (text or "").strip()
|
|
66
|
+
if not line:
|
|
67
|
+
return (0, 0)
|
|
68
|
+
parts = line.split()
|
|
69
|
+
if len(parts) != 2:
|
|
70
|
+
return (0, 0)
|
|
71
|
+
try:
|
|
72
|
+
behind = int(parts[0])
|
|
73
|
+
ahead = int(parts[1])
|
|
74
|
+
except ValueError:
|
|
75
|
+
return (0, 0)
|
|
76
|
+
return (behind, ahead)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def merged_branch_candidates(branch_vv_text, current, default):
|
|
80
|
+
"""Parse `git branch -vv` output for local branches safe to prune.
|
|
81
|
+
|
|
82
|
+
PRUNE RULE (single, deterministic): a branch is a candidate iff its line shows
|
|
83
|
+
its upstream is `gone` — i.e. it contains the `: gone]` marker that git prints
|
|
84
|
+
when the tracked remote branch no longer exists (typically because the PR was
|
|
85
|
+
merged and the remote branch deleted). The current branch (line begins with
|
|
86
|
+
`* `) and the `default` branch (e.g. "main") are NEVER candidates, regardless
|
|
87
|
+
of their upstream state. Order of first appearance is preserved.
|
|
88
|
+
"""
|
|
89
|
+
candidates = []
|
|
90
|
+
for raw in (branch_vv_text or "").splitlines():
|
|
91
|
+
if not raw.strip():
|
|
92
|
+
continue
|
|
93
|
+
is_current = raw.startswith("* ")
|
|
94
|
+
# Strip the `* ` / leading spaces marker, then the branch name is token 0.
|
|
95
|
+
body = raw[2:] if is_current else raw.lstrip()
|
|
96
|
+
tokens = body.split()
|
|
97
|
+
if not tokens:
|
|
98
|
+
continue
|
|
99
|
+
name = tokens[0]
|
|
100
|
+
if ": gone]" not in raw:
|
|
101
|
+
continue
|
|
102
|
+
if is_current or name == current or name == default:
|
|
103
|
+
continue
|
|
104
|
+
candidates.append(name)
|
|
105
|
+
return candidates
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _strip_trailing_sep(path):
|
|
109
|
+
"""Drop a single trailing / or \\ so a repo_root with a trailing separator
|
|
110
|
+
still matches the worktree path git reports (which has none)."""
|
|
111
|
+
if path and path[-1] in ("/", "\\"):
|
|
112
|
+
return path[:-1]
|
|
113
|
+
return path
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def parse_worktrees(porcelain_text, repo_root):
|
|
117
|
+
"""Parse `git worktree list --porcelain` output.
|
|
118
|
+
|
|
119
|
+
Returns a list of {"path": str, "branch": str|None, "is_main": bool}. Records
|
|
120
|
+
are separated by blank lines; each starts with a `worktree <path>` line and may
|
|
121
|
+
carry a `branch refs/heads/<name>` line (absent / `detached` => branch None).
|
|
122
|
+
The main worktree is the record whose path equals `repo_root` (trailing
|
|
123
|
+
separator tolerated). Parses faithfully; staleness/merge classification is a
|
|
124
|
+
later task's job.
|
|
125
|
+
"""
|
|
126
|
+
root = _strip_trailing_sep(repo_root or "")
|
|
127
|
+
out = []
|
|
128
|
+
cur = None
|
|
129
|
+
for raw in (porcelain_text or "").splitlines():
|
|
130
|
+
line = raw.rstrip("\n")
|
|
131
|
+
if not line.strip():
|
|
132
|
+
if cur is not None:
|
|
133
|
+
out.append(cur)
|
|
134
|
+
cur = None
|
|
135
|
+
continue
|
|
136
|
+
if line.startswith("worktree "):
|
|
137
|
+
if cur is not None:
|
|
138
|
+
out.append(cur)
|
|
139
|
+
path = line[len("worktree "):].strip()
|
|
140
|
+
cur = {"path": path, "branch": None, "is_main": path == root}
|
|
141
|
+
elif line.startswith("branch ") and cur is not None:
|
|
142
|
+
ref = line[len("branch "):].strip()
|
|
143
|
+
cur["branch"] = ref[len("refs/heads/"):] if ref.startswith("refs/heads/") else ref
|
|
144
|
+
# HEAD / detached / bare / locked lines carry no fields we surface here.
|
|
145
|
+
if cur is not None:
|
|
146
|
+
out.append(cur)
|
|
147
|
+
return out
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def parse_stash_count(text):
|
|
151
|
+
"""Count stashes from `git stash list` output (one stash per line). Empty → 0."""
|
|
152
|
+
return sum(1 for ln in (text or "").splitlines() if ln.strip())
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def ff_pull_eligible(porcelain_text, behind):
|
|
156
|
+
"""SH-6: True iff a fast-forward pull should be OFFERED — the working tree is
|
|
157
|
+
CLEAN (no staged/unstaged/untracked changes per parse_porcelain) AND we are
|
|
158
|
+
behind (`behind > 0`). A dirty tree withholds the offer (a ff-pull on a dirty
|
|
159
|
+
tree is unsafe); behind == 0 means there is nothing to pull. PURE: porcelain
|
|
160
|
+
OUTPUT STRING + behind count in, bool out. This only IDENTIFIES eligibility;
|
|
161
|
+
it never pulls — the /ca:standup command acts on explicit user confirmation."""
|
|
162
|
+
if behind <= 0:
|
|
163
|
+
return False
|
|
164
|
+
return not parse_porcelain(porcelain_text)["dirty"]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def stale_worktree_candidates(worktrees, gone_or_merged_branches, path_exists=os.path.exists):
|
|
168
|
+
"""SD-B1: from parsed `worktrees` (parse_worktrees output) plus the set of
|
|
169
|
+
branch names that are gone/merged on the remote, return the NON-MAIN worktrees
|
|
170
|
+
that are STALE CANDIDATES. A worktree is stale iff its branch is in
|
|
171
|
+
`gone_or_merged_branches` OR its path no longer exists on disk. The main
|
|
172
|
+
worktree (is_main) is NEVER a candidate. Order of input is preserved.
|
|
173
|
+
|
|
174
|
+
`path_exists(path) -> bool` is injectable so the disk check is deterministic in
|
|
175
|
+
tests (pass a fake predicate); production defaults to os.path.exists. This is a
|
|
176
|
+
PURE classifier: it only IDENTIFIES candidates and removes/mutates nothing —
|
|
177
|
+
the /ca:standup command removes a worktree only on explicit per-item user
|
|
178
|
+
confirmation, which is why the broad (gone OR missing) candidate rule is safe."""
|
|
179
|
+
gone = gone_or_merged_branches or set()
|
|
180
|
+
out = []
|
|
181
|
+
for wt in worktrees or []:
|
|
182
|
+
if wt.get("is_main"):
|
|
183
|
+
continue
|
|
184
|
+
branch = wt.get("branch")
|
|
185
|
+
path = wt.get("path")
|
|
186
|
+
is_stale = (branch in gone) or (not path_exists(path))
|
|
187
|
+
if is_stale:
|
|
188
|
+
out.append(wt)
|
|
189
|
+
return out
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def any_actionable(summary):
|
|
193
|
+
"""True if the assembled briefing summary has ANY actionable condition.
|
|
194
|
+
|
|
195
|
+
Drives the "emit an offer line only if actionable" behavior. Triggers: a dirty
|
|
196
|
+
tree, behind > 0, ahead/unpushed > 0, non-empty prune_candidates, non-empty
|
|
197
|
+
stale_worktrees, or stashes > 0. Missing keys default to falsy.
|
|
198
|
+
"""
|
|
199
|
+
s = summary or {}
|
|
200
|
+
if s.get("dirty"):
|
|
201
|
+
return True
|
|
202
|
+
if (s.get("behind") or 0) > 0:
|
|
203
|
+
return True
|
|
204
|
+
if (s.get("ahead") or 0) > 0:
|
|
205
|
+
return True
|
|
206
|
+
if (s.get("unpushed") or 0) > 0:
|
|
207
|
+
return True
|
|
208
|
+
if s.get("prune_candidates"):
|
|
209
|
+
return True
|
|
210
|
+
if s.get("stale_worktrees"):
|
|
211
|
+
return True
|
|
212
|
+
if (s.get("stashes") or 0) > 0:
|
|
213
|
+
return True
|
|
214
|
+
return False
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter — statusline subagent-rows helpers (extracted from statusline.py,
|
|
3
|
+
# architecture-004).
|
|
4
|
+
#
|
|
5
|
+
# Owns resolving the current session's subagents directory, reading its recent
|
|
6
|
+
# `.jsonl` transcripts (deduped per requestId, same rule as the Session/Today
|
|
7
|
+
# rows), and labeling each subagent from its first user message. Carries no
|
|
8
|
+
# rendering concern — the statusline turns the returned dicts into rows.
|
|
9
|
+
#
|
|
10
|
+
# Design principles (mirroring _ledgerlib.py):
|
|
11
|
+
# - Stdlib only; no third-party imports ever.
|
|
12
|
+
# - Zero side effects at import time.
|
|
13
|
+
# - Never raise on malformed input — every reader degrades to a safe blank.
|
|
14
|
+
#
|
|
15
|
+
# Public API:
|
|
16
|
+
# subagent_dir(data, root, sid) -> str|None
|
|
17
|
+
# read_subagents(sdir) -> (active, recent, shown, (tot_in, tot_out))
|
|
18
|
+
# sub_label(content) -> str
|
|
19
|
+
# display_model(model_id) -> str
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import time
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
import _colorlib
|
|
28
|
+
strip_control = _colorlib.strip_control
|
|
29
|
+
except Exception: # pragma: no cover — never let an import break the statusline
|
|
30
|
+
_colorlib = None
|
|
31
|
+
|
|
32
|
+
def strip_control(s):
|
|
33
|
+
return s if not isinstance(s, str) else re.sub(r"[\000-\037]", "", s)
|
|
34
|
+
|
|
35
|
+
ACTIVE_WINDOW = 150 # secs: a subagent file touched this recently is "active"
|
|
36
|
+
SHOW_WINDOW = 600 # secs: still display recently-finished subagents
|
|
37
|
+
MAX_SUB_ROWS = 4
|
|
38
|
+
MAX_SUB_FILES = 12 # hot-path bound: parse at most this many files / render
|
|
39
|
+
MAX_SUB_LINES = 2500 # per-file line cap
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def display_model(model_id):
|
|
43
|
+
"""Compact a host model ID while retaining its family and version."""
|
|
44
|
+
if not isinstance(model_id, str):
|
|
45
|
+
return "model:?"
|
|
46
|
+
name = strip_control(model_id).strip()
|
|
47
|
+
if not name:
|
|
48
|
+
return "model:?"
|
|
49
|
+
if name.startswith("claude-"):
|
|
50
|
+
name = name[len("claude-"):]
|
|
51
|
+
name = re.sub(r"-(?:\d{8}|\d{4}-\d{2}-\d{2})$", "", name)
|
|
52
|
+
return "model:" + name[:24]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def num(x, default=0.0):
|
|
56
|
+
"""Coerce any host value to float; tolerate strings, None, and containers."""
|
|
57
|
+
try:
|
|
58
|
+
return float(x)
|
|
59
|
+
except (TypeError, ValueError):
|
|
60
|
+
return default
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get(d, *path, default=None):
|
|
64
|
+
cur = d
|
|
65
|
+
for k in path:
|
|
66
|
+
if not isinstance(cur, dict) or k not in cur or cur[k] is None:
|
|
67
|
+
return default
|
|
68
|
+
cur = cur[k]
|
|
69
|
+
return cur
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def subagent_dir(data, root, sid):
|
|
73
|
+
"""Resolve the current session's subagents directory: prefer transcript_path
|
|
74
|
+
(authoritative), else derive the project slug from cwd + session id."""
|
|
75
|
+
tp = data.get("transcript_path") if isinstance(data, dict) else None
|
|
76
|
+
if tp:
|
|
77
|
+
base = os.path.dirname(tp)
|
|
78
|
+
sess = os.path.splitext(os.path.basename(tp))[0]
|
|
79
|
+
cand = os.path.join(base, sess, "subagents")
|
|
80
|
+
if os.path.isdir(cand):
|
|
81
|
+
return cand
|
|
82
|
+
if sid:
|
|
83
|
+
cwd = get(data, "workspace", "current_dir") or get(data, "cwd") or root
|
|
84
|
+
slug = re.sub(r"[^A-Za-z0-9]", "-", os.path.abspath(cwd))
|
|
85
|
+
cand = os.path.join(os.path.expanduser("~"), ".claude", "projects", slug, sid, "subagents")
|
|
86
|
+
if os.path.isdir(cand):
|
|
87
|
+
return cand
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def read_subagents(sdir):
|
|
92
|
+
"""Return (active, recent, shown[{label,inp,out,age,active}], (tot_in, tot_out)).
|
|
93
|
+
`active` = files touched within ACTIVE_WINDOW (a liveness proxy); `recent` =
|
|
94
|
+
all files within SHOW_WINDOW (active + recently finished).
|
|
95
|
+
|
|
96
|
+
ONE result shape on EVERY path (#413). The element types are stable — int,
|
|
97
|
+
int, list, (int|float, int|float) — so a caller may unpack four names
|
|
98
|
+
unconditionally. statusline.py does exactly that, OUTSIDE its safe()
|
|
99
|
+
wrapper, so an error branch returning a shorter tuple was a hard ValueError
|
|
100
|
+
that erased the entire subagent section on a routine directory race. An
|
|
101
|
+
unreadable/absent directory is an EMPTY result, not a different result."""
|
|
102
|
+
empty = (0, 0, [], (0, 0))
|
|
103
|
+
now = time.time()
|
|
104
|
+
files = []
|
|
105
|
+
try:
|
|
106
|
+
for nm in os.listdir(sdir):
|
|
107
|
+
if not nm.endswith(".jsonl"):
|
|
108
|
+
continue
|
|
109
|
+
fp = os.path.join(sdir, nm)
|
|
110
|
+
try:
|
|
111
|
+
st = os.stat(fp)
|
|
112
|
+
except OSError:
|
|
113
|
+
continue
|
|
114
|
+
if now - st.st_mtime <= SHOW_WINDOW:
|
|
115
|
+
files.append((st.st_mtime, st.st_size, fp, nm))
|
|
116
|
+
except OSError:
|
|
117
|
+
return empty
|
|
118
|
+
files.sort(reverse=True) # most-recently-touched first
|
|
119
|
+
|
|
120
|
+
active = sum(1 for mtime, _, _, _ in files if now - mtime <= ACTIVE_WINDOW)
|
|
121
|
+
shown, tot_in, tot_out = [], 0, 0
|
|
122
|
+
for mtime, size, fp, nm in files[:MAX_SUB_FILES]:
|
|
123
|
+
if size > 16 * 1024 * 1024:
|
|
124
|
+
continue
|
|
125
|
+
reqs = {}
|
|
126
|
+
models = []
|
|
127
|
+
label = None
|
|
128
|
+
try:
|
|
129
|
+
with open(fp, encoding="utf-8", errors="replace") as f:
|
|
130
|
+
for i, ln in enumerate(f):
|
|
131
|
+
if i > MAX_SUB_LINES:
|
|
132
|
+
break
|
|
133
|
+
ln = ln.strip()
|
|
134
|
+
if not ln:
|
|
135
|
+
continue
|
|
136
|
+
try:
|
|
137
|
+
d = json.loads(ln)
|
|
138
|
+
except ValueError:
|
|
139
|
+
continue
|
|
140
|
+
if not isinstance(d, dict):
|
|
141
|
+
# A transcript line may be ANY valid JSON value; only an
|
|
142
|
+
# object carries an event. `[]`/`null`/`3`/`"s"` used to
|
|
143
|
+
# reach .get() below inside a try that caught OSError
|
|
144
|
+
# only, so one such line raised AttributeError out of
|
|
145
|
+
# read_subagents and blanked every subagent row (#413).
|
|
146
|
+
continue
|
|
147
|
+
msg = d.get("message")
|
|
148
|
+
if not isinstance(msg, dict):
|
|
149
|
+
continue
|
|
150
|
+
if label is None and msg.get("role") == "user":
|
|
151
|
+
label = sub_label(msg.get("content"))
|
|
152
|
+
if msg.get("role") == "assistant":
|
|
153
|
+
raw_model = msg.get("model")
|
|
154
|
+
if isinstance(raw_model, str) and raw_model.strip():
|
|
155
|
+
raw_model = raw_model.strip()
|
|
156
|
+
if raw_model not in models:
|
|
157
|
+
models.append(raw_model)
|
|
158
|
+
u = msg.get("usage")
|
|
159
|
+
if isinstance(u, dict):
|
|
160
|
+
# dedupe by requestId; fresh input only (cache reads excluded)
|
|
161
|
+
# — same definition as the Session/Today rows.
|
|
162
|
+
reqs[d.get("requestId") or msg.get("id") or i] = (
|
|
163
|
+
num(u.get("input_tokens")) + num(u.get("cache_creation_input_tokens")),
|
|
164
|
+
num(u.get("output_tokens")))
|
|
165
|
+
except Exception: # noqa: BLE001 — #413: the error boundary is PER FILE
|
|
166
|
+
# One unreadable or structurally surprising transcript must cost
|
|
167
|
+
# only its own row, never the rest of the directory. Widened from
|
|
168
|
+
# OSError so a shape the reader has not anticipated degrades the
|
|
169
|
+
# same way malformed JSON already does (the module's standing
|
|
170
|
+
# "never raise on malformed input" contract) instead of escaping
|
|
171
|
+
# into the statusline and blanking every subagent row.
|
|
172
|
+
continue
|
|
173
|
+
inp = sum(v[0] for v in reqs.values())
|
|
174
|
+
out = sum(v[1] for v in reqs.values())
|
|
175
|
+
tot_in += inp
|
|
176
|
+
tot_out += out
|
|
177
|
+
if len(shown) < MAX_SUB_ROWS:
|
|
178
|
+
shown.append({"label": label or ("agent-" + re.sub(r"\.jsonl$", "", nm)[-6:]),
|
|
179
|
+
"model": (display_model(models[0]) if len(models) == 1 else
|
|
180
|
+
("model:mixed" if models else "model:?")),
|
|
181
|
+
"inp": inp, "out": out, "age": now - mtime,
|
|
182
|
+
"active": now - mtime <= ACTIVE_WINDOW})
|
|
183
|
+
return active, len(files), shown, (tot_in, tot_out)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def sub_label(content):
|
|
187
|
+
"""Derive a label from a subagent's first user message. A short, title-like
|
|
188
|
+
first line wins (a dispatcher may lead the prompt with one); otherwise flatten
|
|
189
|
+
and drop a boilerplate role-assignment preamble so the visible text carries
|
|
190
|
+
signal instead of "You are a...". Truncation to the row width is the render's
|
|
191
|
+
job (clip()), so this returns the full cleaned string up to a sane cap."""
|
|
192
|
+
text = ""
|
|
193
|
+
if isinstance(content, str):
|
|
194
|
+
text = content
|
|
195
|
+
elif isinstance(content, list):
|
|
196
|
+
for blk in content:
|
|
197
|
+
if isinstance(blk, dict) and blk.get("type") == "text":
|
|
198
|
+
text = blk.get("text", "")
|
|
199
|
+
break
|
|
200
|
+
if isinstance(blk, str):
|
|
201
|
+
text = blk
|
|
202
|
+
break
|
|
203
|
+
raw = strip_control(str(text))
|
|
204
|
+
# strip leading reminder/system blocks up front (multi-line safe), then a lone tag
|
|
205
|
+
raw = re.sub(r"^\s*(?:<([^>\s]+)[^>]*>.*?</\1>\s*)+", "", raw, flags=re.S)
|
|
206
|
+
raw = re.sub(r"^\s*<[^>]+>\s*", "", raw)
|
|
207
|
+
# a short, title-like first line wins (rewards a leading title line)
|
|
208
|
+
first = ""
|
|
209
|
+
for ln in raw.splitlines():
|
|
210
|
+
ln = ln.strip()
|
|
211
|
+
if ln:
|
|
212
|
+
first = ln
|
|
213
|
+
break
|
|
214
|
+
if 0 < len(first) <= 60 and not re.match(r"(?i)^(you are|act as|role\s*:)\b", first):
|
|
215
|
+
return first
|
|
216
|
+
# otherwise flatten and strip a leading role-assignment preamble (one or more sentences)
|
|
217
|
+
flat = re.sub(r"\s+", " ", raw).strip()
|
|
218
|
+
flat = re.sub(r"(?i)^(?:(?:you are|you're|act as|role\s*:)\b[^.]*\.\s*)+", "", flat)
|
|
219
|
+
return flat[:80].strip()
|