@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,1218 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter v2 — SessionStart activation hook (the linchpin).
3
+ # Python port of session-start.sh (#25): no awk/grep/find, cross-platform, and
4
+ # fails LOUD — if CONTEXT.md exists but its frontmatter is malformed, it now
5
+ # prints a stderr breadcrumb instead of going silently dormant (the worst
6
+ # failure shape for a plugin whose whole job is to be active).
7
+ #
8
+ # Detects an arbiter-enabled repo and injects the orchestrator persona + startup
9
+ # state into context. A plugin has no CLAUDE.md to load an always-on persona, so
10
+ # the SessionStart hook does it: in a repo whose `.codearbiter/CONTEXT.md`
11
+ # frontmatter sets `arbiter: enabled`, this prints ORCHESTRATOR.md (+ live state)
12
+ # to stdout, which Claude Code adds to context.
13
+ #
14
+ # Injection is via PLAIN STDOUT, not hookSpecificOutput.additionalContext:
15
+ # additionalContext from a plugin-scoped hook is unreliable (claude-code #16538),
16
+ # whereas plain stdout is added to context dependably.
17
+ #
18
+ # In any repo WITHOUT the flag, the hook exits silently (dormant) — the plugin
19
+ # can be installed globally and stays out of the way everywhere else.
20
+
21
+ import concurrent.futures
22
+ import copy
23
+ import datetime
24
+ import json
25
+ import os
26
+ import re
27
+ import subprocess
28
+ import sys
29
+ import time
30
+
31
+ from _gitexec import git_executable
32
+
33
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
34
+ import hostapi # noqa: E402 — host seam (ADR-0011): plugin root + capability flags
35
+ from _durabilitylib import is_ephemeral_path # noqa: E402
36
+ from _hooklib import ( # noqa: E402
37
+ frontmatter_enabled, get_host, project_root, set_host, utf8_stdio,
38
+ write_text_atomic,
39
+ )
40
+ from _standuplib import ( # noqa: E402
41
+ any_actionable,
42
+ ff_pull_eligible,
43
+ merged_branch_candidates,
44
+ parse_ahead_behind,
45
+ parse_porcelain,
46
+ parse_stash_count,
47
+ parse_worktrees,
48
+ stale_worktree_candidates,
49
+ )
50
+ import _taskboardlib # noqa: E402 — shared task-board count/staleness logic
51
+ import _provenancelib # noqa: E402 — shared provenance drift detection (T-16)
52
+ import _updatelib # noqa: E402 — update-available notifier (cache read + notice text)
53
+
54
+ INITIALIZED_RE = re.compile(r"<!--\s*INITIALIZED\s*-->")
55
+ STAGE_RE = re.compile(r"^stage:\s*([0-9]+)", re.I | re.M)
56
+ CONFIRM_RE = re.compile(r"CONFIRM-[0-9]+")
57
+
58
+ # reliability-007 (#190): project_root() is now _hooklib.project_root — imported
59
+ # above, not a local copy. The prior local copy ran `git rev-parse
60
+ # --show-toplevel` from the hook's own cwd and fell back to os.getcwd(),
61
+ # skipping the CLAUDE_PROJECT_DIR-first read _hooklib.project_root() exists
62
+ # for. session-start is the linchpin hook (installs git-enforce hooks, writes
63
+ # standup/dev markers, appends overrides.log) — a wrong root there silently
64
+ # targeted the wrong repository.
65
+
66
+
67
+ def read_text(path):
68
+ try:
69
+ with open(path, encoding="utf-8", errors="replace") as f:
70
+ return f.read()
71
+ except Exception: # noqa: BLE001
72
+ return None
73
+
74
+
75
+ # --- First-of-day standup briefing gating (sprint: session-hygiene, SH-1) ---
76
+ # The decision is a PURE function of (root, local-date-as-ISO-string). The date
77
+ # is INJECTED as a parameter — never read via datetime.date.today() inside these
78
+ # helpers — so the gating is deterministic and unit-testable from fixtures. The
79
+ # only caller that supplies "real today" is main(), at the I/O edge.
80
+
81
+
82
+ def local_date_iso(today=None):
83
+ """ISO `YYYY-MM-DD` for the local date. `today` may be injected (a
84
+ datetime.date) for determinism; defaults to the real local date at the
85
+ I/O edge (main())."""
86
+ d = today if today is not None else datetime.date.today()
87
+ return d.isoformat()
88
+
89
+
90
+ def standup_marker_path(root, date_iso):
91
+ """Path of the first-of-day presence marker for `date_iso`:
92
+ `<root>/.codearbiter/.markers/standup-<YYYY-MM-DD>`."""
93
+ return os.path.join(root, ".codearbiter", ".markers", f"standup-{date_iso}")
94
+
95
+
96
+ def should_emit_briefing(root, date_iso):
97
+ """True iff NO first-of-day marker exists for `date_iso` — i.e. this is the
98
+ first session of the local day, so the full briefing should be emitted.
99
+ A marker already present for the date → False (suppress)."""
100
+ return not os.path.isfile(standup_marker_path(root, date_iso))
101
+
102
+
103
+ # The later-session offer (SH-2) is a SINGLE concise line — never a full
104
+ # briefing. Keep it one physical line (no embedded newlines): the emission must
105
+ # stay exactly one line.
106
+ OFFER_LINE_TEMPLATE = "codeArbiter: hygiene items pending — run {standup}"
107
+ OFFER_LINE = OFFER_LINE_TEMPLATE.format(standup="/ca:standup")
108
+
109
+
110
+ def briefing_mode(marker_present, actionable):
111
+ """Choose the first-vs-later-session briefing mode (SH-2). PURE: a function
112
+ of (marker_present, actionable) so it is testable without git or a clock.
113
+
114
+ Three-mode contract:
115
+ - no marker -> "full" (first session of the day:
116
+ emit the full daily briefing — SH-1)
117
+ - marker present AND actionable -> "offer" (later session today with at
118
+ least one actionable condition: emit
119
+ exactly ONE concise offer line)
120
+ - marker present AND not actionable -> "none" (later session today, nothing
121
+ to do: emit nothing additive)
122
+ """
123
+ if not marker_present:
124
+ return "full"
125
+ return "offer" if actionable else "none"
126
+
127
+
128
+ def write_standup_marker(root, date_iso):
129
+ """Write the first-of-day presence marker for `date_iso`, creating the
130
+ `.markers/` dir lazily. Content is a timestamp (presence is what matters)."""
131
+ path = standup_marker_path(root, date_iso)
132
+ os.makedirs(os.path.dirname(path), exist_ok=True)
133
+ with open(path, "w", encoding="utf-8") as f:
134
+ f.write(f"{time.time()}\n")
135
+ return path
136
+
137
+
138
+ # --- Read-only git invocation layer (SH-4 / content assembly) ---------------
139
+ # Every git call the briefing makes is READ-ONLY. The hook NEVER mutates the
140
+ # repo here (the only write in this whole hook is the standup marker). The
141
+ # invocation layer is a thin wrapper that runs a read-only git command and
142
+ # returns its stdout text, returning "" on ANY failure (missing git, timeout,
143
+ # non-zero exit). PARSING stays in _standuplib (pure). The wrapper takes an
144
+ # injectable `runner` so unit tests feed fake command outputs instead of
145
+ # shelling out to real git.
146
+
147
+ GIT_READ_TIMEOUT = 2.5 # seconds: a read must never stall session startup
148
+
149
+
150
+ def _default_git_runner(args, root):
151
+ """Run `git -C <root> <args...>` read-only and return stdout text. Mirrors the
152
+ safe invocation style of project_root()'s existing rev-parse call: captured
153
+ output, text mode, explicit utf-8 with replacement, a timeout. Raises on any
154
+ failure — git_read() is what turns failure into "" so callers degrade."""
155
+ out = subprocess.run(
156
+ [git_executable(), "-C", root, *args],
157
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
158
+ timeout=GIT_READ_TIMEOUT,
159
+ )
160
+ if out.returncode != 0:
161
+ raise RuntimeError(f"git {args} exited {out.returncode}")
162
+ return out.stdout
163
+
164
+
165
+ def git_read(args, root, runner=None):
166
+ """Run a READ-ONLY git command and return its stdout text, or "" on ANY error.
167
+
168
+ `runner(args, root) -> str` is injectable (tests pass a fake; production uses
169
+ the default subprocess runner). A None return or any raised exception degrades
170
+ to "" so a single failing read never crashes the hook."""
171
+ run = runner or _default_git_runner
172
+ try:
173
+ out = run(args, root)
174
+ except Exception: # noqa: BLE001 — any read failure degrades silently
175
+ return ""
176
+ return out or ""
177
+
178
+
179
+ # --- Non-blocking background fetch (SH-4) -----------------------------------
180
+ # The briefing's ahead/behind reflects the LAST COMPLETED fetch (current local
181
+ # refs); it is annotated as such. To keep that data fresh for NEXT time without
182
+ # blocking THIS hook's stdout/return, we spawn a fully DETACHED `git fetch` that
183
+ # we never await. The hook returns immediately even if the network hangs.
184
+
185
+ STALE_REFS_NOTE = "(ahead/behind as of last fetch — refs may be stale)"
186
+
187
+
188
+ def _detached_fetch_spawner(args, root):
189
+ """Default spawner: launch `git -C <root> <args...>` fully DETACHED. Child
190
+ stdout/stderr go to DEVNULL; the process is decoupled from the hook so it
191
+ outlives this process and is never awaited. POSIX: start_new_session=True
192
+ (new session, no controlling terminal). Windows: DETACHED_PROCESS |
193
+ CREATE_NO_WINDOW so no console window flashes and the child is detached."""
194
+ kw = {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL,
195
+ "stdin": subprocess.DEVNULL}
196
+ if os.name == "nt":
197
+ flags = 0
198
+ flags |= getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
199
+ flags |= getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
200
+ kw["creationflags"] = flags
201
+ else:
202
+ kw["start_new_session"] = True
203
+ return subprocess.Popen([git_executable(), "-C", root, *args], **kw)
204
+
205
+
206
+ def spawn_background_fetch(root, spawner=None):
207
+ """Kick a DETACHED `git fetch` that does NOT block the hook. Returns the spawned
208
+ process handle (for tests to inspect) or None if the spawn failed.
209
+
210
+ The returned handle is NEVER awaited (.wait()/.communicate() are not called),
211
+ so a hanging fetch cannot stall the hook. `spawner(args, root) -> proc` is
212
+ injectable; the default detaches per-platform. Any spawn failure (git missing,
213
+ OSError) is swallowed — offline is tolerated silently."""
214
+ spawn = spawner or _detached_fetch_spawner
215
+ try:
216
+ # --quiet --no-tags: read-only refresh of remote-tracking refs only.
217
+ return spawn(["fetch", "--quiet", "--no-tags"], root)
218
+ except Exception: # noqa: BLE001 — offline / missing git tolerated silently
219
+ return None
220
+
221
+
222
+ # --- Statusline reuse (display-only governance line) ------------------------
223
+ # The full briefing shows a DISPLAY-ONLY governance line — overrides-since-
224
+ # checkpoint, aging CONFIRM count, open-tasks count, stage — computed by
225
+ # statusline.py. We REUSE those computations rather than reimplement them:
226
+ # statusline.arbiter_state(root) and statusline.head_branch(root). Import is
227
+ # lazy + guarded so a statusline import problem never crashes the hook.
228
+
229
+
230
+ def _statusline():
231
+ """Import statusline.py (same dir) lazily, returning the module or None. The
232
+ module is importable via the sys.path entry added at file top; on any failure
233
+ we degrade (the governance line is simply omitted)."""
234
+ try:
235
+ import statusline # noqa: PLC0415 — lazy by design
236
+ return statusline
237
+ except Exception: # noqa: BLE001
238
+ return None
239
+
240
+
241
+ def head_branch(root):
242
+ """Current branch name, reusing statusline.head_branch (reads .git/HEAD).
243
+ None on any problem."""
244
+ sl_mod = _statusline()
245
+ if sl_mod is None:
246
+ return None
247
+ try:
248
+ return sl_mod.head_branch(root)
249
+ except Exception: # noqa: BLE001
250
+ return None
251
+
252
+
253
+ def governance_line(root, ctx_text=None, ot_text=None, oq_text=None):
254
+ """Display-only governance summary reused from statusline.arbiter_state:
255
+ `stage:N tasks:N q:N over:N`. Returns "" when arbiter isn't enabled or on any
256
+ failure. DISPLAY ONLY — never acts on these counts.
257
+
258
+ performance-003 (#194): ctx_text/ot_text/oq_text let the caller (main(),
259
+ which already read CONTEXT.md/open-tasks.md/open-questions.md earlier in
260
+ the SAME invocation) thread that content through so arbiter_state doesn't
261
+ re-read those three files a second time. None (the default) preserves the
262
+ original behavior (arbiter_state reads them itself)."""
263
+ sl_mod = _statusline()
264
+ if sl_mod is None:
265
+ return ""
266
+ try:
267
+ st = sl_mod.arbiter_state(root, ctx_text=ctx_text, ot_text=ot_text, oq_text=oq_text)
268
+ except Exception: # noqa: BLE001
269
+ return ""
270
+ if not st:
271
+ return ""
272
+ return (f"governance: stage:{st['stage']} tasks:{st['tasks']} "
273
+ f"q:{st['q']} over:{st['over']}")
274
+
275
+
276
+ def render_full_briefing(root, summary, ctx_text=None, ot_text=None, oq_text=None):
277
+ """Print the read-only daily briefing body: git hygiene state (with the
278
+ last-fetch staleness note) and the display-only governance line. No mutation.
279
+
280
+ ctx_text/ot_text/oq_text (performance-003) are threaded straight through to
281
+ governance_line — see its docstring."""
282
+ print(f" working tree: {'dirty' if summary['dirty'] else 'clean'} "
283
+ f"(staged:{summary['staged']} unstaged:{summary['unstaged']} "
284
+ f"untracked:{summary['untracked']})")
285
+ if summary.get("upstream", True):
286
+ print(f" upstream: behind {summary['behind']}, ahead {summary['ahead']} "
287
+ f"{STALE_REFS_NOTE}")
288
+ else:
289
+ print(" upstream: none (no tracking branch)")
290
+ if summary.get("ff_pull_eligible"):
291
+ print(f" ff-pull available: clean tree, behind upstream — "
292
+ f"{get_host().cmd_ref('standup')} to fast-forward")
293
+ if summary["prune_candidates"]:
294
+ print(f" merged-branch prune candidates: "
295
+ f"{', '.join(summary['prune_candidates'])}")
296
+ if summary["stashes"]:
297
+ print(f" stashes: {summary['stashes']}")
298
+ gov = governance_line(root, ctx_text=ctx_text, ot_text=ot_text, oq_text=oq_text)
299
+ if gov:
300
+ print(f" {gov}")
301
+
302
+
303
+ # --- Briefing content assembly (read-only) ----------------------------------
304
+
305
+
306
+ def assemble_summary(root, runner=None, current=None, default="main", path_exists=os.path.exists):
307
+ """Assemble the briefing `summary` from READ-ONLY git reads, parsed by the pure
308
+ _standuplib functions. Each read is independent: a failure in one degrades that
309
+ field (absent/zero/empty) without crashing the hook.
310
+
311
+ Reads: `status --porcelain=v1`, `rev-list --left-right --count @{u}...HEAD`
312
+ (empty when no upstream -> behind/ahead 0), `branch -vv`, `worktree list
313
+ --porcelain`, `stash list`. Returns keys consumed by any_actionable(): dirty,
314
+ behind, ahead, unpushed, prune_candidates, stale_worktrees, stashes.
315
+
316
+ `stale_worktrees` is the NON-MAIN worktrees that are stale (branch gone/merged
317
+ OR path missing on disk). The gone/merged set is derived from the SAME
318
+ `branch -vv` text via merged_branch_candidates (the `: gone]` branches). The
319
+ disk check uses an injectable `path_exists` so the field is deterministic in
320
+ tests. Read-only: identifies candidates only — never removes a worktree.
321
+
322
+ performance-002 (#194): the five reads above are independent (each degrades
323
+ its own field on failure; none depends on another's output), so they fan out
324
+ across a small thread pool instead of running strictly sequentially — on
325
+ Windows especially, process-creation overhead for `git` compounds when five
326
+ spawns block one after another. Results are gathered before any parsing runs,
327
+ so the parsed values are byte-identical to the sequential form."""
328
+ reads = {
329
+ "porcelain": ["status", "--porcelain=v1"],
330
+ "revlist": ["rev-list", "--left-right", "--count", "@{u}...HEAD"],
331
+ "branch_vv": ["branch", "-vv"],
332
+ "worktree_raw": ["worktree", "list", "--porcelain"],
333
+ "stash_raw": ["stash", "list"],
334
+ }
335
+ with concurrent.futures.ThreadPoolExecutor(max_workers=len(reads)) as ex:
336
+ futures = {name: ex.submit(git_read, args, root, runner) for name, args in reads.items()}
337
+ out = {name: f.result() for name, f in futures.items()}
338
+
339
+ porcelain = out["porcelain"]
340
+ p = parse_porcelain(porcelain)
341
+
342
+ revlist = out["revlist"]
343
+ behind, ahead = parse_ahead_behind(revlist)
344
+ # No tracking branch -> git errors -> git_read returns "". Distinguish that from
345
+ # an in-sync upstream (which returns "0\t0") so the briefing can suppress the
346
+ # misleading "behind 0, ahead 0 (as of last fetch)" line when no upstream exists.
347
+ has_upstream = bool(revlist.strip())
348
+
349
+ branch_vv = out["branch_vv"]
350
+ prune = merged_branch_candidates(branch_vv, current=current, default=default)
351
+
352
+ # Stale-worktree candidates: parse `worktree list --porcelain`, derive the
353
+ # gone/merged branch set from the same branch -vv text, classify. A read error
354
+ # degrades to [] (parse_worktrees("") -> []), so the field never crashes.
355
+ worktrees = parse_worktrees(out["worktree_raw"], root)
356
+ gone = set(merged_branch_candidates(branch_vv, current=current, default=default))
357
+ stale_worktrees = stale_worktree_candidates(worktrees, gone, path_exists=path_exists)
358
+
359
+ stashes = parse_stash_count(out["stash_raw"])
360
+
361
+ return {
362
+ "dirty": p["dirty"],
363
+ "staged": p["staged"],
364
+ "unstaged": p["unstaged"],
365
+ "untracked": p["untracked"],
366
+ "behind": behind,
367
+ "ahead": ahead,
368
+ "upstream": has_upstream,
369
+ # SH-6: the canonical ff-pull gate (clean tree AND behind>0), computed by
370
+ # the same pure helper /ca:standup acts on — no re-derivation in prose.
371
+ "ff_pull_eligible": ff_pull_eligible(porcelain, behind),
372
+ "unpushed": ahead, # alias: ahead == commits not yet pushed upstream
373
+ "prune_candidates": prune,
374
+ "stale_worktrees": stale_worktrees,
375
+ "stashes": stashes,
376
+ }
377
+
378
+
379
+ # --- Statusline pin self-heal (SessionStart) -------------------------------
380
+ # A plugin cannot own a statusLine and ${CLAUDE_PLUGIN_ROOT} is NOT expanded in
381
+ # settings.json, so wire-statusline.py writes an ABSOLUTE, version-pinned path.
382
+ # Nothing re-ran it after a plugin update, so an updated install kept invoking the
383
+ # OLD version's statusline.py — stale, and eventually broken when that cache dir
384
+ # is pruned. We heal it here every SessionStart: refresh a ca-OWNED pin to the
385
+ # current renderer path, persisting ONLY on a real change (no steady-state churn),
386
+ # and degrade silently on ANY failure — a wiring refresh must never crash startup.
387
+
388
+
389
+ def _load_wire_statusline(plugin):
390
+ """Load wire-statusline.py (hyphenated filename) from <plugin>/hooks/ as a
391
+ module, or None on any failure."""
392
+ try:
393
+ import importlib.util # noqa: PLC0415 — lazy by design
394
+ path = os.path.join(plugin, "hooks", "wire-statusline.py")
395
+ spec = importlib.util.spec_from_file_location("wire_statusline", path)
396
+ mod = importlib.util.module_from_spec(spec)
397
+ spec.loader.exec_module(mod)
398
+ return mod
399
+ except Exception: # noqa: BLE001
400
+ return None
401
+
402
+
403
+ def heal_statusline_wiring(plugin, settings_path=None, interp=None, loader=None):
404
+ """Refresh a stale ca-OWNED statusLine pin to the current renderer path.
405
+ Returns True iff settings.json was rewritten. Fully guarded: any failure —
406
+ including a corrupt settings.json (which wire-statusline raises SystemExit on)
407
+ — degrades to False so it never crashes session startup.
408
+
409
+ reliability-009: settings.json is the user's WHOLE host configuration, not
410
+ a ca-owned file — a full read-modify-write of it must not clobber a change
411
+ made by a concurrent session (or the user) between our load and our save.
412
+ Narrow that window by reloading the file fresh immediately before writing:
413
+ if it differs from what we loaded, some other writer touched it in the
414
+ interim, so we SKIP this heal entirely (never overwrite that write with
415
+ our now-stale snapshot) — a later session's heal simply retries.
416
+
417
+ NON-DURABLE ROOTS ARE INERT (found in-session 2026-07-25, after it broke the
418
+ maintainer's statusline three times in one day). This hook runs on EVERY
419
+ SessionStart and pins an ABSOLUTE path into the user's GLOBAL settings.json.
420
+ A session started inside a git worktree (subagents run in
421
+ `<repo>/.claude/worktrees/<id>/`) resolves `plugin` to that worktree, and the
422
+ heal pinned the global config at a directory whose entire purpose is to be
423
+ pruned. When the root is not durable we leave the existing pin exactly as it
424
+ is — not healed, not cleared, no error.
425
+
426
+ wire-statusline.refresh_if_stale enforces the same rule (it is the producer,
427
+ and also reachable by a human running `--plugin-root <worktree>`), so this
428
+ check is deliberately REDUNDANT — but not decoratively so. `_load_wire_
429
+ statusline` loads that producer OUT OF `plugin` itself: a worktree cut from a
430
+ pre-fix branch supplies a pre-fix, unguarded producer, while THIS file may
431
+ have been loaded from somewhere else entirely (main() honours
432
+ $CLAUDE_PLUGIN_ROOT independently of where session-start.py came from). The
433
+ highest-consequence write on the machine gets to refuse on its own account
434
+ rather than on the good behaviour of whatever version it happened to load.
435
+ Both call sites share the one predicate, so there is no second policy to
436
+ drift."""
437
+ try:
438
+ script_abs = os.path.join(plugin, "hooks", "statusline.py")
439
+ if is_ephemeral_path(script_abs):
440
+ return False
441
+ ws = (loader or _load_wire_statusline)(plugin)
442
+ if ws is None:
443
+ return False
444
+ spath = settings_path or ws.settings_path(None)
445
+ interp = interp or ws.default_interp(None)
446
+ settings, exists = ws.load_settings(spath)
447
+ if not exists:
448
+ return False
449
+ original = copy.deepcopy(settings)
450
+ if not ws.refresh_if_stale(settings, script_abs, interp):
451
+ return False
452
+ fresh, fresh_exists = ws.load_settings(spath)
453
+ if not fresh_exists or fresh != original:
454
+ return False # changed underneath us — skip, retry next session
455
+ ws.save_settings(spath, settings)
456
+ return True
457
+ except (Exception, SystemExit): # noqa: BLE001 — heal is best-effort, never fatal
458
+ return False
459
+
460
+
461
+ def has_source(root):
462
+ """True if the repo contains any file that isn't arbiter/scaffold cruft —
463
+ distinguishes brownfield (adopt existing code) from greenfield. Returns on the
464
+ first match, so it does not walk a large tree."""
465
+ excl_top = {".git", ".codearbiter", ".claude", "legacy"}
466
+ excl_names = {"README.md", "LICENSE", ".gitignore", "AGENTS.md", "CLAUDE.md", ".gitmodules"}
467
+ for cur, dirs, files in os.walk(root):
468
+ if cur == root:
469
+ dirs[:] = [d for d in dirs if d not in excl_top]
470
+ else:
471
+ dirs[:] = [d for d in dirs if d != ".git"]
472
+ for fn in files:
473
+ if fn not in excl_names:
474
+ return True
475
+ return False
476
+
477
+
478
+ # #271 C-5 — session-scoping the repo-global dev marker. The marker itself
479
+ # carries no owner: it is dropped by /dev's own prose (dev.md), which has no
480
+ # reliable way to stamp a real session_id into its content (slash-command
481
+ # prose never receives the hook JSON payload — only actual HOOKS do). So
482
+ # ownership is tracked SEPARATELY, by SessionStart itself: every invocation
483
+ # records (its OWN session_id, now) as "the last session known to have
484
+ # started in this repo" BEFORE deciding what to do with the dev marker.
485
+ #
486
+ # This is a heuristic, not true liveness detection (there is no SessionEnd
487
+ # signal this hook can rely on) — documented tradeoff, not a defect. The
488
+ # record's timestamp is ANCHORED TO THE OWNER, not to "whatever session
489
+ # started most recently" — that distinction is load-bearing (a review caught
490
+ # an earlier draft that refreshed it unconditionally on every invocation,
491
+ # which meant an unrelated session B/C/D/... starting in an otherwise-active
492
+ # repo kept sliding the window forward forever and the marker became
493
+ # immortal). The write is therefore CONDITIONAL, decided AFTER checking the
494
+ # marker, not before:
495
+ # - no live marker at all: refresh freely — "the session that could next
496
+ # enter /dev is me" is exactly the fact this record exists to hold.
497
+ # - live marker AND session_id == prev_sid: refresh. This is the owner
498
+ # heartbeating through a resume/compaction, and it's what keeps a
499
+ # genuinely long /ca:dev sitting from being force-closed at the 6h mark.
500
+ # - live marker AND a DIFFERENT session_id: do NOT write. `prev_ts` stays
501
+ # anchored to the OWNER's last known activity — a different session
502
+ # merely observing the marker must not reset that clock, or it would
503
+ # never elapse in any repo that sees regular unrelated activity.
504
+ #
505
+ # Net effect: a marker owned by a session that crashed is left alone by every
506
+ # later, unrelated session (they cannot know it is dead) but self-heals
507
+ # DEV_SESSION_LIVENESS_WINDOW after the OWNER's own last recorded activity —
508
+ # not after the most recent unrelated SessionStart. Symmetric residual: a
509
+ # genuinely live /ca:dev sitting untouched (no resume/compaction of its own)
510
+ # for longer than the window can still be force-closed by a later session,
511
+ # same as the pre-#271 behavior would have done immediately. No session_id
512
+ # available on this invocation/host (Codex parity unverified), or no prior
513
+ # record at all, degrades to the original unconditional clear — a marker that
514
+ # can NEVER be cleared is a worse failure mode than one cleared too eagerly.
515
+ DEV_SESSION_LIVENESS_WINDOW = 6 * 3600 # 6h: generous single-sitting bound
516
+
517
+
518
+ def _dev_session_owner_path(root):
519
+ return os.path.join(root, ".codearbiter", ".markers", "dev-session-owner.json")
520
+
521
+
522
+ def _read_dev_session_owner(root):
523
+ """(session_id, ts) last recorded by ANY SessionStart invocation in this
524
+ repo, or (None, None) on an absent/corrupt/malformed record. Never
525
+ raises."""
526
+ try:
527
+ with open(_dev_session_owner_path(root), encoding="utf-8") as f:
528
+ data = json.load(f)
529
+ sid = data.get("session_id")
530
+ ts = data.get("ts")
531
+ if isinstance(sid, str) and sid and isinstance(ts, (int, float)):
532
+ return sid, float(ts)
533
+ except Exception: # noqa: BLE001 — corrupt/absent record -> no signal
534
+ pass
535
+ return None, None
536
+
537
+
538
+ def _write_dev_session_owner(root, session_id, ts):
539
+ """Best-effort refresh of the last-known-active-session record. Never
540
+ raises — a write failure just means the NEXT SessionStart degrades to the
541
+ conservative no-prior-record fallback, exactly as if this were the first
542
+ session ever."""
543
+ try:
544
+ path = _dev_session_owner_path(root)
545
+ os.makedirs(os.path.dirname(path), exist_ok=True)
546
+ write_text_atomic(path, json.dumps({"session_id": session_id, "ts": ts}))
547
+ except Exception: # noqa: BLE001 — must never brick session startup
548
+ pass
549
+
550
+
551
+ # --- #396: a durable, retryable DEV: exit -----------------------------------
552
+ # The synthetic close line is the ONLY thing that keeps the append-only audit
553
+ # trail's DEV: enter/exit pairs matched after an abandoned maintainer session.
554
+ # It used to be written best-effort ("except OSError: pass") and the marker was
555
+ # then removed regardless — so a locked file, a full disk, or a permission blip
556
+ # permanently erased the obligation and left an orphaned DEV: enter that no
557
+ # later session could know about.
558
+ #
559
+ # The fix is a small write-ahead record: the owed line is staged on disk BEFORE
560
+ # the append is attempted, and the record is deleted only once BOTH the append
561
+ # is confirmed AND the marker it settles is gone. That single record therefore
562
+ # carries three facts at once:
563
+ #
564
+ # "lines" — close lines still owed to overrides.log. Emptied one at a
565
+ # time as each append is confirmed.
566
+ # "marker_mtime" — the identity of the dev-active marker this close belongs
567
+ # to. While the record still names a LIVE marker, the
568
+ # force-close path knows that marker has already been
569
+ # closed in the audit trail and refuses to mint a second
570
+ # row for it — which is what makes a failed `os.remove`
571
+ # idempotent rather than duplicating the close. It is
572
+ # cleared the moment that marker is gone: an mtime only
573
+ # identifies a file that still EXISTS, and a stale one is
574
+ # free to collide with an unrelated future marker (2s
575
+ # granularity on FAT32/exFAT/SMB/WSL mounts makes that a
576
+ # real event, not a theoretical one) and suppress a close
577
+ # that is genuinely owed.
578
+ # "dropped" — how many owed close lines the bound below has discarded.
579
+ # The cap keeps the record small, but the loss must not be
580
+ # silent: the count is written to the trail as one
581
+ # attributable note the moment overrides.log accepts writes.
582
+ #
583
+ # Replayed lines carry the timestamp they were MINTED with, not the time they
584
+ # land, so a delayed replay leaves overrides.log non-chronological. Enter/exit
585
+ # pairing is by timestamp, so that is correct — but an audit reader must not
586
+ # assume file order is time order.
587
+ #
588
+ # Every boundary is covered:
589
+ # crash before the append -> record present, line owed -> replayed
590
+ # crash after the append -> record present, line owed -> the bounded
591
+ # tail scan sees the line already landed and
592
+ # drops it instead of appending a duplicate
593
+ # marker removal fails -> record present, no line owed -> the next
594
+ # session only retries the removal
595
+ #
596
+ # That tail scan is applied ONLY to lines read back off the record — the ones
597
+ # that might have landed before a crash. A line minted in THIS process cannot
598
+ # already be on the trail, and must never be dedupe-checked: close rows are
599
+ # timestamped to the second, so two distinct closes minted in the same second
600
+ # are byte-identical, and checking the fresh one against an owed copy of itself
601
+ # would silently swallow a close that is genuinely owed.
602
+ #
603
+ # Everything here is best-effort by the module's standing convention: session
604
+ # startup must never be bricked by audit bookkeeping, so nothing raises.
605
+ _DEV_PENDING_CLOSE_MAX = 8 # bounded: never accumulate owed lines forever
606
+ _DEV_PENDING_SCAN_BYTES = 64 * 1024 # bounded tail scan for the dedupe check
607
+
608
+
609
+ def _dev_pending_close_path(root):
610
+ return os.path.join(root, ".codearbiter", ".markers", "dev-close-pending.json")
611
+
612
+
613
+ def _overrides_log_path(root):
614
+ return os.path.join(root, ".codearbiter", "overrides.log")
615
+
616
+
617
+ def _read_dev_pending_close(root):
618
+ """The pending-close record as
619
+ {"lines": [...], "marker_mtime": float|None, "dropped": int}, or None when
620
+ there is nothing usable on disk. A record that exists but carries no
621
+ replayable line, no marker identity and no unreported drop is reported as
622
+ None so the caller discards it — a corrupt record must never wedge the
623
+ mechanism shut. Never raises."""
624
+ try:
625
+ with open(_dev_pending_close_path(root), encoding="utf-8") as f:
626
+ data = json.load(f)
627
+ if not isinstance(data, dict):
628
+ return None
629
+ lines = [ln for ln in (data.get("lines") or [])
630
+ if isinstance(ln, str) and ln.strip()][:_DEV_PENDING_CLOSE_MAX]
631
+ mtime = data.get("marker_mtime")
632
+ mtime = float(mtime) if isinstance(mtime, (int, float)) else None
633
+ dropped = data.get("dropped")
634
+ # `isinstance(True, int)` is True, so booleans are excluded explicitly.
635
+ dropped = (int(dropped) if isinstance(dropped, int)
636
+ and not isinstance(dropped, bool) and dropped > 0 else 0)
637
+ if not lines and mtime is None and not dropped:
638
+ return None
639
+ return {"lines": lines, "marker_mtime": mtime, "dropped": dropped}
640
+ except Exception: # noqa: BLE001 — absent/corrupt record -> no signal
641
+ return None
642
+
643
+
644
+ def _write_dev_pending_close(root, rec):
645
+ """Atomically persist the pending-close record. Never raises — a write
646
+ failure only costs the retry signal this call was trying to create, which
647
+ is exactly the pre-#396 behavior and still must not brick startup."""
648
+ try:
649
+ path = _dev_pending_close_path(root)
650
+ os.makedirs(os.path.dirname(path), exist_ok=True)
651
+ write_text_atomic(path, json.dumps(rec), newline="\n")
652
+ except Exception: # noqa: BLE001 — must never brick session startup
653
+ pass
654
+
655
+
656
+ def _discard_dev_pending_close(root):
657
+ try:
658
+ os.remove(_dev_pending_close_path(root))
659
+ except OSError:
660
+ pass
661
+
662
+
663
+ def _overrides_has_line(root, line):
664
+ """True iff `line` already appears in the tail of overrides.log. Bounded to
665
+ the last _DEV_PENDING_SCAN_BYTES — a replay always happens on the very next
666
+ SessionStart, so the line it is looking for is at (or near) the end. An
667
+ unreadable log answers False: re-appending a close row is a far smaller
668
+ harm than silently dropping one.
669
+
670
+ Read in BINARY and decoded here on purpose: a byte offset is only
671
+ meaningful to seek() on a binary stream, and the comparison is made on the
672
+ stripped line so the platform EOL the append produced never matters."""
673
+ needle = line.strip()
674
+ if not needle:
675
+ return False
676
+ try:
677
+ path = _overrides_log_path(root)
678
+ size = os.path.getsize(path)
679
+ with open(path, "rb") as f:
680
+ if size > _DEV_PENDING_SCAN_BYTES:
681
+ f.seek(size - _DEV_PENDING_SCAN_BYTES)
682
+ tail = f.read().decode("utf-8", "replace")
683
+ return needle in tail
684
+ except Exception: # noqa: BLE001 — cannot confirm -> assume not present
685
+ return False
686
+
687
+
688
+ def _append_override_line(root, line):
689
+ """Append one audit line to overrides.log. True on a confirmed write."""
690
+ try:
691
+ with open(_overrides_log_path(root), "a", encoding="utf-8") as f:
692
+ f.write(line)
693
+ return True
694
+ except OSError:
695
+ return False
696
+
697
+
698
+ def _dev_dropped_close_note(count, host_name=None):
699
+ """One audit line accounting for close rows the pending-close cap had to
700
+ discard. Deliberately NOT a `DEV: exit` row — it closes nothing; it records
701
+ that N closes can never be written, so a reader of the append-only trail
702
+ can attribute the unmatched entries instead of finding an unexplained gap.
703
+ """
704
+ ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
705
+ return (f"[{ts}] | BY: session-cleanup | HOST: {host_name or 'unknown'} "
706
+ f"| DEV: close-dropped | NOTE: {count} owed close row(s) discarded - the "
707
+ f"pending-close cap ({_DEV_PENDING_CLOSE_MAX}) was reached while "
708
+ f"overrides.log was unwritable; that many maintainer sessions have "
709
+ f"no matching close row\n")
710
+
711
+
712
+ def _settle_dev_close(root, marker=None, new_line=None, host_name=None):
713
+ """Drive the pending-close record to settlement; the single place the owed
714
+ DEV: exit is appended and the retry state is cleared.
715
+
716
+ `marker` is the dev-active path when one is live (its mtime becomes the
717
+ close identity), None when there is no marker to settle. `new_line` is a
718
+ freshly minted close line to take on, or None when this is a pure replay of
719
+ whatever is already owed. `host_name` only attributes the cap-overflow note
720
+ below; the close lines themselves already carry their own HOST field.
721
+ Returns the number of close lines appended by THIS call. Never raises."""
722
+ if (marker is None and new_line is None
723
+ and not os.path.isfile(_dev_pending_close_path(root))):
724
+ return 0 # nothing owed, nothing to settle — the overwhelming case
725
+ rec = _read_dev_pending_close(root)
726
+ owed = list(rec["lines"]) if rec else []
727
+ prev_mtime = rec["marker_mtime"] if rec else None
728
+ dropped = rec["dropped"] if rec else 0
729
+
730
+ marker_mtime = None
731
+ if marker:
732
+ try:
733
+ marker_mtime = os.path.getmtime(marker)
734
+ except OSError:
735
+ marker_mtime = None
736
+
737
+ # Everything already in `owed` came off disk, so it MAY have reached the
738
+ # trail before a crash and has to be dedupe-checked. Anything appended
739
+ # below is minted in this process and cannot possibly be there yet.
740
+ replays = len(owed)
741
+
742
+ if new_line is not None:
743
+ # Already closed THIS marker (the append landed, only the removal
744
+ # failed) -> do not mint a second row for it; just retry the cleanup.
745
+ already_closed = (rec is not None and prev_mtime is not None
746
+ and marker_mtime is not None
747
+ and prev_mtime == marker_mtime)
748
+ if not already_closed:
749
+ owed.append(new_line)
750
+ if len(owed) > _DEV_PENDING_CLOSE_MAX:
751
+ # Bounded, but never SILENT. A permanently-unwritable overrides.log
752
+ # would otherwise accumulate owed lines forever, so the oldest are
753
+ # discarded — and counted, so the loss is itself auditable rather than
754
+ # reintroducing exactly the unmatched `DEV: enter` this record exists
755
+ # to prevent.
756
+ overflow = len(owed) - _DEV_PENDING_CLOSE_MAX
757
+ dropped += overflow
758
+ owed = owed[-_DEV_PENDING_CLOSE_MAX:]
759
+ replays = max(0, replays - overflow) # the discards come off the front
760
+
761
+ if owed or dropped or marker_mtime is not None:
762
+ # Write-ahead: the obligation is durable BEFORE the append is tried.
763
+ _write_dev_pending_close(root, {"lines": owed,
764
+ "marker_mtime": marker_mtime,
765
+ "dropped": dropped})
766
+
767
+ # The overflow note goes in FIRST — the rows it accounts for are older than
768
+ # everything still owed. It is minted fresh each attempt, so it is not
769
+ # deduped by the tail scan; a crash between this append and the write-back
770
+ # below can repeat it once, which is the same "a duplicate beats a loss"
771
+ # trade the close rows themselves make.
772
+ if dropped and _append_override_line(root, _dev_dropped_close_note(dropped, host_name)):
773
+ dropped = 0
774
+
775
+ appended = 0
776
+ remaining = []
777
+ stalled = False
778
+ for idx, line in enumerate(owed):
779
+ if stalled:
780
+ remaining.append(line) # the log is failing — everything after
781
+ continue # the first failure is still owed
782
+ if idx < replays and _overrides_has_line(root, line):
783
+ continue # crash-after-append: already in the trail
784
+ if not _append_override_line(root, line):
785
+ stalled = True
786
+ remaining.append(line) # still owed — replay on the next session
787
+ continue
788
+ appended += 1
789
+
790
+ marker_gone = True
791
+ if marker:
792
+ try:
793
+ os.remove(marker)
794
+ except OSError:
795
+ marker_gone = not os.path.isfile(marker)
796
+
797
+ # Keep the record ONLY while it still carries information: a line still
798
+ # owed, an unreported cap overflow, or the identity of a marker that
799
+ # survived its own removal (the tombstone that stops the next session
800
+ # minting a second close for it). A marker that IS gone takes its tombstone
801
+ # with it — a dead marker's mtime identifies nothing, and leaving it behind
802
+ # lets an unrelated future marker collide with it and lose a real close.
803
+ if remaining or dropped or (not marker_gone and marker_mtime is not None):
804
+ _write_dev_pending_close(root, {"lines": remaining,
805
+ "marker_mtime": (None if marker_gone
806
+ else marker_mtime),
807
+ "dropped": dropped})
808
+ else:
809
+ _discard_dev_pending_close(root)
810
+ return appended
811
+
812
+
813
+ def clear_dev_marker(root, host_name=None, session_id=None, now=None):
814
+ """Clear the per-session /dev statusline marker on startup. If the marker is
815
+ LIVE (a prior session entered /ca:dev and ended without /ca:arbiter), append a
816
+ synthetic DEV: exit line to overrides.log BEFORE removing it
817
+ (observability-001) — otherwise the audit trail keeps an orphaned DEV: enter
818
+ with no matching close. Append-only (it never rewrites); best-effort — a write
819
+ or remove failure must never brick session startup.
820
+
821
+ #396: "best-effort" is no longer "best-effort ONCE". The close is routed
822
+ through _settle_dev_close, which stages the owed line durably before
823
+ attempting the append and clears that retry state only after the append is
824
+ confirmed — so a locked/failing overrides.log leaves a replayable record
825
+ instead of an orphaned DEV: enter. Startup itself still fails OPEN: this
826
+ function returns normally on every path, exactly as before.
827
+
828
+ `host_name` (observability-001/ADR-0012) is the resolved host's `.name`
829
+ ("claude"/"codex"/"unknown"), so the synthetic close line is attributable to
830
+ the host that wrote it now that three hosts share one overrides.log
831
+ (ADR-0011). Optional and defaults to resolving it here via `get_host()`
832
+ (#257) — main() already holds a Host instance and passes its `.name`
833
+ through to avoid a second resolution, but any other caller (tests
834
+ included) may omit it.
835
+
836
+ `session_id` (#271 C-5) is THIS invocation's own session id from the
837
+ SessionStart hook payload, when the host supplies one. See the module
838
+ comment above `DEV_SESSION_LIVENESS_WINDOW` for the full session-scoping
839
+ contract: a live marker is only force-closed when there is no reason to
840
+ believe a DIFFERENT, still-running session currently owns it — and the
841
+ ownership record's timestamp is refreshed ONLY by the owner itself (never
842
+ by an unrelated session merely observing the marker), so the liveness
843
+ window is anchored to the owner's last activity, not reset by every
844
+ passerby SessionStart. `now` (epoch seconds) is injectable for
845
+ deterministic tests; defaults to `time.time()`."""
846
+ now = time.time() if now is None else now
847
+ prev_sid, prev_ts = _read_dev_session_owner(root)
848
+
849
+ marker = os.path.join(root, ".codearbiter", ".markers", "dev-active")
850
+ marker_live = os.path.isfile(marker)
851
+
852
+ if not marker_live:
853
+ # No live marker: this record is purely "who could next enter /dev" —
854
+ # any session refreshing it is harmless and correct. Nothing else to
855
+ # do — there is no marker to clear, but a close owed by an EARLIER
856
+ # session whose append failed is still replayed here (#396); that is
857
+ # precisely the case the old code could never recover from.
858
+ #
859
+ # `host_name` is passed through as-is (it only attributes the
860
+ # cap-overflow note) rather than resolved here: main() already hands
861
+ # the real host name down, and this branch is the overwhelmingly
862
+ # common one — it must not pay for a host resolution on every startup.
863
+ _settle_dev_close(root, host_name=host_name)
864
+ if session_id:
865
+ _write_dev_session_owner(root, session_id, now)
866
+ return
867
+
868
+ if session_id and prev_sid:
869
+ if prev_sid == session_id:
870
+ # The owner itself, resuming/compacting mid-dev — refresh ITS OWN
871
+ # heartbeat (this is the only case where a write is safe while the
872
+ # marker is live) and leave the marker untouched.
873
+ #
874
+ # #396: deliberately NO _settle_dev_close here or in the sibling
875
+ # branch below. Both return with the marker still LIVE, and a
876
+ # pending record naming a live marker doubles as the "this marker
877
+ # has already been closed in the trail" tombstone — settling it
878
+ # against a marker we are not allowed to touch would discard that
879
+ # tombstone and let a later force-close mint a duplicate row. Any
880
+ # owed line simply waits for a session that is entitled to act.
881
+ _write_dev_session_owner(root, session_id, now)
882
+ return
883
+ if (now - prev_ts) < DEV_SESSION_LIVENESS_WINDOW:
884
+ # A different session, and the OWNER's own clock hasn't elapsed
885
+ # yet — do NOT touch the record (an unrelated observer must never
886
+ # reset a clock it doesn't own) and do not clobber the marker.
887
+ return
888
+ # Different session AND the owner's own record is stale beyond the
889
+ # window: proceed to the force-close below. Deliberately do not write
890
+ # a fresh record here either — there is no live owner left to anchor
891
+ # a new one to; the write happens naturally next time /dev is entered.
892
+
893
+ if session_id and not prev_sid:
894
+ # No prior record at all (first session ever, or a dropped record) —
895
+ # no signal to protect a concurrent owner; seed the record for next
896
+ # time and fall through to the pre-#271 unconditional-clear behavior.
897
+ _write_dev_session_owner(root, session_id, now)
898
+
899
+ # Force-close: either no session_id/no prior record (unconditional-clear
900
+ # fallback), or a genuinely stale owner beyond the window.
901
+ if host_name is None:
902
+ try:
903
+ # get_host() (#257), not a direct hostapi.load_host(): resolves
904
+ # the SAME Host run(host) injected instead of a second load.
905
+ host_name = get_host().name
906
+ except Exception: # noqa: BLE001 — must never brick session startup
907
+ host_name = "unknown"
908
+ try:
909
+ arbiter_ref = get_host().cmd_ref("arbiter")
910
+ except Exception: # noqa: BLE001 — must never brick session startup
911
+ arbiter_ref = "/ca:arbiter"
912
+ ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
913
+ line = (f"[{ts}] | BY: session-cleanup | HOST: {host_name} | DEV: exit | NOTE: cleared by "
914
+ f"SessionStart (prior session ended mid-dev without {arbiter_ref})\n")
915
+ # #396: stage-then-append-then-clear, all inside one settlement step. The
916
+ # append is no longer a fire-and-forget `except OSError: pass` followed by
917
+ # an unconditional marker delete — the owed line outlives a failed write.
918
+ _settle_dev_close(root, marker=marker, new_line=line, host_name=host_name)
919
+
920
+
921
+ def provenance_drift_line(root, runner=None):
922
+ """One-line SessionStart drift notice, or "" when clean/degraded.
923
+
924
+ Wraps _provenancelib.startup_drift_line; any failure degrades to "" so the
925
+ linchpin hook never crashes (mirrors the task-board guard). `runner` is
926
+ injectable so tests are deterministic/offline; production passes None which
927
+ lets the lib bind its default `git -C root hash-object` runner. (T-16)"""
928
+ try:
929
+ return _provenancelib.startup_drift_line(
930
+ root, runner=runner, cmd_ref=get_host().cmd_ref)
931
+ except Exception: # noqa: BLE001 — never crash session startup
932
+ return ""
933
+
934
+
935
+ # --- Update-available notifier (spec: update-available-notifier.md) ---------
936
+ # codeArbiter ships via a third-party marketplace, which Claude Code does NOT
937
+ # auto-update by default. This surfaces a single line when the cached "latest"
938
+ # GitHub release exceeds the installed plugin.json version — reading ONLY the
939
+ # user-global cache (one file read, AC-3: no synchronous network call added to
940
+ # this hot path). The cache itself is refreshed off-path by a DETACHED spawn of
941
+ # update-refresh.py (below), mirroring spawn_background_fetch's git-fetch
942
+ # pattern; that refresh is separately gated to at most once per day by
943
+ # _updatelib.refresh_if_stale's own checked_at check (AC-4).
944
+
945
+
946
+ def update_notice_line(plugin):
947
+ """The single-line update-available notice (AC-1/AC-2), or "" when no update
948
+ is due or on ANY degrade (missing/corrupt cache, missing/corrupt plugin.json)
949
+ — never raises (AC-3). Reads the cache and the installed version only; makes
950
+ no network call itself."""
951
+ try:
952
+ state = _updatelib.read_state(_updatelib.state_path())
953
+ latest = state.get("latest") if isinstance(state, dict) else None
954
+ installed = _updatelib.installed_version(plugin)
955
+ return _updatelib.notice_line(installed, latest) or ""
956
+ except Exception: # noqa: BLE001 — never crash session startup
957
+ return ""
958
+
959
+
960
+ def _detached_update_refresh_spawner(plugin):
961
+ """Default spawner: launch `<python> <plugin>/hooks/update-refresh.py` fully
962
+ DETACHED — same decoupling as _detached_fetch_spawner (child stdout/stderr to
963
+ DEVNULL, new session/process group so it outlives this process and is never
964
+ awaited)."""
965
+ script = os.path.join(plugin, "hooks", "update-refresh.py")
966
+ kw = {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL,
967
+ "stdin": subprocess.DEVNULL}
968
+ if os.name == "nt":
969
+ flags = 0
970
+ flags |= getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
971
+ flags |= getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
972
+ kw["creationflags"] = flags
973
+ else:
974
+ kw["start_new_session"] = True
975
+ return subprocess.Popen([sys.executable, script], **kw)
976
+
977
+
978
+ def spawn_background_update_refresh(plugin, spawner=None):
979
+ """Kick a DETACHED update-refresh.py that does NOT block the hook (AC-3: the
980
+ network call this eventually makes is entirely off SessionStart's hot path).
981
+ Returns the spawned process handle (for tests) or None on any spawn failure —
982
+ NEVER awaited, so a hung or unreachable network cannot stall SessionStart.
983
+ `spawner(plugin) -> proc` is injectable; the default detaches per-platform."""
984
+ spawn = spawner or _detached_update_refresh_spawner
985
+ try:
986
+ return spawn(plugin)
987
+ except Exception: # noqa: BLE001 — spawn failure tolerated silently
988
+ return None
989
+
990
+
991
+ def _session_id_from_stdin():
992
+ """Best-effort session_id from the SessionStart hook's own JSON payload
993
+ (#271 C-5) — session-start.py has never read its stdin before this. Reads
994
+ directly rather than via `_hooklib.read_input()` so an absent/empty
995
+ session_id degrades SILENTLY (it is a normal, expected condition on a host
996
+ that doesn't supply one — not a parse error worth a `warn()` breadcrumb on
997
+ every single session start). Guards against a blocking read on an
998
+ interactive stdin the same way statusline.py's `main()` does (`isatty()`
999
+ check) — this hook must never hang session startup waiting for input that
1000
+ will never arrive. Returns "" on any failure, absence, or malformed
1001
+ payload; the caller treats an empty session_id as "unavailable" and
1002
+ degrades to the pre-#271 unconditional-clear behavior."""
1003
+ try:
1004
+ if sys.stdin.isatty():
1005
+ return ""
1006
+ raw = sys.stdin.read()
1007
+ if not raw.strip():
1008
+ return ""
1009
+ data = json.loads(raw)
1010
+ return str(data.get("session_id") or "") if isinstance(data, dict) else ""
1011
+ except Exception: # noqa: BLE001 — must never brick session startup
1012
+ return ""
1013
+
1014
+
1015
+ def main():
1016
+ utf8_stdio()
1017
+ # get_host() (#257): resolves the SAME Host run(host) already primed via
1018
+ # set_host(), instead of a second hostapi.load_host() disk/probe.
1019
+ host = get_host()
1020
+ root = project_root()
1021
+ plugin = host.plugin_root()
1022
+ ctx = os.path.join(root, ".codearbiter", "CONTEXT.md")
1023
+ session_id = _session_id_from_stdin()
1024
+
1025
+ # /dev developer-override is per-session: clear its statusline marker on
1026
+ # startup — a new session restores orchestration. A live marker means a prior
1027
+ # session never ran /ca:arbiter, so close the DEV audit pair before clearing.
1028
+ # session_id (#271 C-5) lets this distinguish "the same session resuming"
1029
+ # and "a different, possibly still-live session" from a genuinely
1030
+ # abandoned marker — see clear_dev_marker's docstring.
1031
+ clear_dev_marker(root, host.name, session_id)
1032
+
1033
+ # Self-heal a stale ca-owned statusLine pin before the dormant gate: the
1034
+ # statusline is wired GLOBALLY in ~/.claude/settings.json, so a plugin update
1035
+ # must re-point it in every session, not only in arbiter-enabled repos.
1036
+ # Gated on the host capability (ADR-0011): a host with no statusline surface
1037
+ # (Codex) has nothing to heal.
1038
+ if host.has_statusline:
1039
+ heal_statusline_wiring(plugin)
1040
+
1041
+ enabled, malformed = frontmatter_enabled(ctx)
1042
+ if not enabled:
1043
+ if malformed:
1044
+ print("codeArbiter: .codearbiter/CONTEXT.md is present but its frontmatter is "
1045
+ "malformed (opening '---' with no closing '---'). The plugin is DORMANT — "
1046
+ "fix the frontmatter to activate.", file=sys.stderr)
1047
+ sys.exit(0)
1048
+
1049
+ # #161: arbiter is active — ensure the git-level enforcement backstop
1050
+ # (pre-commit/pre-push) is installed and points at the CURRENT plugin path.
1051
+ # Idempotent and best-effort: a foreign existing hook is preserved, and any
1052
+ # failure here must never break session startup.
1053
+ #
1054
+ # #441: the enforcer entry install() refreshes lives in the git COMMON dir,
1055
+ # shared with every linked worktree — so a session started inside a worktree
1056
+ # writes the MAIN repository's entry. _githooks._write_path_entry refuses an
1057
+ # ephemeral enforcer on its own account (it is the producer), and this check
1058
+ # is deliberately redundant for the same reason heal_statusline_wiring's is:
1059
+ # _githooks is imported OUT OF this plugin root, so a worktree cut from a
1060
+ # pre-fix branch supplies a pre-fix, unguarded producer. Losing git-level
1061
+ # enforcement is silent, so the caller refuses on its own account too. Both
1062
+ # sites share the one predicate, so there is no second policy to drift.
1063
+ try:
1064
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
1065
+ from _githooks import install as _install_git_hooks
1066
+ if is_ephemeral_path(os.path.join(plugin, "hooks", "git-enforce.py")):
1067
+ print("codeArbiter: this session's plugin root will not outlive the session "
1068
+ "(linked worktree); leaving the repository's git-level enforcement "
1069
+ "wiring as it is.", file=sys.stderr)
1070
+ else:
1071
+ _install_git_hooks(root)
1072
+ except Exception: # noqa: BLE001
1073
+ # Legacy hosts retain the historical best-effort startup contract.
1074
+ # Pi supplies an authenticated absolute executable pair; losing that
1075
+ # boundary must surface to the bridge so activation remains fail closed.
1076
+ if (os.environ.get("CODEARBITER_GIT_EXECUTABLE")
1077
+ or os.environ.get("CODEARBITER_PYTHON_EXECUTABLE")):
1078
+ raise
1079
+
1080
+ # --- Arbiter active: inject persona ---
1081
+ orch = os.path.join(plugin, "ORCHESTRATOR.md")
1082
+ orch_text = read_text(orch)
1083
+ if orch_text is not None:
1084
+ sys.stdout.write(orch_text)
1085
+ print()
1086
+ else:
1087
+ print(f"codeArbiter: ORCHESTRATOR.md not found at {orch} — persona not injected. "
1088
+ f"Check CLAUDE_PLUGIN_ROOT.", file=sys.stderr)
1089
+
1090
+ # --- Inject live startup state ---
1091
+ print("=== codeArbiter startup state ===")
1092
+ # observability-004 (#268): name the RESOLVED host so a dormant/broken
1093
+ # host (FailClosedHost -> name "unknown", #255) is visible right in the
1094
+ # banner instead of being indistinguishable from a working install.
1095
+ print(f"host: {getattr(host, 'name', 'unknown')}")
1096
+
1097
+ ctx_text = read_text(ctx) or ""
1098
+ if not INITIALIZED_RE.search(ctx_text):
1099
+ if has_source(root):
1100
+ print(f"NOT INITIALIZED: source exists but .codearbiter/CONTEXT.md is a stub. "
1101
+ f"Run {host.cmd_ref('create-context')} before any other command.")
1102
+ else:
1103
+ print(f"NOT INITIALIZED: empty project. Run {host.cmd_ref('decompose')} to begin.")
1104
+ print(f"Type {host.cmd_ref('commands')} for the catalog.")
1105
+ sys.exit(0)
1106
+
1107
+ m = STAGE_RE.search(ctx_text)
1108
+ print(f"stage: {m.group(1) if m else '—'}")
1109
+
1110
+ oq = os.path.join(root, ".codearbiter", "open-questions.md")
1111
+ oq_text = read_text(oq)
1112
+ if oq_text is not None:
1113
+ confirms = CONFIRM_RE.findall(oq_text)
1114
+ if confirms:
1115
+ print(f"BLOCKING questions (CONFIRM-NN): {len(confirms)} — must resolve before "
1116
+ f"dependent work proceeds:")
1117
+ for ln in oq_text.splitlines():
1118
+ if CONFIRM_RE.search(ln):
1119
+ print(f" {ln}")
1120
+ else:
1121
+ print("open questions: 0")
1122
+
1123
+ ot = os.path.join(root, ".codearbiter", "open-tasks.md")
1124
+ ot_text = read_text(ot)
1125
+ if ot_text is not None:
1126
+ # Shared helper: in-flight count (excludes done) + a stale-in-progress
1127
+ # nudge + undated/malformed warnings. Oversize boards degrade to a
1128
+ # one-line notice. Guarded: the task board must never take down the
1129
+ # linchpin hook — on any unexpected parse error, fail LOUD (stderr
1130
+ # breadcrumb) and fall back to the raw count, never go dormant.
1131
+ try:
1132
+ for _line in _taskboardlib.startup_summary(ot_text, datetime.date.today()):
1133
+ print(_line)
1134
+ except Exception as _e: # noqa: BLE001 — never crash session startup
1135
+ n = sum(1 for ln in ot_text.splitlines()
1136
+ if ln.startswith("- ") and not ln.startswith("- [x]"))
1137
+ print(f"in-flight tasks: {n}")
1138
+ print(f"codeArbiter: task-board summary degraded ({_e}); "
1139
+ f"check .codearbiter/open-tasks.md", file=sys.stderr)
1140
+
1141
+ # --- Passive provenance drift notice (T-16, spec pillar 4) ---
1142
+ # ONE line emitted only when drift > 0; silent when docs are fresh or on any
1143
+ # degrade (wrapper swallows all exceptions — never crashes the linchpin hook).
1144
+ _drift = provenance_drift_line(root)
1145
+ if _drift:
1146
+ print(_drift)
1147
+
1148
+ # --- Update-available notice (AC-1/AC-2/AC-3) --------------------------
1149
+ # ONE line, read from the cache only (no network here); silent when the
1150
+ # installed version is current or the cache is absent/stale/corrupt.
1151
+ _update = update_notice_line(plugin)
1152
+ if _update:
1153
+ print(_update)
1154
+
1155
+ print(f"Present this state, then await a {host.command_noun}. "
1156
+ f"Type {host.cmd_ref('commands')} for the catalog.")
1157
+
1158
+ # --- Standup briefing (SH-1 full / SH-2 offer) ---
1159
+ # Additive, AFTER the startup-state block. Read-only: no git mutation here.
1160
+ # first session of the day (no marker) -> full briefing + drop marker
1161
+ # later session today, actionable -> exactly ONE offer line
1162
+ # later session today, nothing to do -> emit nothing
1163
+ # The git-derived `summary` (dirty/behind/ahead/prune candidates/worktrees/
1164
+ # stashes) is assembled below from read-only git reads; any_actionable(summary)
1165
+ # then decides whether a later same-day session emits its single offer line. A
1166
+ # clean repo yields an all-quiet summary, so later sessions stay silent — the
1167
+ # conservative default.
1168
+ date_iso = local_date_iso()
1169
+ marker_present = not should_emit_briefing(root, date_iso)
1170
+
1171
+ # Read-only git assembly. ahead/behind comes from the LAST COMPLETED fetch
1172
+ # (current local refs); we annotate it as possibly stale and kick a DETACHED
1173
+ # fetch to refresh for NEXT time without blocking this hook's return.
1174
+ current = head_branch(root)
1175
+ default = os.environ.get("CODEARBITER_BASE_BRANCH") or "main"
1176
+ summary = assemble_summary(root, current=current, default=default)
1177
+ spawn_background_fetch(root) # detached; never awaited
1178
+ spawn_background_update_refresh(plugin) # detached; never awaited (AC-3/AC-4)
1179
+
1180
+ mode = briefing_mode(marker_present, any_actionable(summary))
1181
+ if mode == "full":
1182
+ print()
1183
+ print(f"=== codeArbiter daily briefing ({date_iso}) ===")
1184
+ print("First session of the day. Daily standup briefing (read-only).")
1185
+ # performance-003 (#194): ctx_text/ot_text/oq_text were already read
1186
+ # above for the startup-state block — thread them through so
1187
+ # governance_line's arbiter_state() call doesn't re-read the same three
1188
+ # files a second time in this same invocation.
1189
+ render_full_briefing(root, summary, ctx_text=ctx_text, ot_text=ot_text, oq_text=oq_text)
1190
+ try:
1191
+ write_standup_marker(root, date_iso)
1192
+ except Exception: # noqa: BLE001 — must never brick session startup
1193
+ pass
1194
+ elif mode == "offer":
1195
+ print(OFFER_LINE_TEMPLATE.format(standup=get_host().cmd_ref("standup")))
1196
+
1197
+ sys.exit(0)
1198
+
1199
+
1200
+ def run(host, argv=None):
1201
+ """Host-seam entry point (ADR-0011): the __main__ guard calls this with the
1202
+ plugin's loaded Host. Wraps main() unchanged — main() still communicates
1203
+ via sys.exit/stdout/stderr, and its return value stays discarded exactly
1204
+ as the old bare `main()` guard discarded it (so the process still exits 0
1205
+ on a normal fall-through).
1206
+
1207
+ Wires `host` live (#257): primes `_hooklib`'s process-cached Host via
1208
+ `set_host()` BEFORE main() runs, so main()'s `get_host()` call resolves
1209
+ to the SAME instance the caller passed here — no second
1210
+ `hostapi.load_host()`, and `run(fake_host)` genuinely exercises
1211
+ `fake_host`."""
1212
+ set_host(host)
1213
+ main()
1214
+ return 0
1215
+
1216
+
1217
+ if __name__ == "__main__":
1218
+ sys.exit(run(hostapi.load_host()) or 0)