@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,595 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter — shared hook helpers (issues #24, #25).
|
|
3
|
+
#
|
|
4
|
+
# Replaces the bash+jq hook layer with a cross-platform, fail-LOUD Python
|
|
5
|
+
# implementation. The bash hooks began with `which jq > /dev/null || exit 0`,
|
|
6
|
+
# so on any machine without jq (e.g. a stock Windows install) EVERY safety gate
|
|
7
|
+
# silently evaporated with no signal — the plugin appeared installed but was
|
|
8
|
+
# inert. Python is already a plugin dependency (statusline, scaffolder), needs
|
|
9
|
+
# no jq, and lets a hook fail loud instead of fail open.
|
|
10
|
+
#
|
|
11
|
+
# Blocking contract: a PreToolUse hook BLOCKS the tool call by exiting 2 (its
|
|
12
|
+
# stderr is fed back to Claude). Exit 0 allows. The prior bash hooks used
|
|
13
|
+
# exit 1 — a NON-blocking error under Claude Code's hook contract — so their
|
|
14
|
+
# "BLOCKED" gates may not have been stopping the tool at all; this port closes
|
|
15
|
+
# that latent gap.
|
|
16
|
+
#
|
|
17
|
+
# Interpreter launch: hooks.json registers every hook TWICE — `python <script>`
|
|
18
|
+
# plus a fallback `python -c "" || python3 <script>`. Preferring `python` avoids
|
|
19
|
+
# activating Windows' python3 MSIX app-execution alias for every hook; POSIX
|
|
20
|
+
# installs that expose only python3 still use the fallback. A single
|
|
21
|
+
# `python x || python3 x` entry would be wrong: when python exists and the
|
|
22
|
+
# script BLOCKS (exit 2), `||` would re-run it against a
|
|
23
|
+
# drained stdin and the rerun's exit 0 would swallow the block. Separate hook
|
|
24
|
+
# entries each receive their own stdin, so the block survives.
|
|
25
|
+
#
|
|
26
|
+
# Public API:
|
|
27
|
+
# get_host() -> Host the process's Host instance (hostapi.load_host(), cached)
|
|
28
|
+
# set_host(host) -> None DI seam (#257): prime the process-cached Host that
|
|
29
|
+
# get_host() returns. Every entry script's run(host) calls
|
|
30
|
+
# this BEFORE main(), so the Host the __main__ guard already
|
|
31
|
+
# loaded is the one get_host() serves — main() no longer
|
|
32
|
+
# triggers a second hostapi.load_host(), and a test calling
|
|
33
|
+
# run(fake_host) genuinely runs against fake_host.
|
|
34
|
+
# utf8_stdio() -> None force UTF-8 on stdout/stderr
|
|
35
|
+
# norm_path(p) -> str normalize path separators to forward-slash
|
|
36
|
+
# frontmatter_enabled(ctx_path) -> tuple[bool, bool] (enabled, malformed)
|
|
37
|
+
# frontmatter_enabled_text(text) -> tuple[bool, bool] same, over content not a path
|
|
38
|
+
# arbiter_active(root) -> bool True iff repo opted in via CONTEXT.md frontmatter
|
|
39
|
+
# read_input() -> dict parse hook JSON from stdin; fail-open on error
|
|
40
|
+
# tool_input(data) -> dict extract tool_input sub-dict from hook payload
|
|
41
|
+
# project_root(payload=None) -> str CLAUDE_PROJECT_DIR, else git repo root, else cwd
|
|
42
|
+
# (memoized per process, keyed on the
|
|
43
|
+
# inputs that could change it — #260)
|
|
44
|
+
# marker_root(payload=None) -> str project_root(payload), escalated to the MAIN
|
|
45
|
+
# checkout when that names a LINKED worktree's
|
|
46
|
+
# own checkout — the root gate MARKERS
|
|
47
|
+
# (.codearbiter/.markers/) live under (#604)
|
|
48
|
+
# repo_rel(fpath, root) -> str repo-relative POSIX path, or "" if outside root
|
|
49
|
+
# line_digest(line) -> str sha256 hex of one diff line (H-09b/H-10b gate)
|
|
50
|
+
# content_digest(text) -> str sha256 hex of a whole file's content (H-14 gate)
|
|
51
|
+
# migration_globs(root) -> tuple[list, list] (includes, excludes) for migration detection
|
|
52
|
+
# scope_globs(root, defaults, decl_re) -> tuple[list, list] generic glob scope resolver
|
|
53
|
+
# path_in_globs(rel, root, defaults, decl_re) -> bool True iff path matches scope
|
|
54
|
+
# is_migration_path(rel, root) -> bool True iff rel is a DB migration (H-14)
|
|
55
|
+
# is_ci_path(rel, root) -> bool True iff rel is a CI/CD workflow (H-15)
|
|
56
|
+
# is_deploy_path(rel, root) -> bool True iff rel is a deployment/IaC manifest (H-16)
|
|
57
|
+
# is_audit_log(rel) -> bool True iff rel is an append-only audit log (H-05)
|
|
58
|
+
# is_tail_append(current, old, new) -> bool True iff old_string is current's exact
|
|
59
|
+
# trailing content AND new_string extends it (H-05)
|
|
60
|
+
# is_decisions_path(rel) -> bool True iff rel is an ADR under decisions/ (H-11)
|
|
61
|
+
# is_context_md(rel) -> bool True iff rel is the CONTEXT.md activation file (#159)
|
|
62
|
+
# is_marker_path(rel) -> bool True iff rel is under .codearbiter/.markers/ (#160)
|
|
63
|
+
# classify_protected(fpath, root) -> set protected classes hit, raw+realpath (#162)
|
|
64
|
+
# is_sensitive_scan_exempt(rel) -> bool True iff rel is exempt from the H-09b/H-10b
|
|
65
|
+
# crypto/secret scan (gate-events.log only, #279)
|
|
66
|
+
# SECURITY_DIFF_GIT_ARGS pinned `git diff` argv suffix (fixed a/ b/
|
|
67
|
+
# prefixes, no external diff) every H-09b/H-10b
|
|
68
|
+
# sensitive-line reader MUST use (#279 review)
|
|
69
|
+
# diff_added_lines(diff_text) -> list[tuple[str|None, str]] path-aware walk of a unified
|
|
70
|
+
# diff's added ('+') lines (from SECURITY_DIFF_GIT_ARGS
|
|
71
|
+
# output), paired with the destination path each
|
|
72
|
+
# belongs to, attributed from `+++ b/<path>` via a
|
|
73
|
+
# fixed-prefix strip within an unspoofable `diff `
|
|
74
|
+
# section (#279 review)
|
|
75
|
+
# sensitive_scan_added_lines(diff_text) -> list[str] diff_added_lines() narrowed to the
|
|
76
|
+
# H-09b/H-10b candidate set (exempt paths dropped)
|
|
77
|
+
# MARKER_FRESHNESS_MINUTES -> int the single H-11 authoring-marker
|
|
78
|
+
# freshness window (30 minutes). Issue
|
|
79
|
+
# #567: this used to be five
|
|
80
|
+
# independent hardcoded `30` literals
|
|
81
|
+
# (pre-write.py, pre-edit.py,
|
|
82
|
+
# _bashguardlib.py, git-enforce.py,
|
|
83
|
+
# _protectedstatelib.py) with no
|
|
84
|
+
# import relationship, so widening or
|
|
85
|
+
# narrowing one silently desynced the
|
|
86
|
+
# window across enforcement flanks.
|
|
87
|
+
# Declared HERE, beside marker_fresh
|
|
88
|
+
# itself (the one freshness
|
|
89
|
+
# implementation every caller already
|
|
90
|
+
# delegates to) — every flank now
|
|
91
|
+
# imports this name rather than
|
|
92
|
+
# restating the literal.
|
|
93
|
+
# marker_fresh(path, minutes) -> bool True iff marker file exists and is recent
|
|
94
|
+
# write_text_atomic(path, text) -> None crash-safe write (temp + os.replace)
|
|
95
|
+
# acquire_lock(path) -> handle|None OS-owned cross-process file lock (#271 C-2);
|
|
96
|
+
# non-blocking + bounded LOCK_WAIT retry spin,
|
|
97
|
+
# fail-soft None on contention/timeout/OSError
|
|
98
|
+
# release_lock(handle) -> None release + close; None handle is a no-op
|
|
99
|
+
# block(tag, msg) -> None BLOCK tool call: print to stderr and exit 2
|
|
100
|
+
# remind(tag, msg) -> None non-blocking nudge to stderr
|
|
101
|
+
# warn(msg) -> None loud degradation breadcrumb to stderr
|
|
102
|
+
# staleness_warning(root, now=None, window_minutes=30) -> list[str]
|
|
103
|
+
# (CONFIRM-09) active-flow audit-log staleness
|
|
104
|
+
# messages, WARN-only, never raises
|
|
105
|
+
|
|
106
|
+
import datetime
|
|
107
|
+
import errno
|
|
108
|
+
import json
|
|
109
|
+
import os
|
|
110
|
+
import re
|
|
111
|
+
import subprocess # noqa: F401 — no longer used directly; kept so any external
|
|
112
|
+
# consumer referencing _hooklib.subprocess keeps resolving
|
|
113
|
+
import sys
|
|
114
|
+
import tempfile
|
|
115
|
+
import threading
|
|
116
|
+
import time
|
|
117
|
+
|
|
118
|
+
import hostapi
|
|
119
|
+
|
|
120
|
+
# Issue #321 - the H-09b/H-10b sensitive-line scan moved to _sensitivelib, the
|
|
121
|
+
# protected-path classifiers to _protectedlib, the H-14/H-15/H-16 scope
|
|
122
|
+
# detection to _scopelib, activation and the host/root caches to
|
|
123
|
+
# _activationlib, and the path primitives to the
|
|
124
|
+
# _pathnorm floor beneath both. Re-exported here, unchanged, so
|
|
125
|
+
# every one of the 59 consuming files keeps importing from _hooklib and the
|
|
126
|
+
# pre-existing suites prove parity without moving. New code SHOULD import from
|
|
127
|
+
# the owning module; this facade exists so the partition costs no caller a diff.
|
|
128
|
+
from _pathnorm import norm_path, repo_rel # noqa: F401
|
|
129
|
+
from _activationlib import ( # noqa: F401
|
|
130
|
+
# `_HOST` is deliberately ABSENT: importing a mutable global binds its VALUE,
|
|
131
|
+
# so a later set_host() would rebind it in _activationlib and leave a stale
|
|
132
|
+
# copy here forever. The accessors below all read and write that module's
|
|
133
|
+
# global, so one cache is shared however a caller reached them. Verified that
|
|
134
|
+
# nothing in the repo reads _hooklib._HOST directly.
|
|
135
|
+
# `_reset_root_cache` is a FUNCTION and has a consumer (test_hooklib calls it
|
|
136
|
+
# through `_hooklib.`), so it is re-exported: a function binding stays live.
|
|
137
|
+
# `_ROOT_CACHE` and `_root_cache_key` have none and are dropped rather than
|
|
138
|
+
# relocated.
|
|
139
|
+
_reset_root_cache,
|
|
140
|
+
ARBITER_RE,
|
|
141
|
+
arbiter_active,
|
|
142
|
+
frontmatter_enabled,
|
|
143
|
+
frontmatter_enabled_text,
|
|
144
|
+
get_host,
|
|
145
|
+
marker_root,
|
|
146
|
+
project_root,
|
|
147
|
+
reset_host,
|
|
148
|
+
set_host,
|
|
149
|
+
)
|
|
150
|
+
from _scopelib import ( # noqa: F401
|
|
151
|
+
# The private names are re-exported too, deliberately. `_read_controls`,
|
|
152
|
+
# `_glob_to_re` and the precompiled default tuples have real consumers
|
|
153
|
+
# (test_hooklib reaches them through `_hooklib.`), and `_CONTROLS_CACHE` is
|
|
154
|
+
# mutated in place rather than rebound, so sharing the binding shares the
|
|
155
|
+
# cache correctly. Slice 1 taught this: an underscore prefix means "not
|
|
156
|
+
# public", never "unused".
|
|
157
|
+
_CI_DECL_RE,
|
|
158
|
+
_CI_DEFAULT_RES,
|
|
159
|
+
_CONTROLS_CACHE,
|
|
160
|
+
_DEFAULT_RES_BY_GLOBS,
|
|
161
|
+
_DEPLOY_DECL_RE,
|
|
162
|
+
_DEPLOY_DEFAULT_RES,
|
|
163
|
+
_MIG_DECL_RE,
|
|
164
|
+
_MIGRATION_DEFAULT_RES,
|
|
165
|
+
_controls_mtime,
|
|
166
|
+
_custom_re_cache,
|
|
167
|
+
_glob_to_re,
|
|
168
|
+
_read_controls,
|
|
169
|
+
_scope_res,
|
|
170
|
+
CI_DEFAULT_GLOBS,
|
|
171
|
+
DEPLOY_DEFAULT_GLOBS,
|
|
172
|
+
MIGRATION_DEFAULT_GLOBS,
|
|
173
|
+
is_ci_path,
|
|
174
|
+
is_deploy_path,
|
|
175
|
+
is_migration_path,
|
|
176
|
+
migration_globs,
|
|
177
|
+
path_in_globs,
|
|
178
|
+
scope_globs,
|
|
179
|
+
)
|
|
180
|
+
from _protectedlib import ( # noqa: F401
|
|
181
|
+
AUDIT_LOG_BASENAMES,
|
|
182
|
+
AUDIT_LOG_FLAT_BASENAMES,
|
|
183
|
+
AUDIT_LOG_NAMES,
|
|
184
|
+
AUDIT_LOG_RE,
|
|
185
|
+
CONTEXT_MD_RE,
|
|
186
|
+
DECISIONS_DIR_RE,
|
|
187
|
+
DECISIONS_PATH_RE,
|
|
188
|
+
DECISION_LOG_BASENAME,
|
|
189
|
+
DECISION_LOG_RE,
|
|
190
|
+
GATE_MARKER_NAMES,
|
|
191
|
+
MARKERS_RE,
|
|
192
|
+
classify_protected,
|
|
193
|
+
is_audit_log,
|
|
194
|
+
is_context_md,
|
|
195
|
+
is_decisions_path,
|
|
196
|
+
is_marker_path,
|
|
197
|
+
is_tail_append,
|
|
198
|
+
)
|
|
199
|
+
from _sensitivelib import ( # noqa: F401
|
|
200
|
+
CRYPTO_RE,
|
|
201
|
+
SECRET_RE,
|
|
202
|
+
SECURITY_DIFF_GIT_ARGS,
|
|
203
|
+
SENSITIVE_SCAN_EXEMPT_RELPATHS,
|
|
204
|
+
content_digest,
|
|
205
|
+
diff_added_lines,
|
|
206
|
+
is_sensitive_scan_exempt,
|
|
207
|
+
line_digest,
|
|
208
|
+
sensitive_scan_added_lines,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# Serialize same-process Windows writers before taking the cross-process lock.
|
|
213
|
+
_GATE_EVENTS_WINDOWS_LOCK = threading.Lock()
|
|
214
|
+
_WINDOWS_LOCK_TIMEOUT_SECONDS = 5.0
|
|
215
|
+
_WINDOWS_LOCK_RETRY_SECONDS = 0.01
|
|
216
|
+
_WINDOWS_CRT_EDEADLK = 36
|
|
217
|
+
|
|
218
|
+
# Bounded best-effort wait for another cross-process writer to release
|
|
219
|
+
# acquire_lock()'s sidecar lock file (#271 C-2). Originally _ledgerlib-private
|
|
220
|
+
# (the statusline's cost/token ledger); hoisted here so taskwrite.py's board
|
|
221
|
+
# writer (a second, genuinely different caller) can share ONE lock
|
|
222
|
+
# implementation instead of a second hand-rolled copy. _ledgerlib re-exports
|
|
223
|
+
# this name (`from _hooklib import LOCK_WAIT`) so its own module-level
|
|
224
|
+
# mock.patch.object(L, "LOCK_WAIT", ...) test seam keeps working unchanged.
|
|
225
|
+
LOCK_WAIT = 0.2
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _is_lock_contention(exc):
|
|
229
|
+
"""True only for CRT/Windows byte-range lock conflict errors."""
|
|
230
|
+
return (getattr(exc, "errno", None) in (errno.EACCES, _WINDOWS_CRT_EDEADLK) or
|
|
231
|
+
getattr(exc, "winerror", None) in (32, 33))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def utf8_stdio():
|
|
249
|
+
"""Force UTF-8 on stdout/stderr. Windows pipes default to the locale code
|
|
250
|
+
page (cp1252), and ORCHESTRATOR.md contains non-cp1252 glyphs — without this
|
|
251
|
+
the SessionStart injection dies with UnicodeEncodeError."""
|
|
252
|
+
for s in (sys.stdout, sys.stderr):
|
|
253
|
+
try:
|
|
254
|
+
s.reconfigure(encoding="utf-8", errors="replace")
|
|
255
|
+
except Exception: # noqa: BLE001
|
|
256
|
+
pass
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def read_input():
|
|
264
|
+
"""Parse the hook JSON from stdin.
|
|
265
|
+
|
|
266
|
+
Deliberately fail-open on unreadable input: a malformed stdin payload must
|
|
267
|
+
NOT brick the session by blocking every subsequent tool call. This is an
|
|
268
|
+
explicit, documented exception to the fail-loud principle — the correct
|
|
269
|
+
behaviour here is warn + allow, not warn + block.
|
|
270
|
+
|
|
271
|
+
"Unreadable" covers a malformed SHAPE as well as malformed syntax
|
|
272
|
+
(ADR-0020). A payload that is valid JSON but not an object — `[]`, `3`,
|
|
273
|
+
`"str"`, `true`, `null` — parses cleanly and never reaches the except
|
|
274
|
+
branch, so it used to be handed downstream as a non-dict: `tool_input()`
|
|
275
|
+
evaluates `(data or {}).get(...)`, which makes the falsy ones accidentally
|
|
276
|
+
safe and raises AttributeError out of the guard on the truthy ones. Both
|
|
277
|
+
are normalized to `{}` here instead, at the one chokepoint that decides it.
|
|
278
|
+
|
|
279
|
+
The distinction between the two failures carries no security content: the
|
|
280
|
+
hook envelope is host-produced (Claude Code, Codex, Pi), not
|
|
281
|
+
model-produced. A model can place hostile content INSIDE `tool_input`, but
|
|
282
|
+
cannot make the top-level object a list — so a non-dict envelope means host
|
|
283
|
+
misbehaviour or version drift, the same compatibility event the parse-error
|
|
284
|
+
branch already rules on.
|
|
285
|
+
"""
|
|
286
|
+
try:
|
|
287
|
+
raw = sys.stdin.read()
|
|
288
|
+
data = json.loads(raw) if raw.strip() else {}
|
|
289
|
+
except Exception as e: # noqa: BLE001 — any malformed input
|
|
290
|
+
warn(f"hook input unparseable ({e}); proceeding without enforcement")
|
|
291
|
+
return {}
|
|
292
|
+
if not isinstance(data, dict):
|
|
293
|
+
warn(f"hook input is a JSON {type(data).__name__}, not an object; "
|
|
294
|
+
"proceeding without enforcement")
|
|
295
|
+
return {}
|
|
296
|
+
return data
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def tool_input(data):
|
|
300
|
+
return (data or {}).get("tool_input", {}) or {}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def write_text_atomic(path, text, newline=None):
|
|
312
|
+
"""Write `text` to `path` atomically: a sibling temp file in the same dir,
|
|
313
|
+
then os.replace() into place (atomic on POSIX; a same-volume rename on
|
|
314
|
+
Windows). A crash between open() and the rename never leaves a half-written
|
|
315
|
+
file at `path`. The gate-marker writers (migration-pass / security-pass) rely
|
|
316
|
+
on this so a partial digest set can't be read back as an unrecognized token
|
|
317
|
+
and force a spurious gate re-run (migration-002). On any failure the temp
|
|
318
|
+
file is cleaned up and the original `path` is left untouched.
|
|
319
|
+
|
|
320
|
+
`newline` is passed through to open()/fdopen() unchanged (default None keeps
|
|
321
|
+
the prior text-mode translation behaviour for existing callers); pass "\\n"
|
|
322
|
+
to force LF output regardless of platform (e.g. for a canonical-EOL file)."""
|
|
323
|
+
d = os.path.dirname(path) or "."
|
|
324
|
+
fd, tmp = tempfile.mkstemp(dir=d, prefix=os.path.basename(path) + ".", suffix=".tmp")
|
|
325
|
+
try:
|
|
326
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline=newline) as f:
|
|
327
|
+
f.write(text)
|
|
328
|
+
os.replace(tmp, path)
|
|
329
|
+
except Exception:
|
|
330
|
+
try:
|
|
331
|
+
os.remove(tmp)
|
|
332
|
+
except OSError:
|
|
333
|
+
pass
|
|
334
|
+
raise
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def acquire_lock(path):
|
|
338
|
+
"""Acquire an OS-owned cross-process file lock keyed on `path`; process
|
|
339
|
+
death releases it automatically (#271 C-2 — hoisted from the
|
|
340
|
+
statusline-ledger-only `_ledgerlib._acquire_lock`, now shared with
|
|
341
|
+
taskwrite.py's board writer).
|
|
342
|
+
|
|
343
|
+
Sidecar lock file `f"{abspath(path)}.lock"`, opened `"a+b"` and seeded
|
|
344
|
+
with one byte so the OS byte-range lock has a byte to lock (an empty file
|
|
345
|
+
has no range to range-lock). Non-blocking (`msvcrt.locking(..., LK_NBLCK,
|
|
346
|
+
1)` on Windows, `fcntl.flock(..., LOCK_EX | LOCK_NB)` elsewhere) with a
|
|
347
|
+
bounded `LOCK_WAIT`-second retry spin; any `OSError` opening the lock file,
|
|
348
|
+
or exhausting the deadline still contended, is FAIL-SOFT: returns `None`
|
|
349
|
+
rather than raising or blocking indefinitely. Callers decide what
|
|
350
|
+
"fail-soft" means for them — `_ledgerlib.ledger_update`/`persist_sess_start`
|
|
351
|
+
treat `None` as a disposable no-op (a statusline render is throwaway), but
|
|
352
|
+
`taskwrite.py` treats it as a hard error (a board write is NOT disposable;
|
|
353
|
+
see its module docstring)."""
|
|
354
|
+
lock_path = f"{os.path.abspath(path)}.lock"
|
|
355
|
+
parent = os.path.dirname(lock_path)
|
|
356
|
+
try:
|
|
357
|
+
os.makedirs(parent, exist_ok=True)
|
|
358
|
+
handle = open(lock_path, "a+b")
|
|
359
|
+
handle.seek(0, os.SEEK_END)
|
|
360
|
+
if handle.tell() == 0:
|
|
361
|
+
handle.write(b"\0")
|
|
362
|
+
handle.flush()
|
|
363
|
+
except OSError:
|
|
364
|
+
return None
|
|
365
|
+
deadline = time.monotonic() + LOCK_WAIT
|
|
366
|
+
while True:
|
|
367
|
+
try:
|
|
368
|
+
handle.seek(0)
|
|
369
|
+
if os.name == "nt":
|
|
370
|
+
import msvcrt
|
|
371
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
372
|
+
else:
|
|
373
|
+
import fcntl
|
|
374
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
375
|
+
return handle
|
|
376
|
+
except (OSError, BlockingIOError):
|
|
377
|
+
if time.monotonic() >= deadline:
|
|
378
|
+
handle.close()
|
|
379
|
+
return None
|
|
380
|
+
time.sleep(0.005)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def release_lock(handle):
|
|
384
|
+
"""Release + close a handle from acquire_lock(). None is a no-op —
|
|
385
|
+
callers that never got the lock don't need to guard the release call."""
|
|
386
|
+
if handle is None:
|
|
387
|
+
return
|
|
388
|
+
try:
|
|
389
|
+
handle.seek(0)
|
|
390
|
+
if os.name == "nt":
|
|
391
|
+
import msvcrt
|
|
392
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
393
|
+
else:
|
|
394
|
+
import fcntl
|
|
395
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
396
|
+
except OSError:
|
|
397
|
+
pass
|
|
398
|
+
finally:
|
|
399
|
+
try:
|
|
400
|
+
handle.close()
|
|
401
|
+
except OSError:
|
|
402
|
+
pass
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
# The H-11 authoring-marker freshness window (issue #567) — the single
|
|
406
|
+
# declaration every `marker_fresh(marker, minutes)` call site resolves by
|
|
407
|
+
# import. Previously five independent `30` literals (pre-write.py,
|
|
408
|
+
# pre-edit.py, _bashguardlib.py, git-enforce.py, _protectedstatelib.py) with
|
|
409
|
+
# no shared source, so a change to one never propagated to the others and
|
|
410
|
+
# nothing went red. All five call sites use this exact marker-freshness
|
|
411
|
+
# shape (a marker file minted immediately before a gated action and checked
|
|
412
|
+
# fresh at the point of use) with no documented reason to diverge, so this is
|
|
413
|
+
# ONE window, not several disguised as one.
|
|
414
|
+
MARKER_FRESHNESS_MINUTES = 30
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def marker_fresh(path, minutes):
|
|
418
|
+
"""True if the marker file exists and was touched within `minutes`."""
|
|
419
|
+
try:
|
|
420
|
+
return os.path.isfile(path) and (time.time() - os.path.getmtime(path)) < minutes * 60
|
|
421
|
+
except Exception: # noqa: BLE001
|
|
422
|
+
return False
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _log_gate_event(kind, tag, msg):
|
|
426
|
+
"""Best-effort durable append of one gate decision to
|
|
427
|
+
.codearbiter/gate-events.log (observability-001, issue #186) — the durable
|
|
428
|
+
sink block()/remind()/warn() funnel every BLOCK/REMIND/WARN through, so a
|
|
429
|
+
decision is no longer visible ONLY in the ephemeral per-turn stderr
|
|
430
|
+
transcript.
|
|
431
|
+
|
|
432
|
+
One line per event: `[ISO-8601Z] KIND [tag] host=<host> hook=<script> | msg`.
|
|
433
|
+
`tag` may be None (warn() carries no tag) — the bracket is simply omitted
|
|
434
|
+
then. `hook` is the invoking script's basename (`sys.argv[0]`), the one
|
|
435
|
+
"which hook fired this" signal available at this shared layer without
|
|
436
|
+
threading a new parameter through all 21 call sites across the 16 entry
|
|
437
|
+
hooks. `host` is `get_host().name` ("claude"/"codex"/"unknown") — added
|
|
438
|
+
for observability-001/ADR-0012: with three hosts now sharing one
|
|
439
|
+
gate-events.log (ADR-0011), a line could not be attributed to the host
|
|
440
|
+
that wrote it. Placed BEFORE `hook=` (both are `key=value` tokens with no
|
|
441
|
+
internal whitespace, so the line stays trivially greppable/parseable by
|
|
442
|
+
either field, and existing `hook=<script>` substring matches are
|
|
443
|
+
unaffected).
|
|
444
|
+
|
|
445
|
+
FAIL-OPEN BY CONTRACT (AC-2): this function must NEVER raise and must
|
|
446
|
+
NEVER be allowed to change the caller's exit code or suppress its stderr
|
|
447
|
+
output. A missing `.codearbiter/` dir, an unwritable/locked/missing log
|
|
448
|
+
file, project_root() itself misbehaving, or host resolution itself
|
|
449
|
+
misbehaving are ALL swallowed silently here — the ONE deliberate
|
|
450
|
+
exception to this module's fail-loud discipline, mirroring the documented
|
|
451
|
+
fail-open exception in read_input()."""
|
|
452
|
+
try:
|
|
453
|
+
root = project_root()
|
|
454
|
+
cad = os.path.join(root, ".codearbiter")
|
|
455
|
+
if not os.path.isdir(cad):
|
|
456
|
+
return # repo never opted in (no .codearbiter/) — nothing to append to
|
|
457
|
+
ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
458
|
+
hook = os.path.basename(sys.argv[0]) if sys.argv and sys.argv[0] else "-"
|
|
459
|
+
try:
|
|
460
|
+
host = get_host().name
|
|
461
|
+
except Exception: # noqa: BLE001 — host resolution must never break the sink
|
|
462
|
+
host = "unknown"
|
|
463
|
+
tag_part = f"[{tag}] " if tag else ""
|
|
464
|
+
line = f"[{ts}] {kind} {tag_part}host={host} hook={hook} | {msg}\n"
|
|
465
|
+
flags = os.O_APPEND | os.O_CREAT | os.O_WRONLY
|
|
466
|
+
if hasattr(os, "O_BINARY"):
|
|
467
|
+
flags |= os.O_BINARY
|
|
468
|
+
process_lock_acquired = False
|
|
469
|
+
if os.name == "nt":
|
|
470
|
+
_GATE_EVENTS_WINDOWS_LOCK.acquire()
|
|
471
|
+
process_lock_acquired = True
|
|
472
|
+
fd = None
|
|
473
|
+
os_lock_acquired = False
|
|
474
|
+
try:
|
|
475
|
+
fd = os.open(os.path.join(cad, "gate-events.log"), flags, 0o600)
|
|
476
|
+
if os.name == "nt":
|
|
477
|
+
import msvcrt
|
|
478
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
479
|
+
lock_mode = getattr(msvcrt, "LK_NBLCK", msvcrt.LK_LOCK)
|
|
480
|
+
lock_deadline = time.monotonic() + _WINDOWS_LOCK_TIMEOUT_SECONDS
|
|
481
|
+
while True:
|
|
482
|
+
try:
|
|
483
|
+
msvcrt.locking(fd, lock_mode, 1)
|
|
484
|
+
os_lock_acquired = True
|
|
485
|
+
break
|
|
486
|
+
except OSError as exc:
|
|
487
|
+
if not _is_lock_contention(exc):
|
|
488
|
+
raise
|
|
489
|
+
if time.monotonic() >= lock_deadline:
|
|
490
|
+
raise
|
|
491
|
+
time.sleep(_WINDOWS_LOCK_RETRY_SECONDS)
|
|
492
|
+
os.write(fd, line.encode("utf-8"))
|
|
493
|
+
finally:
|
|
494
|
+
if os.name == "nt" and os_lock_acquired:
|
|
495
|
+
try:
|
|
496
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
497
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
498
|
+
except Exception: # noqa: BLE001 — outer sink remains fail-open
|
|
499
|
+
pass
|
|
500
|
+
if process_lock_acquired:
|
|
501
|
+
_GATE_EVENTS_WINDOWS_LOCK.release()
|
|
502
|
+
if fd is not None:
|
|
503
|
+
os.close(fd)
|
|
504
|
+
except Exception: # noqa: BLE001 — fail-open: the sink must never affect the gate
|
|
505
|
+
pass
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def block(tag, msg):
|
|
509
|
+
"""BLOCK the tool call: stderr is surfaced to Claude, exit 2."""
|
|
510
|
+
_log_gate_event("BLOCK", tag, msg)
|
|
511
|
+
print(f"BLOCKED [{tag}]: {msg}", file=sys.stderr)
|
|
512
|
+
sys.exit(2)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def remind(tag, msg):
|
|
516
|
+
"""Non-blocking nudge to stderr."""
|
|
517
|
+
_log_gate_event("REMIND", tag, msg)
|
|
518
|
+
print(f"REMINDER [{tag}]: {msg}", file=sys.stderr)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def warn(msg):
|
|
522
|
+
"""Loud degradation/diagnostic breadcrumb — never silent."""
|
|
523
|
+
_log_gate_event("WARN", None, msg)
|
|
524
|
+
print(f"codeArbiter hook: {msg}", file=sys.stderr)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
# --- CONFIRM-09: audit-trail completeness staleness-warn ---------------------
|
|
528
|
+
# The H-05 guards above are INTEGRITY controls (a written audit line can't be
|
|
529
|
+
# rewritten/deleted) — they don't compel a write in the first place. This is
|
|
530
|
+
# the accepted-strategy (a) completeness half (security-controls.md § Audit
|
|
531
|
+
# trail, 2026-07-02): a lightweight WARN, never a gate, surfaced when an
|
|
532
|
+
# active long-running flow's marker has sat around past `window_minutes` with
|
|
533
|
+
# no matching activity in its expected audit log.
|
|
534
|
+
#
|
|
535
|
+
# Only /dev and /sprint have a persistent "in-progress" marker today
|
|
536
|
+
# (.codearbiter/.markers/dev-active and .codearbiter/sprint-active — the same
|
|
537
|
+
# state _arbiterstatelib.dev_active()/arbiter_state() already read). /override
|
|
538
|
+
# is a single synchronous action (announce-then-log in one turn, per
|
|
539
|
+
# override.md) with no analogous "still in progress" marker anywhere in the
|
|
540
|
+
# framework, so per CONFIRM-09's own "do not invent new state" constraint it
|
|
541
|
+
# is not tracked here — there is no existing signal to detect it from.
|
|
542
|
+
#
|
|
543
|
+
# #271 C-5: this staleness WARN is presence + age based (marker mtime vs. an
|
|
544
|
+
# audit-log write), which is unaffected by session-start.py's newer
|
|
545
|
+
# session-scoped CLEARING decision for the SAME dev-active marker — the two
|
|
546
|
+
# consumers ask different questions ("has this sat around too long with no
|
|
547
|
+
# matching log activity?" vs. "am I sure enough this belongs to nobody live
|
|
548
|
+
# right now that I should force-close it?") and neither needs to agree with
|
|
549
|
+
# the other's answer. A dev marker owned by a still-live different session
|
|
550
|
+
# can legitimately trip THIS warning (it really has been open a while) even
|
|
551
|
+
# though session-start.py correctly declines to clobber it.
|
|
552
|
+
_STALE_FLOWS = (
|
|
553
|
+
# (flow name, marker path parts, expected-log path parts)
|
|
554
|
+
("dev", (".markers", "dev-active"), ("overrides.log",)),
|
|
555
|
+
("sprint", ("sprint-active",), ("sprint-log.md",)),
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def staleness_warning(root, now=None, window_minutes=30):
|
|
560
|
+
"""(CONFIRM-09) One WARN message per active flow (see _STALE_FLOWS) whose
|
|
561
|
+
marker has existed for at least `window_minutes` with no audit-log
|
|
562
|
+
activity (marker touch OR log write) inside that same window. Returns []
|
|
563
|
+
when nothing is stale (including when no flow is active at all).
|
|
564
|
+
|
|
565
|
+
WARN-ONLY BY CONTRACT: this function only computes strings — it has no
|
|
566
|
+
side effects, never calls warn()/block() itself, and can NEVER raise (any
|
|
567
|
+
per-flow stat failure just skips that flow, exactly like marker_fresh's
|
|
568
|
+
own degrade-to-False). The caller decides whether to surface the result,
|
|
569
|
+
typically via warn(), which is itself non-blocking."""
|
|
570
|
+
now = time.time() if now is None else now
|
|
571
|
+
cad = os.path.join(root, ".codearbiter")
|
|
572
|
+
messages = []
|
|
573
|
+
for name, marker_parts, log_parts in _STALE_FLOWS:
|
|
574
|
+
try:
|
|
575
|
+
marker = os.path.join(cad, *marker_parts)
|
|
576
|
+
if not os.path.isfile(marker):
|
|
577
|
+
continue
|
|
578
|
+
marker_mtime = os.path.getmtime(marker)
|
|
579
|
+
if now - marker_mtime < window_minutes * 60:
|
|
580
|
+
continue # flow started too recently to call it stale yet
|
|
581
|
+
log_path = os.path.join(cad, *log_parts)
|
|
582
|
+
try:
|
|
583
|
+
log_mtime = os.path.getmtime(log_path)
|
|
584
|
+
except OSError:
|
|
585
|
+
log_mtime = 0 # log never written at all -> definitely stale
|
|
586
|
+
last_activity = max(marker_mtime, log_mtime)
|
|
587
|
+
if now - last_activity >= window_minutes * 60:
|
|
588
|
+
messages.append(
|
|
589
|
+
f"/{name} has been active for over {window_minutes} min with no "
|
|
590
|
+
f"matching {os.path.basename(log_path)} entry since — confirm the "
|
|
591
|
+
f"expected audit line landed (CONFIRM-09)."
|
|
592
|
+
)
|
|
593
|
+
except Exception: # noqa: BLE001 — never raise; skip this flow, not the caller
|
|
594
|
+
continue
|
|
595
|
+
return messages
|