@arbiterforge/ca-pi 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +558 -0
  3. package/package.json +35 -0
  4. package/plugins/ca-pi/CHANGELOG.md +1030 -0
  5. package/plugins/ca-pi/COMMANDS.md +90 -0
  6. package/plugins/ca-pi/ORCHESTRATOR.md +159 -0
  7. package/plugins/ca-pi/SKILLS.md +47 -0
  8. package/plugins/ca-pi/SPRINT.md +142 -0
  9. package/plugins/ca-pi/agents/INDEX.md +31 -0
  10. package/plugins/ca-pi/agents/architecture-drift-reviewer.md +86 -0
  11. package/plugins/ca-pi/agents/auth-crypto-reviewer.md +60 -0
  12. package/plugins/ca-pi/agents/backend-author.md +60 -0
  13. package/plugins/ca-pi/agents/checkpoint-aggregator.md +111 -0
  14. package/plugins/ca-pi/agents/coverage-auditor.md +71 -0
  15. package/plugins/ca-pi/agents/decision-challenger.md +116 -0
  16. package/plugins/ca-pi/agents/dependency-reviewer.md +79 -0
  17. package/plugins/ca-pi/agents/design-quality-reviewer.md +80 -0
  18. package/plugins/ca-pi/agents/finding-triage.md +86 -0
  19. package/plugins/ca-pi/agents/frontend-author.md +64 -0
  20. package/plugins/ca-pi/agents/grader.md +173 -0
  21. package/plugins/ca-pi/agents/infra-author.md +64 -0
  22. package/plugins/ca-pi/agents/map-deps.md +35 -0
  23. package/plugins/ca-pi/agents/map-structure.md +37 -0
  24. package/plugins/ca-pi/agents/migration-reviewer.md +65 -0
  25. package/plugins/ca-pi/agents/scout.md +127 -0
  26. package/plugins/ca-pi/agents/security-reviewer.md +72 -0
  27. package/plugins/ca-pi/agents/tribunal-lens-reviewer.md +65 -0
  28. package/plugins/ca-pi/extensions/codearbiter-child.js +1885 -0
  29. package/plugins/ca-pi/extensions/codearbiter.js +9802 -0
  30. package/plugins/ca-pi/generated/command-catalog.json +197 -0
  31. package/plugins/ca-pi/generated/roles.json +213 -0
  32. package/plugins/ca-pi/helpers/windows-supervisor.js +205 -0
  33. package/plugins/ca-pi/hooks/_activationlib.py +196 -0
  34. package/plugins/ca-pi/hooks/_arbiterstatelib.py +208 -0
  35. package/plugins/ca-pi/hooks/_babysitlib.py +76 -0
  36. package/plugins/ca-pi/hooks/_bashguardlib.py +1667 -0
  37. package/plugins/ca-pi/hooks/_boxlib.py +131 -0
  38. package/plugins/ca-pi/hooks/_colorlib.py +304 -0
  39. package/plugins/ca-pi/hooks/_durabilitylib.py +186 -0
  40. package/plugins/ca-pi/hooks/_entrylib.py +41 -0
  41. package/plugins/ca-pi/hooks/_fmtlib.py +161 -0
  42. package/plugins/ca-pi/hooks/_gitexec.py +45 -0
  43. package/plugins/ca-pi/hooks/_githooks.py +920 -0
  44. package/plugins/ca-pi/hooks/_gitlib.py +110 -0
  45. package/plugins/ca-pi/hooks/_hooklib.py +595 -0
  46. package/plugins/ca-pi/hooks/_host.py +115 -0
  47. package/plugins/ca-pi/hooks/_intentlib.py +242 -0
  48. package/plugins/ca-pi/hooks/_ledgerlib.py +1035 -0
  49. package/plugins/ca-pi/hooks/_metricslib.py +709 -0
  50. package/plugins/ca-pi/hooks/_pathnorm.py +74 -0
  51. package/plugins/ca-pi/hooks/_planfilelib.py +664 -0
  52. package/plugins/ca-pi/hooks/_previewlib.py +193 -0
  53. package/plugins/ca-pi/hooks/_protectedlib.py +312 -0
  54. package/plugins/ca-pi/hooks/_protectedstatelib.py +411 -0
  55. package/plugins/ca-pi/hooks/_provenancelib.py +971 -0
  56. package/plugins/ca-pi/hooks/_prunelib.py +1398 -0
  57. package/plugins/ca-pi/hooks/_prunepolicy.py +235 -0
  58. package/plugins/ca-pi/hooks/_readinjectlib.py +1080 -0
  59. package/plugins/ca-pi/hooks/_releaselib.py +2657 -0
  60. package/plugins/ca-pi/hooks/_scopelib.py +262 -0
  61. package/plugins/ca-pi/hooks/_segmentslib.py +278 -0
  62. package/plugins/ca-pi/hooks/_sensitivelib.py +270 -0
  63. package/plugins/ca-pi/hooks/_sessionlib.py +78 -0
  64. package/plugins/ca-pi/hooks/_sloplib.py +244 -0
  65. package/plugins/ca-pi/hooks/_standuplib.py +214 -0
  66. package/plugins/ca-pi/hooks/_subagentslib.py +219 -0
  67. package/plugins/ca-pi/hooks/_taskboardlib.py +1088 -0
  68. package/plugins/ca-pi/hooks/_updatelib.py +278 -0
  69. package/plugins/ca-pi/hooks/babysit.py +47 -0
  70. package/plugins/ca-pi/hooks/boardsync.py +129 -0
  71. package/plugins/ca-pi/hooks/doctor.py +420 -0
  72. package/plugins/ca-pi/hooks/git-enforce.py +325 -0
  73. package/plugins/ca-pi/hooks/hostapi.py +460 -0
  74. package/plugins/ca-pi/hooks/init-codearbiter.py +225 -0
  75. package/plugins/ca-pi/hooks/metrics.py +62 -0
  76. package/plugins/ca-pi/hooks/migration-pass.py +129 -0
  77. package/plugins/ca-pi/hooks/pi-bridge.py +543 -0
  78. package/plugins/ca-pi/hooks/post-write-edit.py +231 -0
  79. package/plugins/ca-pi/hooks/pre-bash.py +90 -0
  80. package/plugins/ca-pi/hooks/pre-edit.py +284 -0
  81. package/plugins/ca-pi/hooks/pre-read.py +81 -0
  82. package/plugins/ca-pi/hooks/pre-write.py +217 -0
  83. package/plugins/ca-pi/hooks/preview.py +69 -0
  84. package/plugins/ca-pi/hooks/prune-transcript.py +232 -0
  85. package/plugins/ca-pi/hooks/releasehash.py +216 -0
  86. package/plugins/ca-pi/hooks/security-pass.py +139 -0
  87. package/plugins/ca-pi/hooks/session-start.py +1218 -0
  88. package/plugins/ca-pi/hooks/statusline.py +736 -0
  89. package/plugins/ca-pi/hooks/taskwrite.py +351 -0
  90. package/plugins/ca-pi/hooks/update-refresh.py +51 -0
  91. package/plugins/ca-pi/hooks/wire-statusline.py +435 -0
  92. package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +55 -0
  93. package/plugins/ca-pi/includes/anti-slop-design/color.md +43 -0
  94. package/plugins/ca-pi/includes/anti-slop-design/core.md +244 -0
  95. package/plugins/ca-pi/includes/anti-slop-design/images.md +32 -0
  96. package/plugins/ca-pi/includes/anti-slop-design/layout.md +45 -0
  97. package/plugins/ca-pi/includes/anti-slop-design/medium-cli.md +39 -0
  98. package/plugins/ca-pi/includes/anti-slop-design/medium-dataviz.md +43 -0
  99. package/plugins/ca-pi/includes/anti-slop-design/medium-diagram.md +35 -0
  100. package/plugins/ca-pi/includes/anti-slop-design/medium-documents.md +70 -0
  101. package/plugins/ca-pi/includes/anti-slop-design/medium-slides.md +30 -0
  102. package/plugins/ca-pi/includes/anti-slop-design/medium-web.md +39 -0
  103. package/plugins/ca-pi/includes/anti-slop-design/typography.md +51 -0
  104. package/plugins/ca-pi/includes/author-tdd-workflow.md +14 -0
  105. package/plugins/ca-pi/includes/compaction-charter.md +16 -0
  106. package/plugins/ca-pi/includes/cut-docs.md +16 -0
  107. package/plugins/ca-pi/includes/dev-mode.md +30 -0
  108. package/plugins/ca-pi/includes/farm.md +237 -0
  109. package/plugins/ca-pi/includes/fresh-verification.md +14 -0
  110. package/plugins/ca-pi/includes/harvest.md +69 -0
  111. package/plugins/ca-pi/includes/maturity-coverage.md +102 -0
  112. package/plugins/ca-pi/includes/pi-host-notes.md +69 -0
  113. package/plugins/ca-pi/includes/redirect.md +69 -0
  114. package/plugins/ca-pi/includes/reference-map.md +22 -0
  115. package/plugins/ca-pi/includes/review-matrix.md +14 -0
  116. package/plugins/ca-pi/includes/reviewer-contract.md +53 -0
  117. package/plugins/ca-pi/includes/routing-table.md +47 -0
  118. package/plugins/ca-pi/includes/security-gate-record.md +22 -0
  119. package/plugins/ca-pi/includes/smarts/core.md +90 -0
  120. package/plugins/ca-pi/includes/smarts/decision-log-format.md +56 -0
  121. package/plugins/ca-pi/routines/INDEX.md +32 -0
  122. package/plugins/ca-pi/routines/brainstorming/SKILL.md +122 -0
  123. package/plugins/ca-pi/routines/commit-gate/SKILL.md +151 -0
  124. package/plugins/ca-pi/routines/context-check/SKILL.md +85 -0
  125. package/plugins/ca-pi/routines/context-creation/SKILL.md +171 -0
  126. package/plugins/ca-pi/routines/crypto-compliance/SKILL.md +41 -0
  127. package/plugins/ca-pi/routines/debug/SKILL.md +99 -0
  128. package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +104 -0
  129. package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +74 -0
  130. package/plugins/ca-pi/routines/decision-variance/SKILL.md +147 -0
  131. package/plugins/ca-pi/routines/decompose/SKILL.md +168 -0
  132. package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +76 -0
  133. package/plugins/ca-pi/routines/executing-plans/SKILL.md +83 -0
  134. package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +91 -0
  135. package/plugins/ca-pi/routines/post-merge-cleanup/SKILL.md +233 -0
  136. package/plugins/ca-pi/routines/refactor/SKILL.md +91 -0
  137. package/plugins/ca-pi/routines/release/SKILL.md +315 -0
  138. package/plugins/ca-pi/routines/secret-handling/SKILL.md +67 -0
  139. package/plugins/ca-pi/routines/security-architecture/SKILL.md +63 -0
  140. package/plugins/ca-pi/routines/skill-author/SKILL.md +108 -0
  141. package/plugins/ca-pi/routines/skill-author/references/skill-template.md +58 -0
  142. package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +149 -0
  143. package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +145 -0
  144. package/plugins/ca-pi/routines/tdd/SKILL.md +139 -0
  145. package/plugins/ca-pi/routines/tribunal/SKILL.md +109 -0
  146. package/plugins/ca-pi/routines/tribunal/references/ai-markers.md +29 -0
  147. package/plugins/ca-pi/routines/tribunal/references/cost-and-models.md +64 -0
  148. package/plugins/ca-pi/routines/tribunal/references/finding-record.md +27 -0
  149. package/plugins/ca-pi/routines/tribunal/references/issue-filing.md +47 -0
  150. package/plugins/ca-pi/routines/tribunal/references/lenses/appsec.md +22 -0
  151. package/plugins/ca-pi/routines/tribunal/references/lenses/architecture.md +23 -0
  152. package/plugins/ca-pi/routines/tribunal/references/lenses/coverage.md +20 -0
  153. package/plugins/ca-pi/routines/tribunal/references/lenses/infra.md +24 -0
  154. package/plugins/ca-pi/routines/tribunal/references/lenses/migration.md +22 -0
  155. package/plugins/ca-pi/routines/tribunal/references/lenses/observability.md +21 -0
  156. package/plugins/ca-pi/routines/tribunal/references/lenses/performance.md +22 -0
  157. package/plugins/ca-pi/routines/tribunal/references/lenses/reliability.md +23 -0
  158. package/plugins/ca-pi/routines/tribunal/references/lenses/secrets-supply.md +22 -0
  159. package/plugins/ca-pi/routines/tribunal/references/lenses/test-fidelity.md +24 -0
  160. package/plugins/ca-pi/routines/tribunal/references/lenses/typesafety.md +21 -0
  161. package/plugins/ca-pi/routines/tribunal/references/report.md +19 -0
  162. package/plugins/ca-pi/routines/tribunal/references/schemas.md +58 -0
  163. package/plugins/ca-pi/routines/tribunal/references/telemetry.md +28 -0
  164. package/plugins/ca-pi/routines/tribunal/references/triage.md +53 -0
  165. package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +85 -0
  166. package/plugins/ca-pi/routines/writing-plans/SKILL.md +129 -0
  167. package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +50 -0
  168. package/plugins/ca-pi/skills/ca-add-dep/SKILL.md +88 -0
  169. package/plugins/ca-pi/skills/ca-adr/SKILL.md +30 -0
  170. package/plugins/ca-pi/skills/ca-adr-status/SKILL.md +30 -0
  171. package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +36 -0
  172. package/plugins/ca-pi/skills/ca-audit/SKILL.md +51 -0
  173. package/plugins/ca-pi/skills/ca-btw/SKILL.md +23 -0
  174. package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +50 -0
  175. package/plugins/ca-pi/skills/ca-chore/SKILL.md +58 -0
  176. package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +55 -0
  177. package/plugins/ca-pi/skills/ca-commands/SKILL.md +21 -0
  178. package/plugins/ca-pi/skills/ca-commit/SKILL.md +27 -0
  179. package/plugins/ca-pi/skills/ca-conflict/SKILL.md +61 -0
  180. package/plugins/ca-pi/skills/ca-context-check/SKILL.md +32 -0
  181. package/plugins/ca-pi/skills/ca-create-context/SKILL.md +32 -0
  182. package/plugins/ca-pi/skills/ca-debug/SKILL.md +42 -0
  183. package/plugins/ca-pi/skills/ca-decompose/SKILL.md +30 -0
  184. package/plugins/ca-pi/skills/ca-dev/SKILL.md +42 -0
  185. package/plugins/ca-pi/skills/ca-doctor/SKILL.md +44 -0
  186. package/plugins/ca-pi/skills/ca-feature/SKILL.md +105 -0
  187. package/plugins/ca-pi/skills/ca-fix/SKILL.md +42 -0
  188. package/plugins/ca-pi/skills/ca-init/SKILL.md +56 -0
  189. package/plugins/ca-pi/skills/ca-metrics/SKILL.md +80 -0
  190. package/plugins/ca-pi/skills/ca-new-skill/SKILL.md +34 -0
  191. package/plugins/ca-pi/skills/ca-override/SKILL.md +72 -0
  192. package/plugins/ca-pi/skills/ca-pr/SKILL.md +61 -0
  193. package/plugins/ca-pi/skills/ca-preview/SKILL.md +86 -0
  194. package/plugins/ca-pi/skills/ca-prune/SKILL.md +100 -0
  195. package/plugins/ca-pi/skills/ca-reconcile/SKILL.md +43 -0
  196. package/plugins/ca-pi/skills/ca-refactor/SKILL.md +43 -0
  197. package/plugins/ca-pi/skills/ca-release/SKILL.md +57 -0
  198. package/plugins/ca-pi/skills/ca-review/SKILL.md +63 -0
  199. package/plugins/ca-pi/skills/ca-spike/SKILL.md +41 -0
  200. package/plugins/ca-pi/skills/ca-sprint/SKILL.md +44 -0
  201. package/plugins/ca-pi/skills/ca-standup/SKILL.md +112 -0
  202. package/plugins/ca-pi/skills/ca-status/SKILL.md +56 -0
  203. package/plugins/ca-pi/skills/ca-task/SKILL.md +61 -0
  204. package/plugins/ca-pi/skills/ca-threat-model/SKILL.md +45 -0
  205. package/plugins/ca-pi/skills/ca-tribunal/SKILL.md +44 -0
  206. package/plugins/ca-pi/skills/ca-watch/SKILL.md +78 -0
@@ -0,0 +1,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()