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