@arbiterforge/ca-pi 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +558 -0
  3. package/package.json +35 -0
  4. package/plugins/ca-pi/CHANGELOG.md +1030 -0
  5. package/plugins/ca-pi/COMMANDS.md +90 -0
  6. package/plugins/ca-pi/ORCHESTRATOR.md +159 -0
  7. package/plugins/ca-pi/SKILLS.md +47 -0
  8. package/plugins/ca-pi/SPRINT.md +142 -0
  9. package/plugins/ca-pi/agents/INDEX.md +31 -0
  10. package/plugins/ca-pi/agents/architecture-drift-reviewer.md +86 -0
  11. package/plugins/ca-pi/agents/auth-crypto-reviewer.md +60 -0
  12. package/plugins/ca-pi/agents/backend-author.md +60 -0
  13. package/plugins/ca-pi/agents/checkpoint-aggregator.md +111 -0
  14. package/plugins/ca-pi/agents/coverage-auditor.md +71 -0
  15. package/plugins/ca-pi/agents/decision-challenger.md +116 -0
  16. package/plugins/ca-pi/agents/dependency-reviewer.md +79 -0
  17. package/plugins/ca-pi/agents/design-quality-reviewer.md +80 -0
  18. package/plugins/ca-pi/agents/finding-triage.md +86 -0
  19. package/plugins/ca-pi/agents/frontend-author.md +64 -0
  20. package/plugins/ca-pi/agents/grader.md +173 -0
  21. package/plugins/ca-pi/agents/infra-author.md +64 -0
  22. package/plugins/ca-pi/agents/map-deps.md +35 -0
  23. package/plugins/ca-pi/agents/map-structure.md +37 -0
  24. package/plugins/ca-pi/agents/migration-reviewer.md +65 -0
  25. package/plugins/ca-pi/agents/scout.md +127 -0
  26. package/plugins/ca-pi/agents/security-reviewer.md +72 -0
  27. package/plugins/ca-pi/agents/tribunal-lens-reviewer.md +65 -0
  28. package/plugins/ca-pi/extensions/codearbiter-child.js +1885 -0
  29. package/plugins/ca-pi/extensions/codearbiter.js +9802 -0
  30. package/plugins/ca-pi/generated/command-catalog.json +197 -0
  31. package/plugins/ca-pi/generated/roles.json +213 -0
  32. package/plugins/ca-pi/helpers/windows-supervisor.js +205 -0
  33. package/plugins/ca-pi/hooks/_activationlib.py +196 -0
  34. package/plugins/ca-pi/hooks/_arbiterstatelib.py +208 -0
  35. package/plugins/ca-pi/hooks/_babysitlib.py +76 -0
  36. package/plugins/ca-pi/hooks/_bashguardlib.py +1667 -0
  37. package/plugins/ca-pi/hooks/_boxlib.py +131 -0
  38. package/plugins/ca-pi/hooks/_colorlib.py +304 -0
  39. package/plugins/ca-pi/hooks/_durabilitylib.py +186 -0
  40. package/plugins/ca-pi/hooks/_entrylib.py +41 -0
  41. package/plugins/ca-pi/hooks/_fmtlib.py +161 -0
  42. package/plugins/ca-pi/hooks/_gitexec.py +45 -0
  43. package/plugins/ca-pi/hooks/_githooks.py +920 -0
  44. package/plugins/ca-pi/hooks/_gitlib.py +110 -0
  45. package/plugins/ca-pi/hooks/_hooklib.py +595 -0
  46. package/plugins/ca-pi/hooks/_host.py +115 -0
  47. package/plugins/ca-pi/hooks/_intentlib.py +242 -0
  48. package/plugins/ca-pi/hooks/_ledgerlib.py +1035 -0
  49. package/plugins/ca-pi/hooks/_metricslib.py +709 -0
  50. package/plugins/ca-pi/hooks/_pathnorm.py +74 -0
  51. package/plugins/ca-pi/hooks/_planfilelib.py +664 -0
  52. package/plugins/ca-pi/hooks/_previewlib.py +193 -0
  53. package/plugins/ca-pi/hooks/_protectedlib.py +312 -0
  54. package/plugins/ca-pi/hooks/_protectedstatelib.py +411 -0
  55. package/plugins/ca-pi/hooks/_provenancelib.py +971 -0
  56. package/plugins/ca-pi/hooks/_prunelib.py +1398 -0
  57. package/plugins/ca-pi/hooks/_prunepolicy.py +235 -0
  58. package/plugins/ca-pi/hooks/_readinjectlib.py +1080 -0
  59. package/plugins/ca-pi/hooks/_releaselib.py +2657 -0
  60. package/plugins/ca-pi/hooks/_scopelib.py +262 -0
  61. package/plugins/ca-pi/hooks/_segmentslib.py +278 -0
  62. package/plugins/ca-pi/hooks/_sensitivelib.py +270 -0
  63. package/plugins/ca-pi/hooks/_sessionlib.py +78 -0
  64. package/plugins/ca-pi/hooks/_sloplib.py +244 -0
  65. package/plugins/ca-pi/hooks/_standuplib.py +214 -0
  66. package/plugins/ca-pi/hooks/_subagentslib.py +219 -0
  67. package/plugins/ca-pi/hooks/_taskboardlib.py +1088 -0
  68. package/plugins/ca-pi/hooks/_updatelib.py +278 -0
  69. package/plugins/ca-pi/hooks/babysit.py +47 -0
  70. package/plugins/ca-pi/hooks/boardsync.py +129 -0
  71. package/plugins/ca-pi/hooks/doctor.py +420 -0
  72. package/plugins/ca-pi/hooks/git-enforce.py +325 -0
  73. package/plugins/ca-pi/hooks/hostapi.py +460 -0
  74. package/plugins/ca-pi/hooks/init-codearbiter.py +225 -0
  75. package/plugins/ca-pi/hooks/metrics.py +62 -0
  76. package/plugins/ca-pi/hooks/migration-pass.py +129 -0
  77. package/plugins/ca-pi/hooks/pi-bridge.py +543 -0
  78. package/plugins/ca-pi/hooks/post-write-edit.py +231 -0
  79. package/plugins/ca-pi/hooks/pre-bash.py +90 -0
  80. package/plugins/ca-pi/hooks/pre-edit.py +284 -0
  81. package/plugins/ca-pi/hooks/pre-read.py +81 -0
  82. package/plugins/ca-pi/hooks/pre-write.py +217 -0
  83. package/plugins/ca-pi/hooks/preview.py +69 -0
  84. package/plugins/ca-pi/hooks/prune-transcript.py +232 -0
  85. package/plugins/ca-pi/hooks/releasehash.py +216 -0
  86. package/plugins/ca-pi/hooks/security-pass.py +139 -0
  87. package/plugins/ca-pi/hooks/session-start.py +1218 -0
  88. package/plugins/ca-pi/hooks/statusline.py +736 -0
  89. package/plugins/ca-pi/hooks/taskwrite.py +351 -0
  90. package/plugins/ca-pi/hooks/update-refresh.py +51 -0
  91. package/plugins/ca-pi/hooks/wire-statusline.py +435 -0
  92. package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +55 -0
  93. package/plugins/ca-pi/includes/anti-slop-design/color.md +43 -0
  94. package/plugins/ca-pi/includes/anti-slop-design/core.md +244 -0
  95. package/plugins/ca-pi/includes/anti-slop-design/images.md +32 -0
  96. package/plugins/ca-pi/includes/anti-slop-design/layout.md +45 -0
  97. package/plugins/ca-pi/includes/anti-slop-design/medium-cli.md +39 -0
  98. package/plugins/ca-pi/includes/anti-slop-design/medium-dataviz.md +43 -0
  99. package/plugins/ca-pi/includes/anti-slop-design/medium-diagram.md +35 -0
  100. package/plugins/ca-pi/includes/anti-slop-design/medium-documents.md +70 -0
  101. package/plugins/ca-pi/includes/anti-slop-design/medium-slides.md +30 -0
  102. package/plugins/ca-pi/includes/anti-slop-design/medium-web.md +39 -0
  103. package/plugins/ca-pi/includes/anti-slop-design/typography.md +51 -0
  104. package/plugins/ca-pi/includes/author-tdd-workflow.md +14 -0
  105. package/plugins/ca-pi/includes/compaction-charter.md +16 -0
  106. package/plugins/ca-pi/includes/cut-docs.md +16 -0
  107. package/plugins/ca-pi/includes/dev-mode.md +30 -0
  108. package/plugins/ca-pi/includes/farm.md +237 -0
  109. package/plugins/ca-pi/includes/fresh-verification.md +14 -0
  110. package/plugins/ca-pi/includes/harvest.md +69 -0
  111. package/plugins/ca-pi/includes/maturity-coverage.md +102 -0
  112. package/plugins/ca-pi/includes/pi-host-notes.md +69 -0
  113. package/plugins/ca-pi/includes/redirect.md +69 -0
  114. package/plugins/ca-pi/includes/reference-map.md +22 -0
  115. package/plugins/ca-pi/includes/review-matrix.md +14 -0
  116. package/plugins/ca-pi/includes/reviewer-contract.md +53 -0
  117. package/plugins/ca-pi/includes/routing-table.md +47 -0
  118. package/plugins/ca-pi/includes/security-gate-record.md +22 -0
  119. package/plugins/ca-pi/includes/smarts/core.md +90 -0
  120. package/plugins/ca-pi/includes/smarts/decision-log-format.md +56 -0
  121. package/plugins/ca-pi/routines/INDEX.md +32 -0
  122. package/plugins/ca-pi/routines/brainstorming/SKILL.md +122 -0
  123. package/plugins/ca-pi/routines/commit-gate/SKILL.md +151 -0
  124. package/plugins/ca-pi/routines/context-check/SKILL.md +85 -0
  125. package/plugins/ca-pi/routines/context-creation/SKILL.md +171 -0
  126. package/plugins/ca-pi/routines/crypto-compliance/SKILL.md +41 -0
  127. package/plugins/ca-pi/routines/debug/SKILL.md +99 -0
  128. package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +104 -0
  129. package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +74 -0
  130. package/plugins/ca-pi/routines/decision-variance/SKILL.md +147 -0
  131. package/plugins/ca-pi/routines/decompose/SKILL.md +168 -0
  132. package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +76 -0
  133. package/plugins/ca-pi/routines/executing-plans/SKILL.md +83 -0
  134. package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +91 -0
  135. package/plugins/ca-pi/routines/post-merge-cleanup/SKILL.md +233 -0
  136. package/plugins/ca-pi/routines/refactor/SKILL.md +91 -0
  137. package/plugins/ca-pi/routines/release/SKILL.md +315 -0
  138. package/plugins/ca-pi/routines/secret-handling/SKILL.md +67 -0
  139. package/plugins/ca-pi/routines/security-architecture/SKILL.md +63 -0
  140. package/plugins/ca-pi/routines/skill-author/SKILL.md +108 -0
  141. package/plugins/ca-pi/routines/skill-author/references/skill-template.md +58 -0
  142. package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +149 -0
  143. package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +145 -0
  144. package/plugins/ca-pi/routines/tdd/SKILL.md +139 -0
  145. package/plugins/ca-pi/routines/tribunal/SKILL.md +109 -0
  146. package/plugins/ca-pi/routines/tribunal/references/ai-markers.md +29 -0
  147. package/plugins/ca-pi/routines/tribunal/references/cost-and-models.md +64 -0
  148. package/plugins/ca-pi/routines/tribunal/references/finding-record.md +27 -0
  149. package/plugins/ca-pi/routines/tribunal/references/issue-filing.md +47 -0
  150. package/plugins/ca-pi/routines/tribunal/references/lenses/appsec.md +22 -0
  151. package/plugins/ca-pi/routines/tribunal/references/lenses/architecture.md +23 -0
  152. package/plugins/ca-pi/routines/tribunal/references/lenses/coverage.md +20 -0
  153. package/plugins/ca-pi/routines/tribunal/references/lenses/infra.md +24 -0
  154. package/plugins/ca-pi/routines/tribunal/references/lenses/migration.md +22 -0
  155. package/plugins/ca-pi/routines/tribunal/references/lenses/observability.md +21 -0
  156. package/plugins/ca-pi/routines/tribunal/references/lenses/performance.md +22 -0
  157. package/plugins/ca-pi/routines/tribunal/references/lenses/reliability.md +23 -0
  158. package/plugins/ca-pi/routines/tribunal/references/lenses/secrets-supply.md +22 -0
  159. package/plugins/ca-pi/routines/tribunal/references/lenses/test-fidelity.md +24 -0
  160. package/plugins/ca-pi/routines/tribunal/references/lenses/typesafety.md +21 -0
  161. package/plugins/ca-pi/routines/tribunal/references/report.md +19 -0
  162. package/plugins/ca-pi/routines/tribunal/references/schemas.md +58 -0
  163. package/plugins/ca-pi/routines/tribunal/references/telemetry.md +28 -0
  164. package/plugins/ca-pi/routines/tribunal/references/triage.md +53 -0
  165. package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +85 -0
  166. package/plugins/ca-pi/routines/writing-plans/SKILL.md +129 -0
  167. package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +50 -0
  168. package/plugins/ca-pi/skills/ca-add-dep/SKILL.md +88 -0
  169. package/plugins/ca-pi/skills/ca-adr/SKILL.md +30 -0
  170. package/plugins/ca-pi/skills/ca-adr-status/SKILL.md +30 -0
  171. package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +36 -0
  172. package/plugins/ca-pi/skills/ca-audit/SKILL.md +51 -0
  173. package/plugins/ca-pi/skills/ca-btw/SKILL.md +23 -0
  174. package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +50 -0
  175. package/plugins/ca-pi/skills/ca-chore/SKILL.md +58 -0
  176. package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +55 -0
  177. package/plugins/ca-pi/skills/ca-commands/SKILL.md +21 -0
  178. package/plugins/ca-pi/skills/ca-commit/SKILL.md +27 -0
  179. package/plugins/ca-pi/skills/ca-conflict/SKILL.md +61 -0
  180. package/plugins/ca-pi/skills/ca-context-check/SKILL.md +32 -0
  181. package/plugins/ca-pi/skills/ca-create-context/SKILL.md +32 -0
  182. package/plugins/ca-pi/skills/ca-debug/SKILL.md +42 -0
  183. package/plugins/ca-pi/skills/ca-decompose/SKILL.md +30 -0
  184. package/plugins/ca-pi/skills/ca-dev/SKILL.md +42 -0
  185. package/plugins/ca-pi/skills/ca-doctor/SKILL.md +44 -0
  186. package/plugins/ca-pi/skills/ca-feature/SKILL.md +105 -0
  187. package/plugins/ca-pi/skills/ca-fix/SKILL.md +42 -0
  188. package/plugins/ca-pi/skills/ca-init/SKILL.md +56 -0
  189. package/plugins/ca-pi/skills/ca-metrics/SKILL.md +80 -0
  190. package/plugins/ca-pi/skills/ca-new-skill/SKILL.md +34 -0
  191. package/plugins/ca-pi/skills/ca-override/SKILL.md +72 -0
  192. package/plugins/ca-pi/skills/ca-pr/SKILL.md +61 -0
  193. package/plugins/ca-pi/skills/ca-preview/SKILL.md +86 -0
  194. package/plugins/ca-pi/skills/ca-prune/SKILL.md +100 -0
  195. package/plugins/ca-pi/skills/ca-reconcile/SKILL.md +43 -0
  196. package/plugins/ca-pi/skills/ca-refactor/SKILL.md +43 -0
  197. package/plugins/ca-pi/skills/ca-release/SKILL.md +57 -0
  198. package/plugins/ca-pi/skills/ca-review/SKILL.md +63 -0
  199. package/plugins/ca-pi/skills/ca-spike/SKILL.md +41 -0
  200. package/plugins/ca-pi/skills/ca-sprint/SKILL.md +44 -0
  201. package/plugins/ca-pi/skills/ca-standup/SKILL.md +112 -0
  202. package/plugins/ca-pi/skills/ca-status/SKILL.md +56 -0
  203. package/plugins/ca-pi/skills/ca-task/SKILL.md +61 -0
  204. package/plugins/ca-pi/skills/ca-threat-model/SKILL.md +45 -0
  205. package/plugins/ca-pi/skills/ca-tribunal/SKILL.md +44 -0
  206. package/plugins/ca-pi/skills/ca-watch/SKILL.md +78 -0
@@ -0,0 +1,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)