@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,1088 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter — task-board lifecycle helper for `.codearbiter/open-tasks.md`.
3
+ #
4
+ # Gives the backlog a parseable, crash-safe lifecycle (queued -> in-progress ->
5
+ # done) and a content-bearing ID, and supplies the SHARED count/staleness logic
6
+ # that both readers — session-start.py and statusline.py — route through, so the
7
+ # "in-flight tasks" number is computed in exactly one place.
8
+ #
9
+ # Design principles (mirroring _metricslib.py / _previewlib.py):
10
+ # - Stdlib only; no third-party imports ever — runs on stock Python.
11
+ # - Zero side effects at import time: no git calls, no file I/O.
12
+ # - Pure functions are fully testable with synthetic board text (no real file
13
+ # needed). read_board() is the ONLY function that touches the filesystem.
14
+ # - Never raise on malformed input — a board a human typo'd must degrade to a
15
+ # surfaced warning, never a crash (this is the SessionStart linchpin's path).
16
+ #
17
+ # Schema (one task = a top-level lifecycle line + indented content sub-bullets):
18
+ #
19
+ # - [~] poc.auth.0001 - Validate session tokens (started 2026-06-18)
20
+ # - Desc: reject expired/forged tokens at the auth middleware
21
+ # - Done when: an expired token returns 401; a valid one passes
22
+ # - Boundaries: auth, secrets
23
+ #
24
+ # marker [ ] queued | [~] in-progress | [x] done
25
+ # ID <group>.<type>.<seq4> (group = build phase, type = domain, seq >=4 digits)
26
+ # dates (started YYYY-MM-DD) / (done YYYY-MM-DD)
27
+ #
28
+ # Public API:
29
+ # count_in_flight(text) -> int top-level "- " lines (with content) excluding "- [x]"
30
+ # parse_board(text) -> list[Task] structured entries (partial fields ok)
31
+ # validate_id(s) -> bool the dotted-ID grammar
32
+ # duplicate_ids(text) -> list[str] IDs appearing more than once, first-seen order
33
+ # stale_in_progress(text, today, threshold_days) -> dict(count, oldest_age, oldest_id)
34
+ # undated_in_progress(text) -> list[Task] [~] tasks with no parseable start date
35
+ # stale_nudge_line(text, today, threshold_days) -> str | None (ASCII)
36
+ # lint_board(text) -> list[str] independent "task at risk of dropping off" warnings
37
+ # startup_summary(text, today, threshold_days) -> list[str] (the reader's lines)
38
+ # read_board(path) -> str | None thin file reader (not unit-tested)
39
+ # next_seq(text, group, type) -> int next free seq in a group.type namespace
40
+ # add_entry(text, *, desc, origin, group, type, boundaries, section) -> str
41
+ # add_error(*, desc, origin, boundaries, section) -> str | None
42
+ # field-specific validation error for add input
43
+ # set_state(text, target, state, today, *, assign) -> str
44
+ # target state in {"in_progress","done"}; unsupported
45
+ # state degrades gracefully (returns text unchanged)
46
+ # transition_error(text, target, state) -> str | None
47
+ # actionable error for a found task whose requested
48
+ # transition violates queued -> in-progress -> done
49
+ # already_promoted(text, origin) -> bool
50
+ # extract_needs_triage(text, origin) -> list[Candidate]
51
+ # extract_deferrable(text, origin) -> list[Candidate]
52
+ # extract_low_confidence(text, origin) -> list[Candidate]
53
+ # promote(board, questions, candidates, *, mode, today) -> PromoteResult
54
+ # mode in {"interactive","auto"}; unknown mode
55
+ # raises ValueError
56
+ # classify_board_diff(old_text, new_text) -> bool
57
+ # True iff the change is a clean done-flip,
58
+ # start-flip, or single queued add (with its
59
+ # missing section heading if needed); never raises
60
+ # extract_task_ids(text) -> list[str] valid dotted task-ids found in arbitrary text
61
+ # (e.g. git log output); deduped, first-seen
62
+ # order; never raises
63
+ # find_board_drift(board_text, merged_ids, today) -> DriftResult
64
+ # tasks whose work merged but board state is not
65
+ # [x]; pure, never raises; DriftResult fields:
66
+ # drifted (list[Task]), unknown (list[str]),
67
+ # observed (datetime.date)
68
+
69
+ import datetime
70
+ import re
71
+ from collections import namedtuple
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Constants
75
+ # ---------------------------------------------------------------------------
76
+
77
+ # Boards larger than this are not body-parsed at startup; the reader degrades to
78
+ # a one-line notice instead of stalling. Mirrors statusline.py's "never read a
79
+ # large file" precedent (the > 65536 guard).
80
+ MAX_BOARD_BYTES = 65536
81
+
82
+ # Default age (in days) at which an in-progress task triggers the SessionStart
83
+ # nudge. Tunable (open-questions D-3); the mechanism takes `today` injected so it
84
+ # is value-independent in tests.
85
+ STALE_THRESHOLD_DAYS = 3
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Data type
89
+ # ---------------------------------------------------------------------------
90
+
91
+ # One parsed task. `id` is None for a legacy bare bullet; date fields are None
92
+ # when absent or unparseable; list/str fields default empty (never raise).
93
+ Task = namedtuple(
94
+ "Task", "state id title started done desc done_when boundaries raw lineno")
95
+
96
+ # Result of find_board_drift. All fields always present (never None):
97
+ # drifted — list[Task] whose state is not "done" and whose id is in merged_ids
98
+ # (work merged but board was never flipped to [x]).
99
+ # unknown — list[str] of merged_ids absent from the board entirely
100
+ # (informational; first-seen order, deduped; never treated as drift).
101
+ # observed — datetime.date passed by the caller (stamps when the sweep ran).
102
+ DriftResult = namedtuple("DriftResult", "drifted unknown observed")
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Patterns
106
+ # ---------------------------------------------------------------------------
107
+
108
+ _DONE_RE = re.compile(r"^- \[[xX]\]") # a done top-level bullet
109
+ _TOP_RE = re.compile(r"^- ") # any top-level bullet (column 0)
110
+ # Content after the bullet/marker — a bare "- " or empty "- [ ]" is not a task.
111
+ # Group 1 (greedy, optional) absorbs the marker so it can't be mistaken for the
112
+ # body; group 2 is the actual content.
113
+ _CONTENT_RE = re.compile(r"^- (\[[ xX~]\]\s*)?(.*)$")
114
+ _ID_RE = re.compile(r"[a-z][a-z0-9]*\.[a-z][a-z0-9]*\.[0-9]{4,}\Z")
115
+ # A token that LOOKS like an ID (three OR MORE dot-separated parts) — accepts
116
+ # malformed IDs too (including an over-segmented "a.b.c.d"), so validate_id() can
117
+ # later flag them and set_state() can target them, rather than the parser hiding a
118
+ # 4-segment token inside the title where it becomes un-targetable and un-lintable.
119
+ _IDISH_RE = re.compile(r"^[^\s.]+(?:\.[^\s.]+){2,}$")
120
+ _SUB_RE = re.compile(r"^\s+-\s*([^:]+):\s*(.*)$") # indented " - Key: value"
121
+ # A lifecycle marker sitting at (or near) the start of a line — used by lint to
122
+ # catch a task whose marker is NOT in the canonical column-0 "- [m] " position
123
+ # (indented, "-[ ]" no-space, "* [ ]" wrong bullet, bare "[ ]"). Anchored to the
124
+ # line start so a "[x]" inside a title is NOT a false positive.
125
+ _STRAY_MARKER_RE = re.compile(r"^\s*[-*+]?\s*\[[ xX~]\]")
126
+ _CANON_TASK_RE = re.compile(r"^- \[[ xX~]\] ") # a well-formed marked task line
127
+
128
+ _STATE_BY_MARK = {" ": "queued", "~": "in_progress", "x": "done", "X": "done"}
129
+
130
+ # classify_board_diff helpers — strip markers/stamps for content-equality comparison.
131
+ _STAMP_FULL_RE = re.compile(r'\s*\((?:started|done)\s+\d{4}-\d{2}-\d{2}\)')
132
+ _STATE_MARK_RE = re.compile(r'^- \[([ xX~])\]\s*')
133
+ _QUEUED_TOP_RE = re.compile(r'^- \[ \] .+')
134
+ _INDENTED_BULLET_RE = re.compile(r'^\s+- ')
135
+ _LINE_BREAK_RE = re.compile(r'[\n\r\v\f\x1c-\x1e\x85\u2028\u2029]')
136
+ # extract_task_ids search pattern — non-anchored; two-part negative lookahead so
137
+ # trailing sentence punctuation (e.g. "mvp1.ui.0005.") is not blocked, while an
138
+ # extended alphanumeric suffix ("poc.auth.0001x") and a continuing dot-segment
139
+ # ("poc.auth.0001.extra") are both rejected:
140
+ # (?![a-z0-9]) — no letter/digit immediately after the seq digits
141
+ # (?!\.[a-z0-9]) — no dot immediately followed by a letter/digit (next segment)
142
+ # The negative lookbehind prevents matching a mid-word start or a token that is
143
+ # already part of a longer dotted sequence (preceded by "." or alphanum).
144
+ # Every candidate is additionally gated through validate_id() so only
145
+ # grammar-valid IDs are returned.
146
+ _TASK_ID_SCAN_RE = re.compile(
147
+ r"(?<![a-z0-9.])([a-z][a-z0-9]*\.[a-z][a-z0-9]*\.[0-9]{4,})(?![a-z0-9])(?!\.[a-z0-9])"
148
+ )
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # Pure functions
153
+ # ---------------------------------------------------------------------------
154
+
155
+ def _has_content(line):
156
+ """True iff a top-level bullet has a non-empty body after its marker.
157
+
158
+ Excludes a stray bare "- " or an empty "- [ ]" so a placeholder dash never
159
+ inflates the count (it is instead surfaced by lint_board)."""
160
+ m = _CONTENT_RE.match(line)
161
+ return bool(m and m.group(2).strip())
162
+
163
+
164
+ def count_in_flight(text):
165
+ """In-flight count = top-level '- ' lines WITH content, excluding done.
166
+
167
+ Counts queued ('- [ ]'), in-progress ('- [~]') AND legacy bare '- ' bullets
168
+ (backward-compatible with the pre-schema file); excludes done ('- [x]') and
169
+ empty placeholder bullets. Indented sub-bullets are never counted. This is
170
+ the single source both readers use.
171
+ """
172
+ if not text:
173
+ return 0
174
+ return sum(1 for ln in text.splitlines()
175
+ if _TOP_RE.match(ln) and not _DONE_RE.match(ln) and _has_content(ln))
176
+
177
+
178
+ def validate_id(s):
179
+ """True iff `s` matches <group>.<type>.<seq>, seq being >=4 digits.
180
+
181
+ Rejects a missing component, a non-numeric or under-padded seq, uppercase,
182
+ and trailing whitespace/newline (anchored with \\Z, not $). Growth past 9999
183
+ is allowed (>=4 digits).
184
+ """
185
+ return bool(_ID_RE.match(s or ""))
186
+
187
+
188
+ def _extract_date(text, kind):
189
+ """Parse `(kind YYYY-MM-DD)` from `text`; None if absent or unparseable.
190
+
191
+ Iterates every `(kind ...)` occurrence and returns the first that parses, so
192
+ a decoy phrase like "(started by Bob)" before the real "(started 2026-06-18)"
193
+ does not shadow the real date.
194
+ """
195
+ for m in re.finditer(r"\(" + kind + r"\s+([^)]*)\)", text):
196
+ try:
197
+ return datetime.datetime.strptime(m.group(1).strip(), "%Y-%m-%d").date()
198
+ except ValueError:
199
+ continue
200
+ return None
201
+
202
+
203
+ def parse_board(text):
204
+ """Parse board text into a list of Task records.
205
+
206
+ A top-level bullet opens a task; the indented `- Key: value` lines beneath it
207
+ fill Desc / Done when / Boundaries. A heading or any non-indented, non-bullet
208
+ line CLOSES the open task, so sub-fields never leak across a `## section`
209
+ boundary. Absent or `TBD` fields yield empty/`TBD` values — never raises.
210
+ """
211
+ tasks = []
212
+ cur = None
213
+
214
+ def _flush():
215
+ if cur is not None:
216
+ tasks.append(Task(**cur))
217
+
218
+ for i, raw in enumerate(text.splitlines() if text else []):
219
+ if _TOP_RE.match(raw):
220
+ mark_m = re.match(r"^- \[([ xX~])\]\s*(.*)$", raw)
221
+ if mark_m:
222
+ state = _STATE_BY_MARK.get(mark_m.group(1), "queued")
223
+ body = mark_m.group(2)
224
+ else:
225
+ state = "queued" # legacy bare "- ..." bullet
226
+ body = raw[2:]
227
+ _flush()
228
+ tid, title = _split_id_title(body)
229
+ cur = dict(
230
+ state=state, id=tid, title=title,
231
+ started=_extract_date(body, "started"),
232
+ done=_extract_date(body, "done"),
233
+ desc="", done_when="", boundaries=[], raw=raw, lineno=i + 1)
234
+ continue
235
+ sub = _SUB_RE.match(raw)
236
+ if sub and cur is not None:
237
+ key = sub.group(1).strip().lower()
238
+ val = sub.group(2).strip()
239
+ if key == "desc":
240
+ cur["desc"] = val
241
+ elif key in ("done when", "done-when"):
242
+ cur["done_when"] = val
243
+ elif key == "boundaries":
244
+ cur["boundaries"] = ([] if val.upper() == "TBD"
245
+ else [b.strip() for b in val.split(",") if b.strip()])
246
+ elif raw.strip() and not raw[:1].isspace():
247
+ # a non-indented, non-bullet line (e.g. "## heading") closes the task
248
+ _flush()
249
+ cur = None
250
+ _flush()
251
+ return tasks
252
+
253
+
254
+ def _split_id_title(body):
255
+ """Pull an ID-ish first token (and a title) out of a bullet body.
256
+
257
+ Returns (id_or_None, title). The ID is the leading token when it has three
258
+ dot-separated parts (valid OR malformed — validate_id flags bad ones later).
259
+ Strips a leading separator and any trailing (started/done ...) parenthetical
260
+ from the title.
261
+ """
262
+ body = body.strip()
263
+ toks = body.split(None, 1)
264
+ if toks and _IDISH_RE.match(toks[0]):
265
+ tid = toks[0]
266
+ rest = toks[1] if len(toks) > 1 else ""
267
+ rest = re.sub(r"^[—–\-]+\s*", "", rest) # strip leading em/en/hyphen dash
268
+ else:
269
+ tid = None
270
+ rest = body
271
+ title = re.sub(r"\((?:started|done)\s+[^)]*\)", "", rest)
272
+ title = re.sub(r"\(from\s+[^)]*\)", "", title).strip() # back-ref is metadata, not title
273
+ return tid, title
274
+
275
+
276
+ def duplicate_ids(text):
277
+ """IDs that appear more than once on the board, in first-duplicate order."""
278
+ seen, dups = set(), []
279
+ for t in parse_board(text):
280
+ if t.id is None:
281
+ continue
282
+ if t.id in seen and t.id not in dups:
283
+ dups.append(t.id)
284
+ seen.add(t.id)
285
+ return dups
286
+
287
+
288
+ def stale_in_progress(text, today, threshold_days=STALE_THRESHOLD_DAYS):
289
+ """In-progress tasks whose (started) age is >= threshold_days.
290
+
291
+ `today` is injected (a datetime.date) so the result is reproducible. A [~]
292
+ task with a missing/garbage started date is age-unknown — never counted
293
+ stale here (see undated_in_progress, which surfaces it separately).
294
+ """
295
+ aged = []
296
+ for t in parse_board(text):
297
+ if t.state != "in_progress" or t.started is None:
298
+ continue
299
+ age = (today - t.started).days
300
+ if age >= threshold_days:
301
+ aged.append((age, t.id))
302
+ if not aged:
303
+ return {"count": 0, "oldest_age": None, "oldest_id": None}
304
+ # `id or ""` keeps the sort key total-orderable: a legacy bare [~] bullet has
305
+ # id=None, and (int, None) vs (int, str) would raise on a same-age tie.
306
+ aged.sort(key=lambda p: (p[0], p[1] or ""), reverse=True)
307
+ return {"count": len(aged), "oldest_age": aged[0][0], "oldest_id": aged[0][1]}
308
+
309
+
310
+ def undated_in_progress(text):
311
+ """In-progress ([~]) tasks with no parseable started date.
312
+
313
+ These can never age, so the stale nudge would miss them forever — the most
314
+ common abandoned-work shape (a human flips to [~] and forgets the date). The
315
+ reader surfaces them as their own class so they cannot drop off the map.
316
+ """
317
+ return [t for t in parse_board(text)
318
+ if t.state == "in_progress" and t.started is None]
319
+
320
+
321
+ def stale_nudge_line(text, today, threshold_days=STALE_THRESHOLD_DAYS):
322
+ """The one-line startup nudge, or None when nothing is stale. ASCII-only so
323
+ it never trips a Windows console encoding on print."""
324
+ r = stale_in_progress(text, today, threshold_days)
325
+ if not r["count"]:
326
+ return None
327
+ return (f"stale in-progress: {r['count']} "
328
+ f"(oldest {r['oldest_id']}, {r['oldest_age']}d) -- verify or close")
329
+
330
+
331
+ def lint_board(text):
332
+ """Independent ground-truth checks that SURFACE a task at risk of dropping
333
+ off the map. Returns human-readable warning lines (empty when clean).
334
+
335
+ Catches what count_in_flight is structurally blind to: a lifecycle marker
336
+ that is NOT a counted column-0 task line (indented, "-[ ]" no-space, "* [ ]"
337
+ wrong bullet, bare "[ ]"), plus invalid and duplicate IDs. This is the live
338
+ surface for validate_id / duplicate_ids — without it they are dead code and
339
+ a one-character slip hides a real task with zero signal.
340
+ """
341
+ warnings = []
342
+ if not text:
343
+ return warnings
344
+ stray = [i for i, ln in enumerate(text.splitlines(), 1)
345
+ if _STRAY_MARKER_RE.match(ln) and not _CANON_TASK_RE.match(ln)]
346
+ if stray:
347
+ warnings.append(
348
+ f"{len(stray)} task line(s) look malformed (marker not at column 0, "
349
+ f"first at line {stray[0]}) -- check open-tasks.md")
350
+ bad = sorted({t.id for t in parse_board(text) if t.id and not validate_id(t.id)})
351
+ if bad:
352
+ warnings.append("invalid task id(s): " + ", ".join(bad[:3]))
353
+ dups = duplicate_ids(text)
354
+ if dups:
355
+ warnings.append("duplicate task id(s): " + ", ".join(dups[:3]))
356
+ return warnings
357
+
358
+
359
+ def startup_summary(text, today, threshold_days=STALE_THRESHOLD_DAYS):
360
+ """The task-board lines the SessionStart hook prints.
361
+
362
+ Oversize boards degrade to a single notice (never body-parsed). Otherwise:
363
+ the in-flight count, the stale-in-progress nudge, an undated-in-progress
364
+ notice, and any lint warnings (malformed / invalid / duplicate entries).
365
+ """
366
+ if text is None:
367
+ return []
368
+ nbytes = len(text.encode("utf-8", "replace"))
369
+ if nbytes > MAX_BOARD_BYTES:
370
+ return [f"task board too large ({nbytes // 1024}KB) -- open "
371
+ ".codearbiter/open-tasks.md directly"]
372
+ lines = [f"in-flight tasks: {count_in_flight(text)}"]
373
+ nudge = stale_nudge_line(text, today, threshold_days)
374
+ if nudge:
375
+ lines.append(nudge)
376
+ undated = undated_in_progress(text)
377
+ if undated:
378
+ lines.append(f"in-progress with no start date: {len(undated)} "
379
+ f"(cannot age -- add a date or close)")
380
+ lines.extend(lint_board(text))
381
+ return lines
382
+
383
+
384
+ def _strip_stamps_and_marker(line):
385
+ """Strip the state marker and (started/done YYYY-MM-DD) stamps for content comparison.
386
+
387
+ Used by classify_board_diff to check whether two task lines differ only in their
388
+ state marker and date stamp, and not in their descriptive content.
389
+ """
390
+ line = _STATE_MARK_RE.sub('', line)
391
+ line = _STAMP_FULL_RE.sub('', line)
392
+ return line.rstrip()
393
+
394
+
395
+ def _is_valid_state_flip(old_line, new_line):
396
+ """True iff old_line → new_line is a valid done-flip ([~]→[x]+done) or
397
+ start-flip ([ ]→[~]+started), with all other content unchanged. A start may
398
+ also mint one valid dotted ID on an ID-less task, matching ``set_state``'s
399
+ ``assign`` path."""
400
+ old_m = _STATE_MARK_RE.match(old_line)
401
+ new_m = _STATE_MARK_RE.match(new_line)
402
+ if not old_m or not new_m:
403
+ return False
404
+ old_mark = old_m.group(1).lower()
405
+ new_mark = new_m.group(1).lower()
406
+ # done-flip: [~] → [x], new line must carry (done YYYY-MM-DD)
407
+ if old_mark == '~' and new_mark == 'x':
408
+ if _extract_date(new_line, 'done') is None:
409
+ return False
410
+ return _strip_stamps_and_marker(old_line) == _strip_stamps_and_marker(new_line)
411
+ # start-flip: [ ] → [~], new line must carry (started YYYY-MM-DD)
412
+ if old_mark == ' ' and new_mark == '~':
413
+ if _extract_date(new_line, 'started') is None:
414
+ return False
415
+ old_content = _strip_stamps_and_marker(old_line)
416
+ new_content = _strip_stamps_and_marker(new_line)
417
+ if old_content == new_content:
418
+ return True
419
+ old_task = parse_board(old_line)[0]
420
+ new_task = parse_board(new_line)[0]
421
+ if old_task.id is not None or not validate_id(new_task.id):
422
+ return False
423
+ minted_prefix = f"{new_task.id} - "
424
+ return (new_content.startswith(minted_prefix)
425
+ and new_content[len(minted_prefix):] == old_content)
426
+ return False
427
+
428
+
429
+ def _is_valid_queued_block(lines):
430
+ """True iff `lines` form a single valid queued entry: one `- [ ] desc` top-level
431
+ line (non-empty) optionally followed by indented sub-bullets only (e.g. Boundaries)."""
432
+ if not lines or not _QUEUED_TOP_RE.match(lines[0]):
433
+ return False
434
+ return all(_INDENTED_BULLET_RE.match(ln) for ln in lines[1:])
435
+
436
+
437
+ def _is_valid_new_section_add(lines):
438
+ """True iff ``lines`` are one new level-two section plus one queued entry.
439
+
440
+ This is the exact shape ``add_entry`` emits when its requested section does
441
+ not yet exist. The queued entry may carry its normal indented metadata, but
442
+ no free-form content or second entry is accepted.
443
+ """
444
+ return (len(lines) >= 2
445
+ and re.match(r"^##\s+\S.*$", lines[0]) is not None
446
+ and _is_valid_queued_block(lines[1:]))
447
+
448
+
449
+ def classify_board_diff(old_text, new_text):
450
+ """True iff the change from old_text to new_text is a clean task-board transition.
451
+
452
+ A clean transition is EXACTLY ONE of:
453
+ - done-flip: one task's marker changes [~] → [x], a (done YYYY-MM-DD) stamp is
454
+ added, and no other content changes (a prior (started ...) stamp is allowed to
455
+ drop; nothing else may change);
456
+ - start-flip: one task's marker changes [ ] → [~], a (started YYYY-MM-DD) stamp
457
+ is added, and no other content changes except an ID-less task may gain the
458
+ single valid dotted ID minted by the writer's pick-up path;
459
+ - add: exactly one new queued top-level entry `- [ ] desc` (optionally with a
460
+ dotted id, a (from <origin>) back-ref, and/or an indented `- Boundaries:`
461
+ sub-bullet) is inserted or appended, and no existing line is changed. If
462
+ its requested section is absent, the writer's one new level-two heading
463
+ immediately followed by that entry may be appended with it.
464
+
465
+ Returns False for anything else: reworded description, deleted entry, marker
466
+ change without the required date stamp, multiple simultaneous transitions, an
467
+ edit to an unrelated line, or any content change beyond those above.
468
+
469
+ Never raises — empty, None, or garbled input degrades to False (same crash-safe
470
+ invariant as all other _taskboardlib pure functions).
471
+ """
472
+ try:
473
+ if not old_text or not new_text:
474
+ return False
475
+ old_lines = old_text.splitlines()
476
+ new_lines = new_text.splitlines()
477
+
478
+ # ── state-flip branch: same line count, exactly one line changed ──────
479
+ if len(old_lines) == len(new_lines):
480
+ changed = [(old_lines[i], new_lines[i])
481
+ for i in range(len(old_lines))
482
+ if old_lines[i] != new_lines[i]]
483
+ return len(changed) == 1 and _is_valid_state_flip(*changed[0])
484
+
485
+ # ── add branch: new has more lines; all old lines intact, extra is one ─
486
+ if len(new_lines) > len(old_lines):
487
+ n_extra = len(new_lines) - len(old_lines)
488
+ # find how many leading lines are already identical (the common prefix)
489
+ k = 0
490
+ while k < len(old_lines) and old_lines[k] == new_lines[k]:
491
+ k += 1
492
+ extra = new_lines[k:k + n_extra]
493
+ # the lines after the inserted block must equal the old suffix
494
+ if new_lines[k + n_extra:] != old_lines[k:]:
495
+ return False
496
+ if _is_valid_queued_block(extra):
497
+ return True
498
+ return (k == len(old_lines)
499
+ and _is_valid_new_section_add(extra)
500
+ and all(line.strip() != extra[0].strip() for line in old_lines))
501
+
502
+ # new has fewer lines than old — a deletion, never a clean transition
503
+ return False
504
+ except Exception:
505
+ return False
506
+
507
+
508
+ def extract_task_ids(text):
509
+ """Scan arbitrary text and return valid dotted task-ids in first-seen order.
510
+
511
+ Finds ids matching <group>.<type>.<seq> where seq is >=4 digits — the same
512
+ grammar validate_id enforces (e.g. 'v2.rev.0020', 'poc.auth.0001'). Tokens
513
+ that do not match the grammar (issue refs like '#142', version shorthands
514
+ like 'v2', dates, bare words, extended tokens like 'poc.auth.0001x') are
515
+ silently ignored. Deduplicates while preserving first-seen order.
516
+
517
+ Never raises — None, empty, or garbled input returns [] (crash-safe
518
+ invariant, same as classify_board_diff and all other pure functions here).
519
+ """
520
+ try:
521
+ if not text:
522
+ return []
523
+ seen_set = set()
524
+ seen_list = []
525
+ for m in _TASK_ID_SCAN_RE.finditer(text):
526
+ candidate = m.group(1)
527
+ if validate_id(candidate) and candidate not in seen_set:
528
+ seen_set.add(candidate)
529
+ seen_list.append(candidate)
530
+ return seen_list
531
+ except Exception:
532
+ return []
533
+
534
+
535
+ def find_board_drift(board_text, merged_ids, today):
536
+ """Detect task-board drift: tasks whose work merged but board state is not [x].
537
+
538
+ board_text — the open-tasks.md text.
539
+ merged_ids — list/iterable of dotted task-ids referenced in merged work,
540
+ typically produced upstream by extract_task_ids.
541
+ today — a datetime.date; stored as DriftResult.observed so the caller
542
+ can stamp the report with the sweep date (not a dead parameter).
543
+
544
+ Returns a DriftResult namedtuple:
545
+ drifted — Task records with state 'queued' or 'in_progress' whose id is
546
+ in merged_ids (work merged, board not yet flipped to done).
547
+ unknown — merged_ids absent from the board entirely (informational;
548
+ first-seen order, deduped; never reported as drift or done).
549
+ observed — today (the observation date).
550
+
551
+ Never raises. None/empty board_text or merged_ids returns an empty
552
+ DriftResult (drifted=[], unknown=[], observed=today). A merged_id not
553
+ present on the board surfaces in unknown only — the function writes nothing;
554
+ it is pure and returns data only.
555
+ """
556
+ try:
557
+ if not board_text or not merged_ids:
558
+ return DriftResult([], [], today)
559
+
560
+ # Build an id -> Task index; id=None legacy bullets are not addressable.
561
+ board_by_id = {}
562
+ for t in parse_board(board_text):
563
+ if t.id is not None:
564
+ board_by_id[t.id] = t
565
+
566
+ drifted = []
567
+ unknown = []
568
+ seen_unknown = set()
569
+
570
+ for mid in merged_ids:
571
+ if mid in board_by_id:
572
+ t = board_by_id[mid]
573
+ if t.state != "done":
574
+ drifted.append(t)
575
+ # done → already [x], excluded from drift (AC-09)
576
+ else:
577
+ # absent from the board → informational unknown, never drift
578
+ if mid not in seen_unknown:
579
+ seen_unknown.add(mid)
580
+ unknown.append(mid)
581
+
582
+ return DriftResult(drifted, unknown, today)
583
+ except Exception:
584
+ return DriftResult([], [], today)
585
+
586
+
587
+ # ---------------------------------------------------------------------------
588
+ # Writer transforms (pure: text in -> new text out; the /ca:task command does I/O)
589
+ # ---------------------------------------------------------------------------
590
+
591
+ # A harvested follow-up candidate. kind in {"work", "decision"}; blocking marks a
592
+ # decision that must gate (routed to a [CONFIRM-NN]/escalation, never the
593
+ # non-gating Deferred-decisions section). Defaults keep the 4-arg constructions valid.
594
+ Candidate = namedtuple("Candidate", "kind desc origin boundaries blocking")
595
+ Candidate.__new__.__defaults__ = (False,)
596
+ # The outcome of a promote pass.
597
+ PromoteResult = namedtuple("PromoteResult", "candidates board questions audit applied")
598
+
599
+ _MARK_BY_STATE = {"queued": " ", "in_progress": "~", "done": "x"}
600
+
601
+
602
+ def next_seq(text, group, type):
603
+ """Next free 4-digit seq in the `group.type` ID namespace (1 when none)."""
604
+ prefix = f"{group}.{type}."
605
+ mx = 0
606
+ for t in parse_board(text):
607
+ if t.id and t.id.startswith(prefix):
608
+ tail = t.id[len(prefix):]
609
+ if tail.isdigit():
610
+ mx = max(mx, int(tail))
611
+ return mx + 1
612
+
613
+
614
+ def _join(lines, original):
615
+ return "\n".join(lines) + ("\n" if original.endswith("\n") else "")
616
+
617
+
618
+ def _insert_under_section(text, block, section):
619
+ """Insert `block` immediately after the `section` heading, creating the
620
+ section at the end if it is absent."""
621
+ lines = text.splitlines()
622
+ for i, ln in enumerate(lines):
623
+ if ln.strip() == section.strip():
624
+ lines.insert(i + 1, block)
625
+ return _join(lines, text or "\n")
626
+ base = text if (text == "" or text.endswith("\n")) else text + "\n"
627
+ return f"{base}{section}\n{block}\n"
628
+
629
+
630
+ def add_error(*, desc, origin=None, boundaries=None, section="## In-flight",
631
+ rationale=None):
632
+ """Return a field-specific error for an add input, else ``None``.
633
+
634
+ These constraints keep every accepted field on the physical line where the
635
+ board schema expects it. The section must be one canonical level-two heading;
636
+ descriptions are nonblank; optional metadata cannot contain line breaks.
637
+ """
638
+ if not isinstance(desc, str) or not desc.strip():
639
+ return "bad description: expected nonblank single-line text"
640
+ if _LINE_BREAK_RE.search(desc):
641
+ return "bad description: expected nonblank single-line text"
642
+ if (not isinstance(section, str)
643
+ or _LINE_BREAK_RE.search(section)
644
+ or re.fullmatch(r"## \S(?:.*\S)?", section) is None):
645
+ return ("bad --section: expected one canonical level-two heading, "
646
+ "e.g. '## In-flight'")
647
+ if origin is not None and (not isinstance(origin, str)
648
+ or _LINE_BREAK_RE.search(origin)):
649
+ return "bad --from: expected single-line text"
650
+ if rationale is not None:
651
+ # B-17/T-53. `debug` Exit (c) carries a "no action" rationale as an
652
+ # indented `- Desc:` sub-bullet, which it appended DIRECTLY because
653
+ # the helper had no way to express it — the last real surface still
654
+ # writing the board by hand. Same single-line rule as every other
655
+ # optional field: a line break here would emit an orphan physical
656
+ # line the board parser cannot attribute to any task.
657
+ if not isinstance(rationale, str) or not rationale.strip():
658
+ return "bad --desc: expected nonblank single-line text"
659
+ if _LINE_BREAK_RE.search(rationale):
660
+ return "bad --desc: expected nonblank single-line text"
661
+ if boundaries is not None:
662
+ if not isinstance(boundaries, (list, tuple)):
663
+ return "bad --boundaries: expected comma-separated single-line values"
664
+ invalid = any(not isinstance(boundary, str)
665
+ or not boundary.strip()
666
+ or _LINE_BREAK_RE.search(boundary)
667
+ for boundary in boundaries)
668
+ if invalid:
669
+ return ("bad --boundaries: each comma-separated boundary must be "
670
+ "nonblank single-line text")
671
+ return None
672
+
673
+
674
+ def add_entry(text, *, desc, origin=None, group=None, type=None,
675
+ boundaries=None, section="## In-flight", rationale=None):
676
+ """Append a queued entry. ID-less by default; mints `<group>.<type>.<NNNN>`
677
+ when both group and type are given. Optional `(from <origin>)` back-ref and a
678
+ `Boundaries` sub-bullet. Invalid fields fail soft by returning ``text``
679
+ unchanged, so no input can inject an orphan/malformed physical line."""
680
+ if add_error(desc=desc, origin=origin, boundaries=boundaries,
681
+ section=section, rationale=rationale):
682
+ return text
683
+ desc = desc.strip()
684
+ tid = f"{group}.{type}.{next_seq(text, group, type):04d}" if (group and type) else None
685
+ body = f"{tid} - {desc}" if tid else desc
686
+ line = f"- [ ] {body}"
687
+ if origin:
688
+ line += f" (from {origin})"
689
+ if rationale:
690
+ # Emitted BEFORE Boundaries so the rationale reads immediately under
691
+ # its task — the shape `debug` Exit (c) already produced by hand,
692
+ # and the shape the board's existing readers already tolerate.
693
+ line += f"\n - Desc: {rationale.strip()}"
694
+ if boundaries:
695
+ line += f"\n - Boundaries: {', '.join(boundaries)}"
696
+ return _insert_under_section(text, line, section)
697
+
698
+
699
+ # B-20/B4. Done items older than this are sweep candidates. A NAMED
700
+ # constant per D-3's precedent, and tested against an INJECTED date rather
701
+ # than `today()` -- a cutoff test keyed on the real clock passes for eleven
702
+ # days and then starts failing on its own.
703
+ ARCHIVE_CUTOFF_DAYS = 14
704
+
705
+ DONE_TASKS_HEADING = "# Done tasks"
706
+
707
+
708
+ def archive_candidates(text, *, today, cutoff_days=ARCHIVE_CUTOFF_DAYS):
709
+ """`(aged, undated)` — done tasks eligible for the archival sweep.
710
+
711
+ `aged` are done items whose `(done YYYY-MM-DD)` stamp is strictly more
712
+ than `cutoff_days` old. `undated` are `[x]` items carrying NO stamp at
713
+ all, returned SEPARATELY because they cannot be aged: both
714
+ `taskwrite done` and the ADR-0008 classifier require the stamp, so an
715
+ undated entry is legacy or override-era. The spec admits them only
716
+ under explicit per-item confirmation, and keeping them in a second
717
+ list is what makes a caller unable to sweep them by accident.
718
+
719
+ Pure over synthetic input; non-raising. `today` is REQUIRED — there is
720
+ no clock default, so no test can silently depend on the real date.
721
+ """
722
+ aged, undated = [], []
723
+ if not isinstance(text, str) or today is None:
724
+ return aged, undated
725
+ for line in text.splitlines():
726
+ parsed = parse_board(line)
727
+ if not parsed:
728
+ continue
729
+ task = parsed[0]
730
+ if task.state != "done":
731
+ continue
732
+ if task.done is None:
733
+ undated.append(task)
734
+ continue
735
+ if (today - task.done).days > cutoff_days:
736
+ aged.append(task)
737
+ return aged, undated
738
+
739
+
740
+ def already_archived(done_text, task):
741
+ """True iff `task` is already recorded in `done_text`.
742
+
743
+ Dedup is on the DOTTED ID when the task has one, and on exact line
744
+ text when it does not. That split matters: an ID-less entry has no
745
+ stable handle, so only its own text identifies it, while an
746
+ ID-carrying entry must dedup on the ID even if its title was later
747
+ edited -- otherwise a re-run appends a second copy of the same task
748
+ under a slightly different wording.
749
+ """
750
+ if not isinstance(done_text, str) or task is None:
751
+ return False
752
+ if getattr(task, "id", None):
753
+ for line in done_text.splitlines():
754
+ parsed = parse_board(line)
755
+ if parsed and parsed[0].id == task.id:
756
+ return True
757
+ return False
758
+ needle = (getattr(task, "raw", "") or "").strip()
759
+ if not needle:
760
+ return False
761
+ return any(line.strip() == needle for line in done_text.splitlines())
762
+
763
+
764
+ def task_block(lines, task):
765
+ """`(start, end)` half-open line range of `task`'s WHOLE block, or None.
766
+
767
+ A task is not one line. `parse_board` opens a task on a top-level
768
+ bullet and attaches every following `- Key: value` sub-bullet
769
+ (`Desc`, `Done when`, `Boundaries`) to it, closing only on the next
770
+ top-level bullet or a non-indented non-bullet line -- a BLANK line
771
+ does not close it. This function reproduces exactly that rule, so the
772
+ unit the board moves is the unit the board parses. Any other choice
773
+ re-attributes the orphaned sub-bullets to whatever task follows,
774
+ silently rewriting a `Boundaries:` security scope onto an unrelated
775
+ item (workstream-B adversary HIGH-2).
776
+
777
+ Trailing blank lines are TRIMMED off the block: a blank line between
778
+ two tasks is a separator belonging to the board, not cargo belonging
779
+ to the task above it.
780
+
781
+ Located by INDEX, never by text equality. Two done items may be
782
+ character-identical -- `taskwrite add` is documented rerun-safe, so
783
+ two adds of one description is a reachable state -- and removing
784
+ "every line equal to this one" while appending a single record
785
+ destroys the duplicate permanently (adversary HIGH-3). `task.lineno`
786
+ is trusted only when it still points at `task.raw`; otherwise the
787
+ FIRST matching top-level line wins, so a caller holding a Task parsed
788
+ from some other text degrades to the old behaviour instead of
789
+ corrupting the board.
790
+ """
791
+ raw = (getattr(task, "raw", "") or "").strip()
792
+ if not raw:
793
+ return None
794
+
795
+ start = None
796
+ lineno = getattr(task, "lineno", None)
797
+ if isinstance(lineno, int) and 0 < lineno <= len(lines):
798
+ if lines[lineno - 1].strip() == raw:
799
+ start = lineno - 1
800
+ if start is None:
801
+ for i, line in enumerate(lines):
802
+ if _TOP_RE.match(line) and line.strip() == raw:
803
+ start = i
804
+ break
805
+ if start is None:
806
+ return None
807
+
808
+ # The close condition MIRRORS `parse_board` exactly: a top-level bullet
809
+ # opens the next task, and a line that is non-blank AND not indented
810
+ # closes the current one. Everything else -- blank lines, `- Key:`
811
+ # sub-bullets, and indented continuation prose -- keeps it open.
812
+ #
813
+ # The earlier form advanced only past `_SUB_RE` matches and blanks, and
814
+ # broke on anything else. That diverged on INDENTED NON-SUB text: an
815
+ # indented continuation line kept the task open in `parse_board` (which
816
+ # went on to attach a later `- Desc:` to it) while stopping the block
817
+ # here, so the sub-bullets past it were orphaned onto the next task --
818
+ # re-opening the very defect this function exists to close. The two must
819
+ # agree, so the rule is copied rather than paraphrased.
820
+ end = start + 1
821
+ while end < len(lines):
822
+ line = lines[end]
823
+ if _TOP_RE.match(line):
824
+ break
825
+ if line.strip() and not line[:1].isspace():
826
+ break
827
+ end += 1
828
+ while end > start + 1 and not lines[end - 1].strip():
829
+ end -= 1
830
+ return start, end
831
+
832
+
833
+ def archive_transform(open_text, done_text, task):
834
+ """`(new_open_text, new_done_text)` for ONE archived item.
835
+
836
+ Per-item by construction (B-20). Batching is unsafe in both orders:
837
+ appending all N then removing all N duplicates every item if the run
838
+ dies between phases, and removing first loses records outright. One
839
+ item at a time makes an interruption cost at most a single duplicate
840
+ that the next run's dedup absorbs.
841
+
842
+ The caller is responsible for writing `done` BEFORE `open` — this
843
+ function only computes both texts. Rerun-safe: an item already in
844
+ `done_text` is not appended twice, but IS still removed from
845
+ `open_text`, which is exactly the state an interrupted run leaves
846
+ behind.
847
+
848
+ Moves the task's whole BLOCK, not its top line -- see `task_block`
849
+ for the boundary rule and for why removal is index-based.
850
+
851
+ Pure and non-raising; unknown input returns the texts unchanged.
852
+ """
853
+ if not isinstance(open_text, str) or not isinstance(done_text, str):
854
+ return open_text, done_text
855
+ lines = open_text.splitlines()
856
+ span = task_block(lines, task)
857
+ if span is None:
858
+ return open_text, done_text
859
+ start, end = span
860
+ block = lines[start:end]
861
+
862
+ if already_archived(done_text, task):
863
+ new_done = done_text
864
+ else:
865
+ body = done_text if done_text.strip() else DONE_TASKS_HEADING + "\n"
866
+ if not body.endswith("\n"):
867
+ body += "\n"
868
+ new_done = body + "\n".join(block) + "\n"
869
+
870
+ kept = lines[:start] + lines[end:]
871
+ new_open = "\n".join(kept)
872
+ if open_text.endswith("\n") and not new_open.endswith("\n"):
873
+ new_open += "\n"
874
+ return new_open, new_done
875
+
876
+
877
+ def _find_task_line(lines, target):
878
+ """Index of the task line matching `target` (a dotted id, or the title of an
879
+ ID-less item), or -1. PREFERS an open match: a done line never shadows a live
880
+ task of the same title (it is only used as a fallback for an id-targeted
881
+ re-`done`)."""
882
+ fallback = -1
883
+ for i, ln in enumerate(lines):
884
+ if not _TOP_RE.match(ln):
885
+ continue
886
+ parsed = parse_board(ln)
887
+ if not parsed:
888
+ continue
889
+ t = parsed[0]
890
+ if t.id == target or (t.id is None and t.title == target):
891
+ if t.state != "done":
892
+ return i
893
+ if fallback < 0:
894
+ fallback = i
895
+ return fallback
896
+
897
+
898
+ def transition_error(text, target, state):
899
+ """Return an actionable error when a found task cannot enter ``state``.
900
+
901
+ The sanctioned writer's lifecycle is queued -> in-progress -> done. Missing
902
+ targets and unknown states are left to ``set_state`` and its caller so their
903
+ existing graceful-degradation messages stay unchanged. Re-done is also left
904
+ to ``set_state`` as the established safe no-op.
905
+ """
906
+ if state not in ("in_progress", "done"):
907
+ return None
908
+ lines = text.splitlines()
909
+ idx = _find_task_line(lines, target)
910
+ if idx < 0:
911
+ return None
912
+ task = parse_board(lines[idx])[0]
913
+ if state == "done" and task.state == "queued":
914
+ return f"cannot mark '{target}' done: task is queued; start it first"
915
+ if state == "in_progress" and task.state == "done":
916
+ return f"cannot start '{target}': task is already done"
917
+ if state == "in_progress" and task.state == "in_progress":
918
+ return f"no change: '{target}' is already in_progress"
919
+ return None
920
+
921
+
922
+ def set_state(text, target, state, today, *, assign=None):
923
+ """Flip a task's marker and stamp the matching date. `target` is a dotted id
924
+ or the title of an ID-less item (use the id when the desc contains parentheses
925
+ — title matching is best-effort). `in_progress` ALWAYS stamps `(started …)`;
926
+ `done` accepts only an in-progress task and stamps `(done …)`. With
927
+ `assign="group.type"` on an ID-less target,
928
+ mints the dotted ID at pick-up. A queued-to-done transition is rejected, a
929
+ re-`done` is a no-op, and a missing target
930
+ returns the text unchanged (no raise). An unknown `state` value degrades
931
+ gracefully: returns `text` unchanged rather than raising KeyError (coding
932
+ standard: never raise on malformed user input — this is a hook-stdin path).
933
+
934
+ Valid target states: "in_progress", "done"."""
935
+ if (state not in ("in_progress", "done")
936
+ or (assign is not None and not validate_id(f"{assign}.0000"))
937
+ or transition_error(text, target, state)):
938
+ return text
939
+ lines = text.splitlines()
940
+ idx = _find_task_line(lines, target)
941
+ if idx < 0:
942
+ return text
943
+ raw = lines[idx]
944
+ t = parse_board(raw)[0]
945
+ if state == "done" and t.state == "done":
946
+ return text
947
+ m = re.match(r"^- (?:\[[ xX~]\] )?(.*)$", raw)
948
+ rest = m.group(1) if m else raw[2:]
949
+ if assign and t.id is None and "." in assign: # mint a dotted ID on pick-up
950
+ g, ty = assign.split(".", 1)
951
+ rest = f"{g}.{ty}.{next_seq(text, g, ty):04d} - {rest}"
952
+ rest = re.sub(r"\s*\((?:started|done)\s+[^)]*\)", "", rest).rstrip() # drop old stamp, keep the rest
953
+ line = f"- [{_MARK_BY_STATE[state]}] {rest}"
954
+ if state == "in_progress":
955
+ line += f" (started {today.isoformat()})"
956
+ elif state == "done":
957
+ line += f" (done {today.isoformat()})"
958
+ lines[idx] = line
959
+ return _join(lines, text)
960
+
961
+
962
+ def already_promoted(text, origin):
963
+ """True iff an OPEN (non-done) entry already carries `(from <origin>)`."""
964
+ needle = f"(from {origin})"
965
+ return any(_TOP_RE.match(ln) and not _DONE_RE.match(ln) and needle in ln
966
+ for ln in text.splitlines())
967
+
968
+
969
+ # ---------------------------------------------------------------------------
970
+ # Harvest extractors (pure: artifact text -> candidate list)
971
+ # ---------------------------------------------------------------------------
972
+
973
+ def extract_needs_triage(text, origin):
974
+ """Candidates from `[NEEDS-TRIAGE]` markers (tdd / brainstorming /
975
+ writing-plans / commit-gate residue). kind=work."""
976
+ out = []
977
+ for ln in (text or "").splitlines():
978
+ if "[NEEDS-TRIAGE]" in ln:
979
+ desc = ln.split("[NEEDS-TRIAGE]", 1)[1].strip(" \t-:")
980
+ out.append(Candidate("work", desc, f"{origin}#triage-{len(out) + 1}", []))
981
+ return out
982
+
983
+
984
+ def extract_deferrable(text, origin):
985
+ """Candidates from a checkpoint doc's `### DEFERRABLE` section. The real
986
+ checkpoint-aggregator emits a markdown TABLE (`| Finding | Source | Severity |`);
987
+ a hand-written bullet list is also accepted. The heading must START with
988
+ DEFERRABLE (so a prose `###` mentioning the word doesn't trigger), and only
989
+ column-0 bullets / table rows are taken (nested sub-bullets are ignored).
990
+ kind=work (re-tag to decision at the confirm step if it is really a decision)."""
991
+ out, in_def = [], False
992
+ for ln in (text or "").splitlines():
993
+ s = ln.strip()
994
+ if s.startswith("###"):
995
+ in_def = s.lstrip("# ").upper().startswith("DEFERRABLE")
996
+ continue
997
+ if not in_def:
998
+ continue
999
+ if s.startswith("|"): # table row
1000
+ cells = [c.strip() for c in s.strip("|").split("|")]
1001
+ first = cells[0] if cells else ""
1002
+ if not first or first.lower() == "finding" or set(first) <= set("-: "):
1003
+ continue # header / separator row
1004
+ desc = first
1005
+ elif ln.startswith("- ") or ln.startswith("* "): # column-0 bullet only
1006
+ desc = ln[2:].strip()
1007
+ else:
1008
+ continue
1009
+ out.append(Candidate("work", desc, f"{origin}#deferrable-{len(out) + 1}", []))
1010
+ return out
1011
+
1012
+
1013
+ def extract_low_confidence(text, origin):
1014
+ """Candidates from `sprint-log.md` `confidence: low` auto-decisions. kind=work."""
1015
+ out = []
1016
+ for ln in (text or "").splitlines():
1017
+ if ln.lstrip().startswith("#") and "confidence: low" in ln.lower():
1018
+ title = re.split(r"·\s*confidence:\s*low", ln.lstrip("#").strip(),
1019
+ flags=re.I)[0].strip()
1020
+ out.append(Candidate("work", title, f"{origin}#{len(out) + 1}", []))
1021
+ return out
1022
+
1023
+
1024
+ # ---------------------------------------------------------------------------
1025
+ # Promote (route + dedup + apply)
1026
+ # ---------------------------------------------------------------------------
1027
+
1028
+ def _add_deferred_decision(questions, desc, origin):
1029
+ block = f"- **(harvested)** {desc} (from {origin})"
1030
+ lines = questions.splitlines()
1031
+ for i, ln in enumerate(lines):
1032
+ if ln.strip().lower().startswith("## deferred decisions"):
1033
+ lines.insert(i + 1, block)
1034
+ return _join(lines, questions or "\n")
1035
+ base = questions if (questions == "" or questions.endswith("\n")) else questions + "\n"
1036
+ return f"{base}\n## Deferred decisions\n{block}\n"
1037
+
1038
+
1039
+ def promote(board, questions, candidates, *, mode, today):
1040
+ """Route follow-up candidates: work -> board, decision -> questions. Dedups by
1041
+ origin. mode="interactive" returns the fresh candidates WITHOUT mutating
1042
+ (caller confirms, then applies); mode="auto" applies and returns an audit.
1043
+
1044
+ Valid mode values: "interactive", "auto". Any other value raises ValueError
1045
+ so a typo (e.g. mode="dry-run") is caught immediately rather than silently
1046
+ applying all candidates to persistent state."""
1047
+ _VALID_MODES = ("interactive", "auto")
1048
+ if mode not in _VALID_MODES:
1049
+ raise ValueError(
1050
+ f"promote: unknown mode {mode!r}; expected one of {_VALID_MODES}"
1051
+ )
1052
+ fresh = []
1053
+ for c in candidates:
1054
+ if c.kind == "work" and already_promoted(board, c.origin):
1055
+ continue
1056
+ if c.kind == "decision" and f"(from {c.origin})" in questions:
1057
+ continue
1058
+ fresh.append(c)
1059
+ if mode == "interactive":
1060
+ return PromoteResult(fresh, board, questions, [], False)
1061
+ nb, nq, audit = board, questions, []
1062
+ for c in fresh:
1063
+ if c.kind == "work":
1064
+ nb = add_entry(nb, desc=c.desc, origin=c.origin,
1065
+ boundaries=(c.boundaries or None))
1066
+ audit.append(f"work -> open-tasks: {c.desc} (from {c.origin})")
1067
+ elif getattr(c, "blocking", False):
1068
+ # A blocking decision must GATE — it is never filed into the
1069
+ # non-gating Deferred-decisions section. Escalate for a [CONFIRM-NN].
1070
+ audit.append(f"ESCALATE (blocking decision — author a [CONFIRM-NN]): "
1071
+ f"{c.desc} (from {c.origin})")
1072
+ else:
1073
+ nq = _add_deferred_decision(nq, c.desc, c.origin)
1074
+ audit.append(f"decision -> open-questions: {c.desc} (from {c.origin})")
1075
+ return PromoteResult(fresh, nb, nq, audit, True)
1076
+
1077
+
1078
+ # ---------------------------------------------------------------------------
1079
+ # Thin file reader (not unit-tested — pure logic is tested with synthetic text)
1080
+ # ---------------------------------------------------------------------------
1081
+
1082
+ def read_board(path):
1083
+ """Read board text, or None if it cannot be read. Never raises."""
1084
+ try:
1085
+ with open(path, encoding="utf-8", errors="replace") as f:
1086
+ return f.read()
1087
+ except OSError:
1088
+ return None