@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,351 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter — the sanctioned task-board mutator behind /ca:task (resolves D-1).
|
|
3
|
+
#
|
|
4
|
+
# The ONLY blessed way to write .codearbiter/open-tasks.md: add a queued task,
|
|
5
|
+
# start one (flip to [~] + stamp the date, minting a dotted ID on pick-up), or
|
|
6
|
+
# mark an in-progress task done. All board LOGIC is the pure text->text transforms in
|
|
7
|
+
# _taskboardlib; this is the thin I/O wrapper (read board -> transform -> write),
|
|
8
|
+
# the same shape as doctor.py / security-pass.py.
|
|
9
|
+
#
|
|
10
|
+
# Invoked by skill/command prose as (interpreter resolved once by presence,
|
|
11
|
+
# never a `python3 X || python X` fold -- #577):
|
|
12
|
+
# "$PY" "<plugin>/hooks/taskwrite.py" <verb> ...
|
|
13
|
+
#
|
|
14
|
+
# Verbs:
|
|
15
|
+
# add "<desc>" [--from ORIGIN] [--id GROUP.TYPE] [--boundaries a,b]
|
|
16
|
+
# [--desc RATIONALE] [--section "## In-flight"]
|
|
17
|
+
# start <ID-or-"title"> [--as GROUP.TYPE] [--date YYYY-MM-DD]
|
|
18
|
+
# done <ID-or-"title"> [--date YYYY-MM-DD]
|
|
19
|
+
# archive <ID-or-"title"> [--allow-undated] move a done item into
|
|
20
|
+
# done-tasks.md: append THERE first, then remove here
|
|
21
|
+
#
|
|
22
|
+
# Rerun-safe: start/done are no-ops on an already-matching state; a missing target
|
|
23
|
+
# is reported, never a partial write.
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import datetime
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
import tempfile
|
|
30
|
+
|
|
31
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
32
|
+
import hostapi # noqa: E402 — host seam (ADR-0011)
|
|
33
|
+
import _taskboardlib as tb # noqa: E402
|
|
34
|
+
from _hooklib import ( # noqa: E402
|
|
35
|
+
acquire_lock, project_root, release_lock, set_host, utf8_stdio,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Exit 3, deliberately NOT the generic exit 1 every other refusal uses.
|
|
39
|
+
# Exit 1 means "I read the state and the answer is no" (no such task, the
|
|
40
|
+
# item is undated, the lock is held). This means "I could not read the
|
|
41
|
+
# append-only archive at all", and the two must stay distinguishable: a
|
|
42
|
+
# caller retrying on 1 is retrying a decision, a caller seeing 3 has an
|
|
43
|
+
# unreadable file to fix first. Same split as _releaselib's exit 4-vs-3.
|
|
44
|
+
EXIT_ARCHIVE_UNREADABLE = 3
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ArchiveUnreadable(RuntimeError):
|
|
48
|
+
"""`done-tasks.md` exists but could not be read, so no archive may be
|
|
49
|
+
computed from it. Raised INSTEAD of proceeding, because the archive
|
|
50
|
+
write replaces the file wholesale.
|
|
51
|
+
|
|
52
|
+
Deliberately NOT an `OSError` subclass, even though an `OSError` is
|
|
53
|
+
what triggers it. This exception exists precisely because someone
|
|
54
|
+
treated a failed read as an empty file; making it an `OSError` would
|
|
55
|
+
let the next `except OSError: text = ""` swallow it and reintroduce
|
|
56
|
+
the same data loss one level up, invisibly. `RuntimeError` also
|
|
57
|
+
matches `_releaselib`'s convention for its declared-file errors.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
# reliability-007 (#190): project_root() is now _hooklib.project_root —
|
|
61
|
+
# imported above, not a local copy. The prior local copy ran `git rev-parse
|
|
62
|
+
# --show-toplevel` from the hook's own cwd and fell back to os.getcwd(),
|
|
63
|
+
# skipping the CLAUDE_PROJECT_DIR-first read _hooklib.project_root() exists
|
|
64
|
+
# for; taskwrite mutates .codearbiter/open-tasks.md, so a wrong root silently
|
|
65
|
+
# wrote into the wrong repository's board.
|
|
66
|
+
#
|
|
67
|
+
# #271 C-2/C-3 — lock + re-read-under-lock CAS (spec D-4). taskwrite is the
|
|
68
|
+
# only *programmatic* board mutator, but it is NOT the only writer: the
|
|
69
|
+
# harvest/decompose paths mutate open-tasks.md directly via the host's own
|
|
70
|
+
# Edit/Write tool and will never take this lock (ADR-0008 tolerates that
|
|
71
|
+
# out-of-band path deliberately). A lock alone would therefore still let an
|
|
72
|
+
# interleaved external Edit get silently overwritten by a stale in-memory
|
|
73
|
+
# snapshot, AND would still let two lock-serialized taskwrite calls mint the
|
|
74
|
+
# SAME dotted id if each computed next_seq() against the text it opened with
|
|
75
|
+
# rather than the text current at write time. Taking the lock and THEN
|
|
76
|
+
# reading the board — re-running the pure transform against that fresh read —
|
|
77
|
+
# fixes both: an interleaved external edit is preserved (detected-loss instead
|
|
78
|
+
# of silent clobber), and next_seq() always runs against current text.
|
|
79
|
+
#
|
|
80
|
+
# Fail-soft choice: _ledgerlib's convention is to silently no-op when the lock
|
|
81
|
+
# handle is None (a statusline render is disposable — nobody is worse off if
|
|
82
|
+
# ONE render skips the ledger write). A board mutation is not disposable: it
|
|
83
|
+
# is the single user-visible effect of the whole `taskwrite` invocation, and
|
|
84
|
+
# /ca:task has no other way to report "your task was recorded." Silently
|
|
85
|
+
# proceeding unlocked on contention would silently reintroduce the exact race
|
|
86
|
+
# this fix exists to close (two writers both read-modify-write with no
|
|
87
|
+
# serialization at all). So on a None lock handle, taskwrite refuses to write
|
|
88
|
+
# and exits nonzero with a clear stderr message — the caller (a skill/command)
|
|
89
|
+
# sees a failed exit code and can retry, rather than a false "added"/"marked"
|
|
90
|
+
# success hiding a lost or colliding write.
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _date(s):
|
|
94
|
+
"""Parse a YYYY-MM-DD date, today when absent, or None when malformed."""
|
|
95
|
+
if not s:
|
|
96
|
+
return datetime.date.today()
|
|
97
|
+
try:
|
|
98
|
+
return datetime.datetime.strptime(s, "%Y-%m-%d").date()
|
|
99
|
+
except ValueError:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _parse_gid(gid, option="--id"):
|
|
104
|
+
"""Split a ``GROUP.TYPE`` value, or None with an error message on a bad
|
|
105
|
+
spelling. Pure — no I/O — so it can run before OR after the lock is held."""
|
|
106
|
+
parts = gid.split(".")
|
|
107
|
+
if (len(parts) != 2 or not all(parts)
|
|
108
|
+
or not tb.validate_id(f"{gid}.0000")):
|
|
109
|
+
return None, None, (f"bad {option} '{gid}' (expected GROUP.TYPE, e.g. 'mvp1.store'; "
|
|
110
|
+
f"the 4-digit seq is minted automatically)")
|
|
111
|
+
return parts[0], parts[1], None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _atomic_write(path, text, prefix):
|
|
115
|
+
"""Write `text` to `path` atomically: sibling temp file, then
|
|
116
|
+
`os.replace()`. Atomic on POSIX and a same-volume rename on Windows,
|
|
117
|
+
so a crash between open() and write() never leaves a truncated board.
|
|
118
|
+
|
|
119
|
+
Extracted so the archival sweep's TWO writes (done-tasks, then
|
|
120
|
+
open-tasks) share one implementation. Two hand-rolled copies of an
|
|
121
|
+
atomic write is how one of them quietly stops being atomic.
|
|
122
|
+
"""
|
|
123
|
+
directory = os.path.dirname(path)
|
|
124
|
+
os.makedirs(directory, exist_ok=True)
|
|
125
|
+
fd, tmp_path = tempfile.mkstemp(dir=directory, suffix=".tmp", prefix=prefix)
|
|
126
|
+
try:
|
|
127
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
|
128
|
+
handle.write(text)
|
|
129
|
+
os.replace(tmp_path, path)
|
|
130
|
+
except Exception:
|
|
131
|
+
try:
|
|
132
|
+
os.remove(tmp_path)
|
|
133
|
+
except OSError:
|
|
134
|
+
pass
|
|
135
|
+
raise
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _archive(text, args, root):
|
|
139
|
+
"""One archived item: `(new_open_text, action_or_error, wrote_done)`.
|
|
140
|
+
|
|
141
|
+
ORDER IS THE WHOLE POINT (B-20/B-22). done-tasks.md is written FIRST,
|
|
142
|
+
inside the same lock, and only then is open-tasks.md rewritten without
|
|
143
|
+
the item. An interruption between the two leaves the record in BOTH
|
|
144
|
+
files -- recoverable, and absorbed by the next run's dedup. The
|
|
145
|
+
reverse order loses the record outright, and batching all appends
|
|
146
|
+
before all removals duplicates every item in the batch.
|
|
147
|
+
"""
|
|
148
|
+
# Parse the board ONCE, as a whole. The per-line form this replaces
|
|
149
|
+
# (`parse_board(line) for line in text.splitlines()`) handed every Task a
|
|
150
|
+
# `lineno` of 1 and no sub-fields, because a single line has no context:
|
|
151
|
+
# `task_block`'s index-based location could therefore never match and
|
|
152
|
+
# silently fell through to its first-matching-line fallback on every
|
|
153
|
+
# archive. The outcome happened to be right; the mechanism was dead, and
|
|
154
|
+
# dead code that reads as load-bearing is how the NEXT change breaks it.
|
|
155
|
+
parsed = tb.parse_board(text)
|
|
156
|
+
target = args.target
|
|
157
|
+
matches = [t for t in parsed
|
|
158
|
+
if t.state == "done" and (t.id == target or t.title == target)]
|
|
159
|
+
if not matches:
|
|
160
|
+
return None, (f"no done task matching {target!r} — archive only moves "
|
|
161
|
+
f"items already marked done"), False
|
|
162
|
+
task = matches[0]
|
|
163
|
+
if task.done is None and not args.allow_undated:
|
|
164
|
+
return None, (f"{target!r} is marked done but carries no (done DATE) "
|
|
165
|
+
f"stamp, so it cannot be aged. Both `taskwrite done` and "
|
|
166
|
+
f"the board classifier require the stamp, so this is a "
|
|
167
|
+
f"legacy or override-era entry — pass --allow-undated to "
|
|
168
|
+
f"archive it deliberately"), False
|
|
169
|
+
|
|
170
|
+
done_path = os.path.join(root, ".codearbiter", "done-tasks.md")
|
|
171
|
+
# ABSENT and UNREADABLE are not the same answer, and folding them
|
|
172
|
+
# together destroys the archive (workstream-B adversary HIGH-4). The
|
|
173
|
+
# write below REPLACES done-tasks.md wholesale, so treating a failed
|
|
174
|
+
# read as "" seeds a fresh heading and drops every historical record
|
|
175
|
+
# -- with rc=0 and the word "archived". A transient OSError is enough:
|
|
176
|
+
# an editor or antivirus holding the file, a deny-read ACL, a failing
|
|
177
|
+
# volume. `append-only` exists precisely to make that record
|
|
178
|
+
# permanent, so an unreadable archive REFUSES rather than guesses.
|
|
179
|
+
try:
|
|
180
|
+
with open(done_path, encoding="utf-8") as handle:
|
|
181
|
+
done_text = handle.read()
|
|
182
|
+
except FileNotFoundError:
|
|
183
|
+
done_text = "" # genuinely absent — seeding one is correct
|
|
184
|
+
except OSError as exc:
|
|
185
|
+
raise ArchiveUnreadable(
|
|
186
|
+
f"cannot read {done_path}: {exc}. Refusing to archive: this "
|
|
187
|
+
f"file is append-only and rewriting it from an unread state "
|
|
188
|
+
f"would discard every record it already holds. Nothing was "
|
|
189
|
+
f"written. Fix the read (close whatever holds the file, or "
|
|
190
|
+
f"restore its permissions) and retry.")
|
|
191
|
+
|
|
192
|
+
new_open, new_done = tb.archive_transform(text, done_text, task)
|
|
193
|
+
if new_open == text and new_done == done_text:
|
|
194
|
+
return None, f"nothing to archive for {target!r}", False
|
|
195
|
+
|
|
196
|
+
# done FIRST, then the caller writes open.
|
|
197
|
+
_atomic_write(done_path, new_done, "done-tasks.")
|
|
198
|
+
return new_open, f"archived: {task.id or task.title}", True
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _apply(args, text):
|
|
202
|
+
"""Pure text -> (new_text, action) | (None, error_msg) transform against
|
|
203
|
+
the board text the caller hands in. Deliberately takes NO lock and does NO
|
|
204
|
+
I/O itself — the caller (main()) re-runs this against a FRESH read taken
|
|
205
|
+
INSIDE the lock (#271 C-3/D-4), so next_seq()'s duplicate-id mint and any
|
|
206
|
+
interleaved external Edit are both resolved against current text, never a
|
|
207
|
+
stale snapshot."""
|
|
208
|
+
if args.verb == "add":
|
|
209
|
+
boundaries = ([b.strip() for b in args.boundaries.split(",")]
|
|
210
|
+
if args.boundaries is not None else None)
|
|
211
|
+
err = tb.add_error(desc=args.desc, origin=args.origin,
|
|
212
|
+
boundaries=boundaries, section=args.section,
|
|
213
|
+
rationale=args.rationale)
|
|
214
|
+
if err:
|
|
215
|
+
return None, err
|
|
216
|
+
group = typ = None
|
|
217
|
+
if args.gid is not None:
|
|
218
|
+
group, typ, err = _parse_gid(args.gid)
|
|
219
|
+
if err:
|
|
220
|
+
return None, err
|
|
221
|
+
new = tb.add_entry(text, desc=args.desc, origin=args.origin, group=group,
|
|
222
|
+
type=typ, boundaries=boundaries, section=args.section,
|
|
223
|
+
rationale=args.rationale)
|
|
224
|
+
return new, f"added queued task: {args.desc}"
|
|
225
|
+
|
|
226
|
+
# start / done
|
|
227
|
+
state = "in_progress" if args.verb == "start" else "done"
|
|
228
|
+
day = _date(args.date)
|
|
229
|
+
if day is None:
|
|
230
|
+
return None, f"bad --date '{args.date}' (expected YYYY-MM-DD)"
|
|
231
|
+
assign = getattr(args, "assign", None)
|
|
232
|
+
if assign is not None:
|
|
233
|
+
_, _, err = _parse_gid(assign, option="--as")
|
|
234
|
+
if err:
|
|
235
|
+
return None, err
|
|
236
|
+
transition_error = tb.transition_error(text, args.target, state)
|
|
237
|
+
if transition_error:
|
|
238
|
+
return None, transition_error
|
|
239
|
+
new = tb.set_state(text, args.target, state, day, assign=assign)
|
|
240
|
+
if new == text:
|
|
241
|
+
return None, f"no change: '{args.target}' not found or already {state}"
|
|
242
|
+
return new, f"marked {state}: {args.target}"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def main(argv=None):
|
|
246
|
+
utf8_stdio()
|
|
247
|
+
p = argparse.ArgumentParser(prog="taskwrite", add_help=True)
|
|
248
|
+
sub = p.add_subparsers(dest="verb", required=True)
|
|
249
|
+
|
|
250
|
+
pa = sub.add_parser("add")
|
|
251
|
+
pa.add_argument("desc")
|
|
252
|
+
pa.add_argument("--from", dest="origin", default=None)
|
|
253
|
+
pa.add_argument("--id", dest="gid", default=None, help="GROUP.TYPE to mint a dotted ID")
|
|
254
|
+
pa.add_argument("--boundaries", default=None)
|
|
255
|
+
pa.add_argument("--desc", dest="rationale", default=None,
|
|
256
|
+
help="rationale, emitted as an indented `- Desc:` sub-bullet")
|
|
257
|
+
pa.add_argument("--section", default="## In-flight")
|
|
258
|
+
|
|
259
|
+
ps = sub.add_parser("start")
|
|
260
|
+
ps.add_argument("target")
|
|
261
|
+
ps.add_argument("--as", dest="assign", default=None, help="GROUP.TYPE to mint on pick-up")
|
|
262
|
+
ps.add_argument("--date", default=None)
|
|
263
|
+
|
|
264
|
+
pd = sub.add_parser("done")
|
|
265
|
+
pd.add_argument("target")
|
|
266
|
+
pd.add_argument("--date", default=None)
|
|
267
|
+
|
|
268
|
+
par = sub.add_parser("archive")
|
|
269
|
+
par.add_argument("target")
|
|
270
|
+
par.add_argument("--allow-undated", dest="allow_undated", action="store_true",
|
|
271
|
+
help="archive a done item carrying no (done DATE) stamp; "
|
|
272
|
+
"requires deliberate per-item confirmation")
|
|
273
|
+
|
|
274
|
+
args = p.parse_args(argv)
|
|
275
|
+
|
|
276
|
+
root = project_root()
|
|
277
|
+
board_path = os.path.join(root, ".codearbiter", "open-tasks.md")
|
|
278
|
+
|
|
279
|
+
# An uninitialized repo (no board at all) is a static precondition, not
|
|
280
|
+
# part of the race this fix closes — check it BEFORE touching the lock so
|
|
281
|
+
# a run against an uninitialized repo creates no side effect (no
|
|
282
|
+
# .codearbiter/ dir, no lock sidecar file), exactly as before #271.
|
|
283
|
+
if not os.path.isfile(board_path):
|
|
284
|
+
print(f"no board at {board_path} — is this an initialized repo?", file=sys.stderr)
|
|
285
|
+
return 1
|
|
286
|
+
|
|
287
|
+
# #271 C-3 (spec D-4): take the lock FIRST, THEN read the board — everything
|
|
288
|
+
# from here to the write is inside the critical section, so the read this
|
|
289
|
+
# transaction acts on is guaranteed fresh relative to any other
|
|
290
|
+
# lock-taking writer. Fail-soft nuance: unlike _ledgerlib's disposable
|
|
291
|
+
# statusline write, a board mutation must never be silently dropped on
|
|
292
|
+
# contention — refuse and exit nonzero instead (see module docstring).
|
|
293
|
+
lock = acquire_lock(board_path)
|
|
294
|
+
if lock is None:
|
|
295
|
+
print(f"could not acquire the task-board lock ({board_path}.lock) — "
|
|
296
|
+
f"another writer holds it; no changes were written, retry the "
|
|
297
|
+
f"{args.verb}", file=sys.stderr)
|
|
298
|
+
return 1
|
|
299
|
+
try:
|
|
300
|
+
text = tb.read_board(board_path)
|
|
301
|
+
if text is None:
|
|
302
|
+
print(f"no board at {board_path} — is this an initialized repo?", file=sys.stderr)
|
|
303
|
+
return 1
|
|
304
|
+
|
|
305
|
+
if args.verb == "archive":
|
|
306
|
+
# done-tasks.md is written INSIDE `_archive`, before this
|
|
307
|
+
# function returns the new open-tasks text — the ordering
|
|
308
|
+
# B-20/B-22 turn on. See `_archive`'s docstring.
|
|
309
|
+
#
|
|
310
|
+
# An unreadable archive aborts HERE, before either write: the
|
|
311
|
+
# done-tasks write lives inside `_archive` and is reached only
|
|
312
|
+
# after the read succeeds, and open-tasks is written below. So
|
|
313
|
+
# this exit leaves BOTH files exactly as they were.
|
|
314
|
+
try:
|
|
315
|
+
new, action_or_err, _wrote_done = _archive(text, args, root)
|
|
316
|
+
except ArchiveUnreadable as exc:
|
|
317
|
+
print(str(exc), file=sys.stderr)
|
|
318
|
+
return EXIT_ARCHIVE_UNREADABLE
|
|
319
|
+
else:
|
|
320
|
+
new, action_or_err = _apply(args, text)
|
|
321
|
+
if new is None:
|
|
322
|
+
print(action_or_err, file=sys.stderr)
|
|
323
|
+
return 1
|
|
324
|
+
action = action_or_err
|
|
325
|
+
|
|
326
|
+
# Atomic write via the shared helper — same sibling-temp-file plus
|
|
327
|
+
# os.replace() as before, now in one place so the archival sweep's
|
|
328
|
+
# done-tasks write cannot drift from this one.
|
|
329
|
+
_atomic_write(board_path, new, "open-tasks.")
|
|
330
|
+
finally:
|
|
331
|
+
release_lock(lock)
|
|
332
|
+
print(action)
|
|
333
|
+
return 0
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def run(host, argv=None):
|
|
337
|
+
"""Host-seam entry point (ADR-0011): the __main__ guard calls this with the
|
|
338
|
+
plugin's loaded Host. Delegates to main(argv) and returns its exit code,
|
|
339
|
+
exactly as the old `sys.exit(main())` guard propagated it.
|
|
340
|
+
|
|
341
|
+
Wires `host` live (#257): primes `_hooklib`'s process-cached Host via
|
|
342
|
+
`set_host()` BEFORE main() runs, so any `get_host()` call downstream
|
|
343
|
+
resolves to the SAME instance the caller passed here — no second
|
|
344
|
+
`hostapi.load_host()`, and `run(fake_host)` genuinely exercises
|
|
345
|
+
`fake_host`."""
|
|
346
|
+
set_host(host)
|
|
347
|
+
return main(argv)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
if __name__ == "__main__":
|
|
351
|
+
sys.exit(run(hostapi.load_host()) or 0)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter — off-hot-path update-check refresh (AC-3/AC-4).
|
|
3
|
+
#
|
|
4
|
+
# Thin entry point. session-start.py spawns this DETACHED so the GitHub
|
|
5
|
+
# Releases fetch never blocks SessionStart's stdout injection (issue #194's
|
|
6
|
+
# constraint): the parent hook returns immediately without ever awaiting this
|
|
7
|
+
# process. This script may be launched once per session, but the network call
|
|
8
|
+
# inside _updatelib.refresh_if_stale() is itself gated to AT MOST once per day
|
|
9
|
+
# by the cached `checked_at` — a same-day re-launch is a single cheap file
|
|
10
|
+
# read, not a fetch.
|
|
11
|
+
#
|
|
12
|
+
# Fail-silent by construction (mirrors _updatelib.refresh_if_stale): any error
|
|
13
|
+
# here — missing plugin dir, filesystem, network — degrades to "cache
|
|
14
|
+
# unchanged", never a traceback. The spawner also discards this process's
|
|
15
|
+
# stdout/stderr, so even an unswallowed print would go nowhere; the try/except
|
|
16
|
+
# below exists so the process still exits 0 either way.
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
22
|
+
import hostapi # noqa: E402 — host seam (ADR-0011)
|
|
23
|
+
import _hooklib # noqa: E402 — set_host DI seam (#257)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main():
|
|
27
|
+
try:
|
|
28
|
+
import _updatelib
|
|
29
|
+
_updatelib.refresh_if_stale()
|
|
30
|
+
except Exception: # noqa: BLE001 — detached refresh must never surface an error
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def run(host, argv=None):
|
|
35
|
+
"""Host-seam entry point (ADR-0011): the __main__ guard calls this with the
|
|
36
|
+
plugin's loaded Host. Wraps main() unchanged — the refresh stays fail-silent
|
|
37
|
+
and the process still exits 0 either way, exactly as the prior module-level
|
|
38
|
+
try/except did.
|
|
39
|
+
|
|
40
|
+
Wires `host` live (#257): primes `_hooklib`'s process-cached Host via
|
|
41
|
+
`set_host()` BEFORE main() runs, so any `_updatelib` call that resolves a
|
|
42
|
+
host via `_hooklib.get_host()` gets the SAME instance the caller passed
|
|
43
|
+
here — no second `hostapi.load_host()`, and `run(fake_host)` genuinely
|
|
44
|
+
exercises `fake_host`."""
|
|
45
|
+
_hooklib.set_host(host)
|
|
46
|
+
main()
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
sys.exit(run(hostapi.load_host()) or 0)
|