@arbiterforge/ca-pi 0.6.3 → 0.10.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/README.md +41 -98
- package/package.json +1 -1
- package/plugins/ca-pi/CHANGELOG.md +145 -0
- package/plugins/ca-pi/COMMANDS.md +138 -68
- package/plugins/ca-pi/SKILLS.md +137 -30
- package/plugins/ca-pi/agents/INDEX.md +3 -2
- package/plugins/ca-pi/agents/checkpoint-aggregator.md +8 -7
- package/plugins/ca-pi/agents/design-quality-reviewer.md +1 -1
- package/plugins/ca-pi/agents/finding-triage.md +31 -14
- package/plugins/ca-pi/agents/verdict-aggregator.md +64 -0
- package/plugins/ca-pi/{ORCHESTRATOR.md → arbiter.md} +37 -36
- package/plugins/ca-pi/extensions/codearbiter.js +844 -19
- package/plugins/ca-pi/generated/command-catalog.json +386 -196
- package/plugins/ca-pi/generated/roles.json +9 -0
- package/plugins/ca-pi/hooks/_arbiterstatelib.py +59 -11
- package/plugins/ca-pi/hooks/_bashguardlib.py +30 -12
- package/plugins/ca-pi/hooks/_gitexec.py +23 -0
- package/plugins/ca-pi/hooks/_githooks.py +50 -23
- package/plugins/ca-pi/hooks/_hooklib.py +148 -20
- package/plugins/ca-pi/hooks/_host.py +9 -1
- package/plugins/ca-pi/hooks/_metricslib.py +20 -0
- package/plugins/ca-pi/hooks/_modelib.py +762 -0
- package/plugins/ca-pi/hooks/_protectedlib.py +13 -4
- package/plugins/ca-pi/hooks/_prunelib.py +51 -12
- package/plugins/ca-pi/hooks/_prunepolicy.py +33 -7
- package/plugins/ca-pi/hooks/_readinjectlib.py +10 -4
- package/plugins/ca-pi/hooks/_releaselib.py +278 -48
- package/plugins/ca-pi/hooks/_updatelib.py +230 -50
- package/plugins/ca-pi/hooks/doctor.py +58 -9
- package/plugins/ca-pi/hooks/git-enforce.py +10 -3
- package/plugins/ca-pi/hooks/hostapi.py +220 -22
- package/plugins/ca-pi/hooks/pi-bridge.py +10 -4
- package/plugins/ca-pi/hooks/prompt-submit.py +486 -0
- package/plugins/ca-pi/hooks/prune-transcript.py +23 -3
- package/plugins/ca-pi/hooks/session-start.py +529 -435
- package/plugins/ca-pi/hooks/statusline.py +28 -10
- package/plugins/ca-pi/hooks/wire-statusline.py +13 -8
- package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +1 -1
- package/plugins/ca-pi/includes/command-compatibility.md +16 -0
- package/plugins/ca-pi/includes/dangerous-mode.md +57 -0
- package/plugins/ca-pi/includes/ops-mode.md +96 -0
- package/plugins/ca-pi/includes/pi-host-notes.md +10 -1
- package/plugins/ca-pi/includes/redirect.md +12 -1
- package/plugins/ca-pi/includes/routing-table.md +14 -5
- package/plugins/ca-pi/includes/safety-core.md +86 -0
- package/plugins/ca-pi/includes/smarts/core.md +1 -1
- package/plugins/ca-pi/routines/INDEX.md +1 -1
- package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +55 -3
- package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +9 -1
- package/plugins/ca-pi/routines/decompose/SKILL.md +1 -1
- package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +4 -4
- package/plugins/ca-pi/routines/release/SKILL.md +1 -1
- package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +5 -4
- package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +6 -0
- package/plugins/ca-pi/skills/ca-context-check/SKILL.md +6 -0
- package/plugins/ca-pi/skills/ca-create-context/SKILL.md +6 -0
- package/plugins/ca-pi/skills/ca-decompose/SKILL.md +6 -0
- package/plugins/ca-pi/skills/ca-doctor/SKILL.md +4 -0
- package/plugins/ca-pi/skills/ca-init/SKILL.md +18 -1
- package/plugins/ca-pi/skills/ca-pr/SKILL.md +17 -1
- package/plugins/ca-pi/skills/ca-review/SKILL.md +3 -4
- package/plugins/ca-pi/skills/ca-spike/SKILL.md +15 -8
- package/plugins/ca-pi/skills/ca-status/SKILL.md +13 -1
- package/plugins/ca-pi/skills/ca-watch/SKILL.md +6 -0
- package/plugins/ca-pi/includes/dev-mode.md +0 -30
- package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +0 -36
- package/plugins/ca-pi/skills/ca-dev/SKILL.md +0 -42
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
# count_matches_text(text, pattern) -> int same count, given already-read text (performance-003)
|
|
28
28
|
# arbiter_state(root, count_in_flight=None, read_board=None, frontmatter_enabled=None,
|
|
29
29
|
# ctx_text=None, ot_text=None, oq_text=None) -> dict|None
|
|
30
|
-
#
|
|
30
|
+
# current_mode(session_id, root=None, payload=None) -> str one of _modelib.MODES
|
|
31
|
+
# (#437, mode-plane-deterministic-flip; replaces
|
|
32
|
+
# the retired dev_active(root) presence check)
|
|
31
33
|
#
|
|
32
34
|
# performance-003 (#194): SessionStart's main() already reads CONTEXT.md,
|
|
33
35
|
# open-tasks.md, and open-questions.md before the display-only governance line
|
|
@@ -42,6 +44,15 @@
|
|
|
42
44
|
import os
|
|
43
45
|
import re
|
|
44
46
|
|
|
47
|
+
# #437 (mode-plane-deterministic-flip, T-51): the mode plane replaces the old
|
|
48
|
+
# presence-only 'dev-active' marker this module used to read. Imported
|
|
49
|
+
# unguarded (like every other sibling import in this file) — statusline.py,
|
|
50
|
+
# this module's only external caller, already wraps `import _arbiterstatelib`
|
|
51
|
+
# in its own try/except, so a failure here degrades the whole module import,
|
|
52
|
+
# exactly like a failure to import `os` would. No circularity risk: _modelib
|
|
53
|
+
# imports _activationlib and _hooklib, neither of which imports this module.
|
|
54
|
+
from _modelib import current_mode as _modelib_current_mode
|
|
55
|
+
|
|
45
56
|
# mtime-keyed memo: statusline.py is a short-lived subprocess, but a single render
|
|
46
57
|
# can resolve arbiter_state more than once (safe() probes), and the StopHook fires
|
|
47
58
|
# the whole script on every tool-call completion. Caching on max(input mtime) makes
|
|
@@ -155,6 +166,22 @@ def arbiter_state(root, count_in_flight=None, read_board=None, frontmatter_enabl
|
|
|
155
166
|
return result
|
|
156
167
|
|
|
157
168
|
|
|
169
|
+
# AC-40: MODE: <name> enter|exit and legacy DEV: enter|exit rows are ledger
|
|
170
|
+
# bookkeeping (the #437 mode-plane audit trail), not overrides — they must not
|
|
171
|
+
# inflate the statusline's "over:" counter. Mode transitions are about to
|
|
172
|
+
# become routine traffic in overrides.log, so an uncorrected counter would
|
|
173
|
+
# turn from noisy into actively WRONG. Mirrors _metricslib._MODE_TRANSITION_RE's
|
|
174
|
+
# exact matched fragment (kept as an independent literal, not an import: this
|
|
175
|
+
# module has no other reason to depend on _metricslib, and one small regex
|
|
176
|
+
# fragment is cheaper to duplicate than to add a cross-module coupling for).
|
|
177
|
+
_MODE_TRANSITION_FRAGMENT = (
|
|
178
|
+
r"\|\s*(?:MODE:\s*\S+\s+(?:enter|exit)|DEV:\s*(?:enter|exit))\s*(?:\||$)"
|
|
179
|
+
)
|
|
180
|
+
_OVERRIDE_LINE_RE = (
|
|
181
|
+
r"^(?!\s*#)(?!\s*$)(?!.*" + _MODE_TRANSITION_FRAGMENT + r").+"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
158
185
|
def _arbiter_state_uncached(cad, count_in_flight=None, read_board=None, frontmatter_enabled=None,
|
|
159
186
|
ctx_text=None, ot_text=None, oq_text=None):
|
|
160
187
|
ctx_path = os.path.join(cad, "CONTEXT.md")
|
|
@@ -164,7 +191,7 @@ def _arbiter_state_uncached(cad, count_in_flight=None, read_board=None, frontmat
|
|
|
164
191
|
# open-questions.md text when supplied, instead of a second disk read. `None`
|
|
165
192
|
# (the default) preserves the exact original read-from-disk behavior.
|
|
166
193
|
fm = frontmatter_text(ctx_text) if ctx_text is not None else frontmatter(ctx_path)
|
|
167
|
-
total_over = count_matches(os.path.join(cad, "overrides.log"),
|
|
194
|
+
total_over = count_matches(os.path.join(cad, "overrides.log"), _OVERRIDE_LINE_RE)
|
|
168
195
|
# last-checkpoint holds the override COUNT at the last /ca:checkpoint. A value
|
|
169
196
|
# outside [0, total] is not a valid count (e.g. a timestamp from a stale writer)
|
|
170
197
|
# -> fail safe to 0 so overrides are surfaced, never silently hidden.
|
|
@@ -196,13 +223,34 @@ def _arbiter_state_uncached(cad, count_in_flight=None, read_board=None, frontmat
|
|
|
196
223
|
}
|
|
197
224
|
|
|
198
225
|
|
|
199
|
-
def
|
|
200
|
-
"""
|
|
201
|
-
|
|
226
|
+
def current_mode(session_id, root=None, payload=None):
|
|
227
|
+
"""The session's current orchestration posture — one of `_modelib.MODES`
|
|
228
|
+
('arbiter' | 'dangerous' | 'ops') — read off the mode plane (#437,
|
|
229
|
+
mode-plane-deterministic-flip). Replaces the retired `dev_active(root)`
|
|
230
|
+
presence check: the 'dev-active' marker is not dual-written (Decided
|
|
231
|
+
parameters: "Single source of truth" — every reader migrates to the mode
|
|
232
|
+
file), so a reader that kept checking for that marker would silently and
|
|
233
|
+
permanently report inactive the moment nothing writes it anymore.
|
|
234
|
+
|
|
235
|
+
Deliberately resolves through `_activationlib.marker_root` — via
|
|
236
|
+
`_modelib.current_mode`'s own `root=None` contract — rather than this
|
|
237
|
+
module's OTHER readers' pre-resolved `root` (arbiter_state's caller
|
|
238
|
+
passes a project root suitable for reading CONTEXT.md/overrides.log
|
|
239
|
+
in-place). `.codearbiter/.markers/` is gitignored, so a linked
|
|
240
|
+
worktree's own checkout never has a fresh copy of it, and every other
|
|
241
|
+
`.markers/` writer (security-pass.py, migration-pass.py, the mode flip
|
|
242
|
+
itself) already resolves through marker_root — the mode marker must
|
|
243
|
+
agree with them or a linked-worktree session silently reads the wrong
|
|
244
|
+
file (#604, AC-5).
|
|
245
|
+
|
|
246
|
+
Pass `payload` (the raw hook/status-line JSON) for production-correct
|
|
247
|
+
resolution; `root` is a test-only escape hatch that mirrors
|
|
248
|
+
`_modelib.current_mode`'s own contract exactly — an explicit root
|
|
249
|
+
bypasses payload/host resolution entirely and is read verbatim.
|
|
202
250
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
return
|
|
251
|
+
Never raises: an absent/unreadable/malformed marker, or an unrecognized
|
|
252
|
+
per-session value, degrades to 'arbiter' — the diagnostic half of
|
|
253
|
+
`_modelib.current_mode`'s return is a maintainer/audit concern, not this
|
|
254
|
+
render-facing reader's; callers here need only the mode, never why."""
|
|
255
|
+
mode, _diag = _modelib_current_mode(session_id, root=root, payload=payload)
|
|
256
|
+
return mode
|
|
@@ -81,7 +81,8 @@ import subprocess
|
|
|
81
81
|
import sys
|
|
82
82
|
|
|
83
83
|
from _hooklib import (
|
|
84
|
-
|
|
84
|
+
ADR_LIFECYCLE_LOG_BASENAME, AUDIT_LOG_BASENAMES, AUDIT_LOG_FLAT_BASENAMES,
|
|
85
|
+
AUDIT_LOG_NAMES, CRYPTO_RE, DECISION_AUDIT_LOG_NAMES, DECISION_LOG_BASENAME, DECISIONS_DIR_RE,
|
|
85
86
|
GATE_MARKER_NAMES, MARKER_FRESHNESS_MINUTES, SECRET_RE, SECURITY_DIFF_GIT_ARGS, block,
|
|
86
87
|
content_digest, is_migration_path, line_digest, marker_fresh, sensitive_scan_added_lines,
|
|
87
88
|
)
|
|
@@ -317,7 +318,7 @@ PROTECTED_DEST_RE = re.compile(r"(?:\S+:|:)?(?:refs/heads/)?(?:main|master)")
|
|
|
317
318
|
# (the /sprint decision record). The bare-name alternation is centralized in
|
|
318
319
|
# _hooklib.AUDIT_LOG_NAMES so the Write/Edit and shell flanks never drift.
|
|
319
320
|
LOG_NAMES = AUDIT_LOG_NAMES
|
|
320
|
-
LOG_TRUNC_RE = re.compile(r"(?<!>)>(?!>)\|?\s*\S*" + LOG_NAMES)
|
|
321
|
+
LOG_TRUNC_RE = re.compile(r"(?<!>)>(?!>)\|?\s*\S*" + LOG_NAMES, re.I)
|
|
321
322
|
LOG_DESTROY_RE = re.compile(
|
|
322
323
|
r"\b(rm|del|mv|cp|copy|dd|tee|sed|truncate|sponge"
|
|
323
324
|
# #528: `New-Item -Force` TRUNCATES an existing file (verified in PowerShell:
|
|
@@ -345,20 +346,20 @@ DECISIONS = DECISIONS_DIR_RE + r"\b"
|
|
|
345
346
|
# #528: the one path under decisions/ that H-11 must NOT claim — see
|
|
346
347
|
# _check_h11_decisions. Matched on the raw command, so both separators.
|
|
347
348
|
#
|
|
348
|
-
# DELIBERATELY CASE-SENSITIVE. H-05, which takes over for this file, is itself
|
|
349
|
-
# case-sensitive on both flanks: _check_h05_audit_log pre-filters with a plain
|
|
350
|
-
# `in` test over AUDIT_LOG_BASENAMES, and LOG_TRUNC_RE carries no re.I. An re.I
|
|
351
|
-
# here therefore stripped `Decision-Log.md` out of H-11's view and handed it to a
|
|
352
|
-
# guard that could not see it — and on Windows/NTFS and default macOS/APFS that
|
|
353
|
-
# spelling resolves to the real file, so `rm …/Decision-Log.md` destroyed the
|
|
354
|
-
# append-only log with nothing firing at all. The two flanks must agree on case.
|
|
355
|
-
#
|
|
356
349
|
# The right edge is anchored so this path cannot SHIELD a sibling token: without
|
|
357
350
|
# it, `touch …/decision-log.md.evil.md` was stripped to a harmless remainder and
|
|
358
351
|
# H-11 stopped seeing a decisions/ write at all.
|
|
359
352
|
DECISION_LOG_SHELL_RE = re.compile(
|
|
360
353
|
DECISIONS_DIR_RE + r"[\\/]+" + re.escape(DECISION_LOG_BASENAME) + r"""(?=$|[\s>|;&"'])""",
|
|
361
354
|
)
|
|
355
|
+
# The lifecycle ledger is new and explicitly enrolled case-insensitively on
|
|
356
|
+
# supported case-folding filesystems. Keep this separate from decision-log.md:
|
|
357
|
+
# historical mixed-case decision-log spellings remain H-11, as #528 requires.
|
|
358
|
+
ADR_LIFECYCLE_LOG_SHELL_RE = re.compile(
|
|
359
|
+
DECISIONS_DIR_RE + r"[\\/]+" + re.escape(ADR_LIFECYCLE_LOG_BASENAME) +
|
|
360
|
+
r"""(?=$|[\s>|;&"'])""",
|
|
361
|
+
re.I,
|
|
362
|
+
)
|
|
362
363
|
# `>>?\|?` covers `>`, `>>`, and the `>|` force-clobber form into decisions/.
|
|
363
364
|
DECISIONS_REDIRECT_RE = re.compile(r">>?\|?\s*\S*" + DECISIONS, re.I)
|
|
364
365
|
DECISIONS_WRITE_RE = re.compile(
|
|
@@ -804,6 +805,12 @@ def git_cwd(cmd, root):
|
|
|
804
805
|
return acc
|
|
805
806
|
|
|
806
807
|
|
|
808
|
+
_CODEX_EXPLICIT_WORKDIR_TOOLS = frozenset({
|
|
809
|
+
"shell_command", "exec_command", "unified_exec",
|
|
810
|
+
"functions.exec",
|
|
811
|
+
})
|
|
812
|
+
|
|
813
|
+
|
|
807
814
|
def _effective_exec_root(payload, root):
|
|
808
815
|
"""The git root that a `-C`-less git command in THIS Bash call actually
|
|
809
816
|
runs against — the command's effective cwd — rather than always the
|
|
@@ -841,7 +848,13 @@ def _effective_exec_root(payload, root):
|
|
|
841
848
|
unchanged — the overwhelmingly common (non-worktree) case sees zero
|
|
842
849
|
behavioral difference. It returns the climbed root only when it names a
|
|
843
850
|
genuinely DIFFERENT filesystem location."""
|
|
844
|
-
|
|
851
|
+
data = payload if isinstance(payload, dict) else {}
|
|
852
|
+
tool_input = data.get("tool_input")
|
|
853
|
+
workdir = tool_input.get("workdir") if isinstance(tool_input, dict) else None
|
|
854
|
+
if (data.get("tool_name") in _CODEX_EXPLICIT_WORKDIR_TOOLS
|
|
855
|
+
and isinstance(workdir, str) and os.path.isdir(workdir)):
|
|
856
|
+
data = {"cwd": workdir}
|
|
857
|
+
exec_root = _gitlib.project_root(data)
|
|
845
858
|
if os.path.normpath(os.path.abspath(exec_root)) == os.path.normpath(os.path.abspath(root)):
|
|
846
859
|
return root
|
|
847
860
|
return exec_root
|
|
@@ -1329,7 +1342,11 @@ def _check_h05_audit_log(cmd):
|
|
|
1329
1342
|
# (`python3 -c "open('.codearbiter/overrides.log','w')..."`) — the
|
|
1330
1343
|
# verb-list and redirect legs above never look for an interpreter token
|
|
1331
1344
|
# at all, so this shape walked past both.
|
|
1332
|
-
|
|
1345
|
+
folded = cmd.casefold()
|
|
1346
|
+
legacy_names = AUDIT_LOG_FLAT_BASENAMES + (DECISION_LOG_BASENAME,)
|
|
1347
|
+
enrolled_name = (any(n in cmd for n in legacy_names) or
|
|
1348
|
+
ADR_LIFECYCLE_LOG_BASENAME.casefold() in folded)
|
|
1349
|
+
if enrolled_name and (
|
|
1333
1350
|
LOG_TRUNC_RE.search(cmd) or LOG_DESTROY_RE.search(cmd)
|
|
1334
1351
|
or LOG_GIT_RESTORE_RE.search(cmd) or LOG_INTERP_RE.search(cmd)):
|
|
1335
1352
|
block("H-05", "The .codearbiter audit logs (overrides.log, triage.log, sprint-log.md, "
|
|
@@ -1356,6 +1373,7 @@ def _check_h11_decisions(cmd):
|
|
|
1356
1373
|
# append to decisions/decision-log.md stays the #528 carve-out's to
|
|
1357
1374
|
# police (via H-05's own LOG_INTERP_RE), not a false H-11 block.
|
|
1358
1375
|
cmd = DECISION_LOG_SHELL_RE.sub(" ", cmd)
|
|
1376
|
+
cmd = ADR_LIFECYCLE_LOG_SHELL_RE.sub(" ", cmd)
|
|
1359
1377
|
if (DECISIONS_REDIRECT_RE.search(cmd) or DECISIONS_WRITE_RE.search(cmd)
|
|
1360
1378
|
or DECISIONS_INTERP_RE.search(cmd)):
|
|
1361
1379
|
block("H-11", "ADR files under .codearbiter/decisions/ are authored only via "
|
|
@@ -20,6 +20,29 @@ import os
|
|
|
20
20
|
GIT_ENV = "CODEARBITER_GIT_EXECUTABLE"
|
|
21
21
|
PYTHON_ENV = "CODEARBITER_PYTHON_EXECUTABLE"
|
|
22
22
|
|
|
23
|
+
_ROOT_OVERRIDE_ENV = frozenset({
|
|
24
|
+
# `git rev-parse --local-env-vars` repository/object selectors.
|
|
25
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_OBJECT_DIRECTORY",
|
|
26
|
+
"GIT_DIR", "GIT_WORK_TREE", "GIT_IMPLICIT_WORK_TREE",
|
|
27
|
+
"GIT_GRAFT_FILE", "GIT_INDEX_FILE", "GIT_NO_REPLACE_OBJECTS",
|
|
28
|
+
"GIT_REPLACE_REF_BASE", "GIT_PREFIX", "GIT_SHALLOW_FILE",
|
|
29
|
+
"GIT_COMMON_DIR",
|
|
30
|
+
# Discovery indirection can also override an exact root-bound probe.
|
|
31
|
+
"GIT_CEILING_DIRECTORIES", "GIT_DISCOVERY_ACROSS_FILESYSTEM",
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def root_bound_git_env():
|
|
36
|
+
"""Environment for selected Git to describe an explicit checkout root.
|
|
37
|
+
|
|
38
|
+
Protected configuration such as safe.directory remains authoritative; only
|
|
39
|
+
repository, object, and discovery selectors that override `-C` are removed.
|
|
40
|
+
"""
|
|
41
|
+
env = os.environ.copy()
|
|
42
|
+
for name in _ROOT_OVERRIDE_ENV:
|
|
43
|
+
env.pop(name, None)
|
|
44
|
+
return env
|
|
45
|
+
|
|
23
46
|
|
|
24
47
|
def _trusted_environment_path(name):
|
|
25
48
|
value = os.environ.get(name)
|
|
@@ -62,8 +62,8 @@
|
|
|
62
62
|
# * Idempotent: an up-to-date ours-hook is left untouched (no churn); a stale
|
|
63
63
|
# ours-hook is refreshed.
|
|
64
64
|
# * performance-002 (#194): re-resolving hooks_dir() every SessionStart costs
|
|
65
|
-
#
|
|
66
|
-
#
|
|
65
|
+
# one blocking `git` subprocess spawn (`rev-parse --git-path hooks`) even
|
|
66
|
+
# on the common steady-state call where
|
|
67
67
|
# nothing changed. install() first checks a cheap on-disk cache (a single
|
|
68
68
|
# small file read, no git spawn) recording the hooks_dir a prior successful
|
|
69
69
|
# resolution used; if BOTH phase shims at that cached location already
|
|
@@ -114,7 +114,8 @@ import sys
|
|
|
114
114
|
|
|
115
115
|
import _hooklib
|
|
116
116
|
from _durabilitylib import is_ephemeral_path
|
|
117
|
-
from _gitexec import git_executable,
|
|
117
|
+
from _gitexec import (git_executable, root_bound_git_env,
|
|
118
|
+
trusted_git_executable, trusted_python_executable)
|
|
118
119
|
|
|
119
120
|
SENTINEL = (
|
|
120
121
|
"# codeArbiter-managed git hook (#161) — this SHIM is refreshed by any live "
|
|
@@ -140,6 +141,7 @@ def _git(args, cwd):
|
|
|
140
141
|
return subprocess.run(
|
|
141
142
|
[git_executable()] + args, cwd=cwd, capture_output=True, text=True,
|
|
142
143
|
encoding="utf-8", errors="replace", timeout=5,
|
|
144
|
+
env=root_bound_git_env(),
|
|
143
145
|
)
|
|
144
146
|
except Exception: # noqa: BLE001
|
|
145
147
|
return None
|
|
@@ -148,20 +150,15 @@ def _git(args, cwd):
|
|
|
148
150
|
def hooks_dir(root):
|
|
149
151
|
"""The directory git actually reads hooks from for `root`, or None.
|
|
150
152
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
cfg = _git(["config", "--get", "core.hooksPath"], root)
|
|
156
|
-
if cfg is not None and cfg.returncode == 0 and cfg.stdout.strip():
|
|
157
|
-
hp = cfg.stdout.strip()
|
|
158
|
-
return hp if os.path.isabs(hp) else os.path.join(root, hp)
|
|
153
|
+
The selected Git binary owns core.hooksPath parsing, including its path
|
|
154
|
+
grammar (`~`, `%(prefix)`, absolute, and relative forms), and linked
|
|
155
|
+
worktree/submodule semantics. Python must not reinterpret the raw config
|
|
156
|
+
value differently from the binary that will execute the hooks."""
|
|
159
157
|
gp = _git(["rev-parse", "--git-path", "hooks"], root)
|
|
160
158
|
if gp is not None and gp.returncode == 0 and gp.stdout.strip():
|
|
161
159
|
hp = gp.stdout.strip()
|
|
162
160
|
return hp if os.path.isabs(hp) else os.path.join(root, hp)
|
|
163
|
-
|
|
164
|
-
return default if os.path.isdir(os.path.join(root, ".git")) else None
|
|
161
|
+
return None
|
|
165
162
|
|
|
166
163
|
|
|
167
164
|
def _enforcer_path():
|
|
@@ -213,13 +210,15 @@ def _git_common_dir(root):
|
|
|
213
210
|
repo must resolve to the SAME common dir here, or the #265 drop-in dir
|
|
214
211
|
would fork per-worktree and defeat the entire cross-host purpose (a shim
|
|
215
212
|
installed from worktree A would never see an entry written from
|
|
216
|
-
worktree B). The
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
213
|
+
worktree B). The returned path is canonical so equivalent symlink, macOS
|
|
214
|
+
`/var`, and Windows short-name spellings produce the same registry path in
|
|
215
|
+
every managed shim. The main-repo case (`.git` is a directory) needs no
|
|
216
|
+
spawn at all: it IS its own common dir. Returns None if nothing resolves —
|
|
217
|
+
callers must treat that as "can't place the drop-in dir right now" and
|
|
218
|
+
never invent a per-worktree fallback."""
|
|
220
219
|
git_path = os.path.join(root, ".git")
|
|
221
220
|
if os.path.isdir(git_path):
|
|
222
|
-
return os.path.
|
|
221
|
+
return os.path.realpath(git_path)
|
|
223
222
|
if os.path.isfile(git_path):
|
|
224
223
|
text = _read(git_path)
|
|
225
224
|
if text:
|
|
@@ -234,12 +233,13 @@ def _git_common_dir(root):
|
|
|
234
233
|
cd = cd_text.strip()
|
|
235
234
|
common = (cd if os.path.isabs(cd)
|
|
236
235
|
else os.path.normpath(os.path.join(wt_gitdir, cd)))
|
|
237
|
-
return os.path.
|
|
236
|
+
return os.path.realpath(common)
|
|
238
237
|
break
|
|
239
238
|
r = _git(["rev-parse", "--git-common-dir"], root)
|
|
240
239
|
if r is not None and r.returncode == 0 and r.stdout.strip():
|
|
241
240
|
out = r.stdout.strip()
|
|
242
|
-
return os.path.
|
|
241
|
+
return os.path.realpath(
|
|
242
|
+
out if os.path.isabs(out) else os.path.join(root, out))
|
|
243
243
|
return None
|
|
244
244
|
|
|
245
245
|
|
|
@@ -370,8 +370,9 @@ def _touch_seen_marker(dropin_dir, plugin, enforcer):
|
|
|
370
370
|
# SENTINEL) session bakes in. `/ca:doctor` runs the SAME text as a real
|
|
371
371
|
# subprocess instead of a parallel port, so the two can never drift.
|
|
372
372
|
#
|
|
373
|
-
# Algorithm:
|
|
374
|
-
#
|
|
373
|
+
# Algorithm: first discard entries whose registered enforcer is not a live
|
|
374
|
+
# regular file. A remaining plugin's registered entry is "stale" — printed to
|
|
375
|
+
# stdout, one per line — iff (a) at least one OTHER live registered entry in the same
|
|
375
376
|
# drop-in dir has recorded a `.seen` heartbeat, AND (b) this plugin's own
|
|
376
377
|
# heartbeat is either absent or strictly older than the freshest one seen.
|
|
377
378
|
# When NOBODY has ever recorded a heartbeat (a repo that predates #556, or
|
|
@@ -401,7 +402,7 @@ _FRESHNESS_PY = (
|
|
|
401
402
|
" if legacy.fullmatch(plugin):\n"
|
|
402
403
|
" continue\n"
|
|
403
404
|
" path_val = _rd(os.path.join(d, n))\n"
|
|
404
|
-
" if path_val
|
|
405
|
+
" if not path_val or not os.path.isfile(path_val):\n"
|
|
405
406
|
" continue\n"
|
|
406
407
|
" seen_file = os.path.join(d, plugin + '.seen')\n"
|
|
407
408
|
# `.seen` only counts as a confirmation of what's registered RIGHT NOW when
|
|
@@ -455,6 +456,32 @@ def stale_registered_plugins(dropin_dir):
|
|
|
455
456
|
return [ln for ln in r.stdout.splitlines() if ln.strip()]
|
|
456
457
|
|
|
457
458
|
|
|
459
|
+
def live_registered_plugins(dropin_dir):
|
|
460
|
+
"""Stable plugin names whose current `.path` target is a regular file.
|
|
461
|
+
|
|
462
|
+
This deliberately uses the same liveness boundary as the generated shim
|
|
463
|
+
(`[ -f "$E" ]`) and the freshness probe above. It is diagnostic support
|
|
464
|
+
for doctor, not a second freshness implementation."""
|
|
465
|
+
if not dropin_dir or not os.path.isdir(dropin_dir):
|
|
466
|
+
return []
|
|
467
|
+
legacy = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$")
|
|
468
|
+
live = []
|
|
469
|
+
try:
|
|
470
|
+
names = sorted(os.listdir(dropin_dir))
|
|
471
|
+
except OSError:
|
|
472
|
+
return []
|
|
473
|
+
for name in names:
|
|
474
|
+
if not name.endswith(".path"):
|
|
475
|
+
continue
|
|
476
|
+
plugin = name[:-len(".path")]
|
|
477
|
+
if legacy.fullmatch(plugin):
|
|
478
|
+
continue
|
|
479
|
+
path_val = _read(os.path.join(dropin_dir, name))
|
|
480
|
+
if path_val and os.path.isfile(path_val.strip()):
|
|
481
|
+
live.append(plugin)
|
|
482
|
+
return live
|
|
483
|
+
|
|
484
|
+
|
|
458
485
|
_TRUSTED_IDENTITY_FILE = "trusted-executables.identity"
|
|
459
486
|
|
|
460
487
|
|
|
@@ -186,7 +186,10 @@ from _protectedlib import ( # noqa: F401
|
|
|
186
186
|
DECISIONS_DIR_RE,
|
|
187
187
|
DECISIONS_PATH_RE,
|
|
188
188
|
DECISION_LOG_BASENAME,
|
|
189
|
+
ADR_LIFECYCLE_LOG_BASENAME,
|
|
190
|
+
DECISION_AUDIT_LOG_NAMES,
|
|
189
191
|
DECISION_LOG_RE,
|
|
192
|
+
ADR_LIFECYCLE_LOG_RE,
|
|
190
193
|
GATE_MARKER_NAMES,
|
|
191
194
|
MARKERS_RE,
|
|
192
195
|
classify_protected,
|
|
@@ -532,30 +535,149 @@ def warn(msg):
|
|
|
532
535
|
# active long-running flow's marker has sat around past `window_minutes` with
|
|
533
536
|
# no matching activity in its expected audit log.
|
|
534
537
|
#
|
|
535
|
-
# Only
|
|
536
|
-
# (.codearbiter/.markers/
|
|
537
|
-
# state _arbiterstatelib.
|
|
538
|
-
# is
|
|
539
|
-
#
|
|
540
|
-
#
|
|
541
|
-
#
|
|
538
|
+
# Only the mode plane and /sprint have a persistent "in-progress" marker
|
|
539
|
+
# today (.codearbiter/.markers/mode.d/ and .codearbiter/sprint-active — the same
|
|
540
|
+
# state _arbiterstatelib.current_mode()/arbiter_state() already read; the
|
|
541
|
+
# mode marker is #437's direct successor to the retired dev-active marker
|
|
542
|
+
# this comment originally described). /override is a single synchronous
|
|
543
|
+
# action (announce-then-log in one turn, per override.md) with no analogous
|
|
544
|
+
# "still in progress" marker anywhere in the framework, so per CONFIRM-09's
|
|
545
|
+
# own "do not invent new state" constraint it is not tracked here — there is
|
|
546
|
+
# no existing signal to detect it from.
|
|
542
547
|
#
|
|
543
|
-
# #271 C-5: this staleness WARN is presence + age based (marker
|
|
544
|
-
# audit-log write), which
|
|
545
|
-
# session-scoped CLEARING decision for the SAME dev-active
|
|
546
|
-
# consumers ask different questions ("has this sat around
|
|
547
|
-
# matching log activity?" vs. "am I sure enough this
|
|
548
|
-
# right now that I should force-close it?") and
|
|
549
|
-
# the other's answer.
|
|
550
|
-
#
|
|
551
|
-
#
|
|
548
|
+
# #271 C-5 (pre-#437): this staleness WARN is presence + age based (marker
|
|
549
|
+
# mtime vs. an audit-log write), which was unaffected by session-start.py's
|
|
550
|
+
# then-newer session-scoped CLEARING decision for the SAME dev-active
|
|
551
|
+
# marker — the two consumers ask different questions ("has this sat around
|
|
552
|
+
# too long with no matching log activity?" vs. "am I sure enough this
|
|
553
|
+
# belongs to nobody live right now that I should force-close it?") and
|
|
554
|
+
# neither needs to agree with the other's answer. The same reasoning now
|
|
555
|
+
# applies to the mode marker: a non-arbiter entry owned by a still-live
|
|
556
|
+
# different session can legitimately trip THIS warning even though
|
|
557
|
+
# SessionStart correctly declines to clobber a live session's own entry.
|
|
552
558
|
_STALE_FLOWS = (
|
|
553
559
|
# (flow name, marker path parts, expected-log path parts)
|
|
554
|
-
|
|
560
|
+
#
|
|
561
|
+
# #437 (mode-plane-deterministic-flip): the 'dev'/'dev-active' entry this
|
|
562
|
+
# tuple used to carry is RENAMED, not removed — the mode marker is the
|
|
563
|
+
# direct successor of dev-active (Decided parameters: dev-active is not
|
|
564
|
+
# dual-written; every reader migrates to the mode file). Missing this
|
|
565
|
+
# rename is the one hazard this whole registry exists to avoid: it is a
|
|
566
|
+
# WARN, not a gate, so a stale matcher fails PERMANENTLY SILENT with an
|
|
567
|
+
# otherwise green suite — nothing else in the repo would ever notice.
|
|
568
|
+
#
|
|
569
|
+
# #681 moved the target again, from that one file to the per-session entry
|
|
570
|
+
# DIRECTORY, for the same reason: pointed at `mode`, this row would have
|
|
571
|
+
# kept matching a file nothing writes any more and gone silent exactly as
|
|
572
|
+
# the note above warns. This row's marker is therefore a directory, and
|
|
573
|
+
# `_mode_plane_active_since` — not `os.path.isfile` + `getmtime` — answers
|
|
574
|
+
# both "is anything active" and "since when" for it.
|
|
575
|
+
("mode", (".markers", "mode.d"), ("overrides.log",)),
|
|
555
576
|
("sprint", ("sprint-active",), ("sprint-log.md",)),
|
|
556
577
|
)
|
|
557
578
|
|
|
558
579
|
|
|
580
|
+
def _mode_marker_has_non_arbiter_entry(marker):
|
|
581
|
+
"""AC-36: True iff the mode marker JSON names at least one session in a
|
|
582
|
+
non-'arbiter' posture. Presence alone is NOT "active" for this flow the
|
|
583
|
+
way it was for the old boolean dev-active marker — the mode file is a
|
|
584
|
+
persistent `{session_id: mode}` map (#437's State: decided parameter)
|
|
585
|
+
that legitimately keeps existing, with plenty of purely-'arbiter'
|
|
586
|
+
entries, long after every non-arbiter session has flipped back or
|
|
587
|
+
ended. Warning on mere file presence would trip AC-36's negative arm
|
|
588
|
+
(never warn for arbiter) permanently, for every repo that has ever used
|
|
589
|
+
the mode plane at all — exactly the "warns on everything" matcher the
|
|
590
|
+
negative-arm assertion exists to catch.
|
|
591
|
+
|
|
592
|
+
Deliberately duplicates the literal 'arbiter' value here rather than
|
|
593
|
+
importing `_modelib.MODES[0]`: `_modelib` imports `write_text_atomic`
|
|
594
|
+
FROM this module, so importing back the other way would be circular.
|
|
595
|
+
Never raises: an absent/corrupt/malformed marker answers False — the
|
|
596
|
+
same fail-toward-silent convention `staleness_warning`'s own try/except
|
|
597
|
+
already applies to every other stat/read in this loop."""
|
|
598
|
+
try:
|
|
599
|
+
with open(marker, encoding="utf-8") as f:
|
|
600
|
+
data = json.load(f)
|
|
601
|
+
except Exception: # noqa: BLE001 — unreadable/corrupt -> nothing provably active
|
|
602
|
+
return False
|
|
603
|
+
if not isinstance(data, dict):
|
|
604
|
+
return False
|
|
605
|
+
return any(v != "arbiter" for v in data.values())
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
# Worst-case bound on the entry scan below: the all-arbiter case, where the
|
|
609
|
+
# newest-first early exit never fires and every entry would otherwise be read
|
|
610
|
+
# on every prompt. 64 is far above any plausible count of sessions live at once
|
|
611
|
+
# and far below the unbounded total a long-lived repo accumulates. Capping
|
|
612
|
+
# costs only a WARN — a flow older than the 64 most recently touched sessions
|
|
613
|
+
# goes unreported, which this registry's own contract already tolerates
|
|
614
|
+
# ("a missed warn is acceptable; a crash is not", prune-transcript.py).
|
|
615
|
+
_MODE_ENTRY_SCAN_MAX = 64
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _mode_plane_active_since(cad):
|
|
619
|
+
"""(#681) Newest mtime among per-session mode entries in a NON-arbiter
|
|
620
|
+
posture, or None when the mode plane is not active.
|
|
621
|
+
|
|
622
|
+
Replaces the single marker file's stat for this flow. The plane is now a
|
|
623
|
+
directory of one-file-per-session entries (`_modelib.mode_entry_dir`),
|
|
624
|
+
which changes both halves of the question this registry asks:
|
|
625
|
+
|
|
626
|
+
- *Active* is still "some session is non-arbiter", but it is now answered
|
|
627
|
+
per entry rather than over one map's values.
|
|
628
|
+
- *Since when* gets strictly more accurate. The shared map's mtime was
|
|
629
|
+
bumped by ANY session's write, so one arbiter session flipping reset the
|
|
630
|
+
staleness clock for a different session sitting in `dangerous`. An
|
|
631
|
+
entry's own mtime is that session's own last flip.
|
|
632
|
+
|
|
633
|
+
Bounded: entries are examined newest-first and the scan stops at the first
|
|
634
|
+
non-arbiter one (which is by definition the newest), with a hard
|
|
635
|
+
`_MODE_ENTRY_SCAN_MAX` ceiling for the all-arbiter case.
|
|
636
|
+
|
|
637
|
+
Falls back to the pre-split map so a repo upgraded mid-flow keeps warning.
|
|
638
|
+
Never raises: this whole registry is a WARN, and a matcher that stopped
|
|
639
|
+
matching would fail permanently SILENT with a green suite — the exact
|
|
640
|
+
hazard `_STALE_FLOWS`' own comment names.
|
|
641
|
+
|
|
642
|
+
Duplicates the literal 'arbiter' for the same reason
|
|
643
|
+
`_mode_marker_has_non_arbiter_entry` does: `_modelib` imports
|
|
644
|
+
`write_text_atomic` from this module, so importing back would be circular.
|
|
645
|
+
"""
|
|
646
|
+
entries = []
|
|
647
|
+
entry_dir = os.path.join(cad, ".markers", "mode.d")
|
|
648
|
+
try:
|
|
649
|
+
with os.scandir(entry_dir) as it:
|
|
650
|
+
for item in it:
|
|
651
|
+
try:
|
|
652
|
+
entries.append((item.stat().st_mtime, item.path))
|
|
653
|
+
except OSError:
|
|
654
|
+
continue
|
|
655
|
+
except OSError: # no directory yet -> no per-session entries to weigh
|
|
656
|
+
entries = []
|
|
657
|
+
# NEWEST FIRST, and stop at the first non-arbiter entry: that entry IS the
|
|
658
|
+
# newest non-arbiter mtime, so the ordering turns "read every entry" into
|
|
659
|
+
# "usually read one". This runs on the prompt seam — `prune-transcript.py`
|
|
660
|
+
# calls `staleness_warning` on every UserPromptSubmit — and entries are
|
|
661
|
+
# never reaped, so an unordered full scan would make every prompt pay for
|
|
662
|
+
# every session the repo has ever had.
|
|
663
|
+
entries.sort(reverse=True)
|
|
664
|
+
for mtime, path in entries[:_MODE_ENTRY_SCAN_MAX]:
|
|
665
|
+
try:
|
|
666
|
+
with open(path, encoding="utf-8") as f:
|
|
667
|
+
data = json.load(f)
|
|
668
|
+
except Exception: # noqa: BLE001 — unreadable entry proves nothing active
|
|
669
|
+
continue
|
|
670
|
+
if isinstance(data, dict) and data.get("mode") not in (None, "arbiter"):
|
|
671
|
+
return mtime
|
|
672
|
+
legacy = os.path.join(cad, ".markers", "mode")
|
|
673
|
+
if os.path.isfile(legacy) and _mode_marker_has_non_arbiter_entry(legacy):
|
|
674
|
+
try:
|
|
675
|
+
return os.path.getmtime(legacy)
|
|
676
|
+
except OSError:
|
|
677
|
+
return None
|
|
678
|
+
return None
|
|
679
|
+
|
|
680
|
+
|
|
559
681
|
def staleness_warning(root, now=None, window_minutes=30):
|
|
560
682
|
"""(CONFIRM-09) One WARN message per active flow (see _STALE_FLOWS) whose
|
|
561
683
|
marker has existed for at least `window_minutes` with no audit-log
|
|
@@ -573,9 +695,15 @@ def staleness_warning(root, now=None, window_minutes=30):
|
|
|
573
695
|
for name, marker_parts, log_parts in _STALE_FLOWS:
|
|
574
696
|
try:
|
|
575
697
|
marker = os.path.join(cad, *marker_parts)
|
|
576
|
-
if
|
|
577
|
-
|
|
578
|
-
|
|
698
|
+
if name == "mode":
|
|
699
|
+
# AC-36: never warn for arbiter — presence alone isn't "active".
|
|
700
|
+
marker_mtime = _mode_plane_active_since(cad)
|
|
701
|
+
if marker_mtime is None:
|
|
702
|
+
continue
|
|
703
|
+
else:
|
|
704
|
+
if not os.path.isfile(marker):
|
|
705
|
+
continue
|
|
706
|
+
marker_mtime = os.path.getmtime(marker)
|
|
579
707
|
if now - marker_mtime < window_minutes * 60:
|
|
580
708
|
continue # flow started too recently to call it stale yet
|
|
581
709
|
log_path = os.path.join(cad, *log_parts)
|
|
@@ -10,6 +10,11 @@ import hostapi # noqa: E402
|
|
|
10
10
|
|
|
11
11
|
class PiHost(hostapi.Host):
|
|
12
12
|
name = "pi"
|
|
13
|
+
adapter_name = "@arbiterforge/ca-pi"
|
|
14
|
+
adapter_version = "0.10.0"
|
|
15
|
+
update_target = "ca-pi"
|
|
16
|
+
update_tag_prefix = "ca-pi-v"
|
|
17
|
+
update_command = "pi update npm:@arbiterforge/ca-pi"
|
|
13
18
|
command_noun = "command"
|
|
14
19
|
has_statusline = False
|
|
15
20
|
has_read_tool = True
|
|
@@ -40,7 +45,10 @@ class PiHost(hostapi.Host):
|
|
|
40
45
|
return hostapi.git_toplevel() or os.getcwd()
|
|
41
46
|
|
|
42
47
|
def plugin_root(self):
|
|
43
|
-
return
|
|
48
|
+
return hostapi.resolve_plugin_root(
|
|
49
|
+
__file__, adapter_name=self.adapter_name, adapter_version=self.adapter_version,
|
|
50
|
+
manifest_relpath=self.manifest_relpath(), anchor_relpath="hooks/_host.py",
|
|
51
|
+
)
|
|
44
52
|
|
|
45
53
|
def manifest_relpath(self):
|
|
46
54
|
return "package.json"
|
|
@@ -247,6 +247,17 @@ def commit_timeline(root):
|
|
|
247
247
|
import re as _re
|
|
248
248
|
_OVERRIDE_TS_RE = _re.compile(r"^\[([^\]]+)\]")
|
|
249
249
|
|
|
250
|
+
# Mode-transition rows are ledger bookkeeping, not overrides, and must be
|
|
251
|
+
# excluded from override_rate (AC-40). Two forms appear in overrides.log:
|
|
252
|
+
# - new: "MODE: <name> enter" / "MODE: <name> exit"
|
|
253
|
+
# - legacy: "DEV: enter" / "DEV: exit"
|
|
254
|
+
# Both are matched as a pipe-delimited field (bounded by "|" or end-of-line),
|
|
255
|
+
# the same shape as _SMALL_LANE_RE's field match below, so a REASON/NOTE
|
|
256
|
+
# sentence that merely mentions "mode" or "dev" cannot false-positive.
|
|
257
|
+
_MODE_TRANSITION_RE = _re.compile(
|
|
258
|
+
r"\|\s*(?:MODE:\s*\S+\s+(?:enter|exit)|DEV:\s*(?:enter|exit))\s*(?:\||$)"
|
|
259
|
+
)
|
|
260
|
+
|
|
250
261
|
|
|
251
262
|
def override_rate(lines_or_text, windows):
|
|
252
263
|
"""Compute the override rate for the current and prior windows.
|
|
@@ -274,6 +285,10 @@ def override_rate(lines_or_text, windows):
|
|
|
274
285
|
- Lines that start with "#" (after stripping leading whitespace) are
|
|
275
286
|
comment lines and are excluded.
|
|
276
287
|
- Blank and whitespace-only lines are excluded.
|
|
288
|
+
- Mode-transition rows are excluded: the new form
|
|
289
|
+
"MODE: <name> enter|exit" and the legacy form "DEV: enter|exit"
|
|
290
|
+
(AC-40). These are ledger bookkeeping, not overrides — genuine
|
|
291
|
+
"GATE:" / "SECURITY-OVERRIDE" / other override rows still count.
|
|
277
292
|
- Entries whose parsed timestamp maps to BEFORE_HISTORY are excluded
|
|
278
293
|
from all window counts.
|
|
279
294
|
"""
|
|
@@ -300,6 +315,11 @@ def override_rate(lines_or_text, windows):
|
|
|
300
315
|
if not line or line.startswith("#"):
|
|
301
316
|
continue
|
|
302
317
|
|
|
318
|
+
# Skip mode-transition rows (new MODE: and legacy DEV: forms) — see
|
|
319
|
+
# AC-40 and _MODE_TRANSITION_RE above.
|
|
320
|
+
if _MODE_TRANSITION_RE.search(line):
|
|
321
|
+
continue
|
|
322
|
+
|
|
303
323
|
# Extract the leading timestamp token "[<ts>]".
|
|
304
324
|
m = _OVERRIDE_TS_RE.match(line)
|
|
305
325
|
if not m:
|