@arbiterforge/ca-pi 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +558 -0
- package/package.json +35 -0
- package/plugins/ca-pi/CHANGELOG.md +1030 -0
- package/plugins/ca-pi/COMMANDS.md +90 -0
- package/plugins/ca-pi/ORCHESTRATOR.md +159 -0
- package/plugins/ca-pi/SKILLS.md +47 -0
- package/plugins/ca-pi/SPRINT.md +142 -0
- package/plugins/ca-pi/agents/INDEX.md +31 -0
- package/plugins/ca-pi/agents/architecture-drift-reviewer.md +86 -0
- package/plugins/ca-pi/agents/auth-crypto-reviewer.md +60 -0
- package/plugins/ca-pi/agents/backend-author.md +60 -0
- package/plugins/ca-pi/agents/checkpoint-aggregator.md +111 -0
- package/plugins/ca-pi/agents/coverage-auditor.md +71 -0
- package/plugins/ca-pi/agents/decision-challenger.md +116 -0
- package/plugins/ca-pi/agents/dependency-reviewer.md +79 -0
- package/plugins/ca-pi/agents/design-quality-reviewer.md +80 -0
- package/plugins/ca-pi/agents/finding-triage.md +86 -0
- package/plugins/ca-pi/agents/frontend-author.md +64 -0
- package/plugins/ca-pi/agents/grader.md +173 -0
- package/plugins/ca-pi/agents/infra-author.md +64 -0
- package/plugins/ca-pi/agents/map-deps.md +35 -0
- package/plugins/ca-pi/agents/map-structure.md +37 -0
- package/plugins/ca-pi/agents/migration-reviewer.md +65 -0
- package/plugins/ca-pi/agents/scout.md +127 -0
- package/plugins/ca-pi/agents/security-reviewer.md +72 -0
- package/plugins/ca-pi/agents/tribunal-lens-reviewer.md +65 -0
- package/plugins/ca-pi/extensions/codearbiter-child.js +1885 -0
- package/plugins/ca-pi/extensions/codearbiter.js +9802 -0
- package/plugins/ca-pi/generated/command-catalog.json +197 -0
- package/plugins/ca-pi/generated/roles.json +213 -0
- package/plugins/ca-pi/helpers/windows-supervisor.js +205 -0
- package/plugins/ca-pi/hooks/_activationlib.py +196 -0
- package/plugins/ca-pi/hooks/_arbiterstatelib.py +208 -0
- package/plugins/ca-pi/hooks/_babysitlib.py +76 -0
- package/plugins/ca-pi/hooks/_bashguardlib.py +1667 -0
- package/plugins/ca-pi/hooks/_boxlib.py +131 -0
- package/plugins/ca-pi/hooks/_colorlib.py +304 -0
- package/plugins/ca-pi/hooks/_durabilitylib.py +186 -0
- package/plugins/ca-pi/hooks/_entrylib.py +41 -0
- package/plugins/ca-pi/hooks/_fmtlib.py +161 -0
- package/plugins/ca-pi/hooks/_gitexec.py +45 -0
- package/plugins/ca-pi/hooks/_githooks.py +920 -0
- package/plugins/ca-pi/hooks/_gitlib.py +110 -0
- package/plugins/ca-pi/hooks/_hooklib.py +595 -0
- package/plugins/ca-pi/hooks/_host.py +115 -0
- package/plugins/ca-pi/hooks/_intentlib.py +242 -0
- package/plugins/ca-pi/hooks/_ledgerlib.py +1035 -0
- package/plugins/ca-pi/hooks/_metricslib.py +709 -0
- package/plugins/ca-pi/hooks/_pathnorm.py +74 -0
- package/plugins/ca-pi/hooks/_planfilelib.py +664 -0
- package/plugins/ca-pi/hooks/_previewlib.py +193 -0
- package/plugins/ca-pi/hooks/_protectedlib.py +312 -0
- package/plugins/ca-pi/hooks/_protectedstatelib.py +411 -0
- package/plugins/ca-pi/hooks/_provenancelib.py +971 -0
- package/plugins/ca-pi/hooks/_prunelib.py +1398 -0
- package/plugins/ca-pi/hooks/_prunepolicy.py +235 -0
- package/plugins/ca-pi/hooks/_readinjectlib.py +1080 -0
- package/plugins/ca-pi/hooks/_releaselib.py +2657 -0
- package/plugins/ca-pi/hooks/_scopelib.py +262 -0
- package/plugins/ca-pi/hooks/_segmentslib.py +278 -0
- package/plugins/ca-pi/hooks/_sensitivelib.py +270 -0
- package/plugins/ca-pi/hooks/_sessionlib.py +78 -0
- package/plugins/ca-pi/hooks/_sloplib.py +244 -0
- package/plugins/ca-pi/hooks/_standuplib.py +214 -0
- package/plugins/ca-pi/hooks/_subagentslib.py +219 -0
- package/plugins/ca-pi/hooks/_taskboardlib.py +1088 -0
- package/plugins/ca-pi/hooks/_updatelib.py +278 -0
- package/plugins/ca-pi/hooks/babysit.py +47 -0
- package/plugins/ca-pi/hooks/boardsync.py +129 -0
- package/plugins/ca-pi/hooks/doctor.py +420 -0
- package/plugins/ca-pi/hooks/git-enforce.py +325 -0
- package/plugins/ca-pi/hooks/hostapi.py +460 -0
- package/plugins/ca-pi/hooks/init-codearbiter.py +225 -0
- package/plugins/ca-pi/hooks/metrics.py +62 -0
- package/plugins/ca-pi/hooks/migration-pass.py +129 -0
- package/plugins/ca-pi/hooks/pi-bridge.py +543 -0
- package/plugins/ca-pi/hooks/post-write-edit.py +231 -0
- package/plugins/ca-pi/hooks/pre-bash.py +90 -0
- package/plugins/ca-pi/hooks/pre-edit.py +284 -0
- package/plugins/ca-pi/hooks/pre-read.py +81 -0
- package/plugins/ca-pi/hooks/pre-write.py +217 -0
- package/plugins/ca-pi/hooks/preview.py +69 -0
- package/plugins/ca-pi/hooks/prune-transcript.py +232 -0
- package/plugins/ca-pi/hooks/releasehash.py +216 -0
- package/plugins/ca-pi/hooks/security-pass.py +139 -0
- package/plugins/ca-pi/hooks/session-start.py +1218 -0
- package/plugins/ca-pi/hooks/statusline.py +736 -0
- package/plugins/ca-pi/hooks/taskwrite.py +351 -0
- package/plugins/ca-pi/hooks/update-refresh.py +51 -0
- package/plugins/ca-pi/hooks/wire-statusline.py +435 -0
- package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +55 -0
- package/plugins/ca-pi/includes/anti-slop-design/color.md +43 -0
- package/plugins/ca-pi/includes/anti-slop-design/core.md +244 -0
- package/plugins/ca-pi/includes/anti-slop-design/images.md +32 -0
- package/plugins/ca-pi/includes/anti-slop-design/layout.md +45 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-cli.md +39 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-dataviz.md +43 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-diagram.md +35 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-documents.md +70 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-slides.md +30 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-web.md +39 -0
- package/plugins/ca-pi/includes/anti-slop-design/typography.md +51 -0
- package/plugins/ca-pi/includes/author-tdd-workflow.md +14 -0
- package/plugins/ca-pi/includes/compaction-charter.md +16 -0
- package/plugins/ca-pi/includes/cut-docs.md +16 -0
- package/plugins/ca-pi/includes/dev-mode.md +30 -0
- package/plugins/ca-pi/includes/farm.md +237 -0
- package/plugins/ca-pi/includes/fresh-verification.md +14 -0
- package/plugins/ca-pi/includes/harvest.md +69 -0
- package/plugins/ca-pi/includes/maturity-coverage.md +102 -0
- package/plugins/ca-pi/includes/pi-host-notes.md +69 -0
- package/plugins/ca-pi/includes/redirect.md +69 -0
- package/plugins/ca-pi/includes/reference-map.md +22 -0
- package/plugins/ca-pi/includes/review-matrix.md +14 -0
- package/plugins/ca-pi/includes/reviewer-contract.md +53 -0
- package/plugins/ca-pi/includes/routing-table.md +47 -0
- package/plugins/ca-pi/includes/security-gate-record.md +22 -0
- package/plugins/ca-pi/includes/smarts/core.md +90 -0
- package/plugins/ca-pi/includes/smarts/decision-log-format.md +56 -0
- package/plugins/ca-pi/routines/INDEX.md +32 -0
- package/plugins/ca-pi/routines/brainstorming/SKILL.md +122 -0
- package/plugins/ca-pi/routines/commit-gate/SKILL.md +151 -0
- package/plugins/ca-pi/routines/context-check/SKILL.md +85 -0
- package/plugins/ca-pi/routines/context-creation/SKILL.md +171 -0
- package/plugins/ca-pi/routines/crypto-compliance/SKILL.md +41 -0
- package/plugins/ca-pi/routines/debug/SKILL.md +99 -0
- package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +104 -0
- package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +74 -0
- package/plugins/ca-pi/routines/decision-variance/SKILL.md +147 -0
- package/plugins/ca-pi/routines/decompose/SKILL.md +168 -0
- package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +76 -0
- package/plugins/ca-pi/routines/executing-plans/SKILL.md +83 -0
- package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +91 -0
- package/plugins/ca-pi/routines/post-merge-cleanup/SKILL.md +233 -0
- package/plugins/ca-pi/routines/refactor/SKILL.md +91 -0
- package/plugins/ca-pi/routines/release/SKILL.md +315 -0
- package/plugins/ca-pi/routines/secret-handling/SKILL.md +67 -0
- package/plugins/ca-pi/routines/security-architecture/SKILL.md +63 -0
- package/plugins/ca-pi/routines/skill-author/SKILL.md +108 -0
- package/plugins/ca-pi/routines/skill-author/references/skill-template.md +58 -0
- package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +149 -0
- package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +145 -0
- package/plugins/ca-pi/routines/tdd/SKILL.md +139 -0
- package/plugins/ca-pi/routines/tribunal/SKILL.md +109 -0
- package/plugins/ca-pi/routines/tribunal/references/ai-markers.md +29 -0
- package/plugins/ca-pi/routines/tribunal/references/cost-and-models.md +64 -0
- package/plugins/ca-pi/routines/tribunal/references/finding-record.md +27 -0
- package/plugins/ca-pi/routines/tribunal/references/issue-filing.md +47 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/appsec.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/architecture.md +23 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/coverage.md +20 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/infra.md +24 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/migration.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/observability.md +21 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/performance.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/reliability.md +23 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/secrets-supply.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/test-fidelity.md +24 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/typesafety.md +21 -0
- package/plugins/ca-pi/routines/tribunal/references/report.md +19 -0
- package/plugins/ca-pi/routines/tribunal/references/schemas.md +58 -0
- package/plugins/ca-pi/routines/tribunal/references/telemetry.md +28 -0
- package/plugins/ca-pi/routines/tribunal/references/triage.md +53 -0
- package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +85 -0
- package/plugins/ca-pi/routines/writing-plans/SKILL.md +129 -0
- package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +50 -0
- package/plugins/ca-pi/skills/ca-add-dep/SKILL.md +88 -0
- package/plugins/ca-pi/skills/ca-adr/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-adr-status/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +36 -0
- package/plugins/ca-pi/skills/ca-audit/SKILL.md +51 -0
- package/plugins/ca-pi/skills/ca-btw/SKILL.md +23 -0
- package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +50 -0
- package/plugins/ca-pi/skills/ca-chore/SKILL.md +58 -0
- package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +55 -0
- package/plugins/ca-pi/skills/ca-commands/SKILL.md +21 -0
- package/plugins/ca-pi/skills/ca-commit/SKILL.md +27 -0
- package/plugins/ca-pi/skills/ca-conflict/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-context-check/SKILL.md +32 -0
- package/plugins/ca-pi/skills/ca-create-context/SKILL.md +32 -0
- package/plugins/ca-pi/skills/ca-debug/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-decompose/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-dev/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-doctor/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-feature/SKILL.md +105 -0
- package/plugins/ca-pi/skills/ca-fix/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-init/SKILL.md +56 -0
- package/plugins/ca-pi/skills/ca-metrics/SKILL.md +80 -0
- package/plugins/ca-pi/skills/ca-new-skill/SKILL.md +34 -0
- package/plugins/ca-pi/skills/ca-override/SKILL.md +72 -0
- package/plugins/ca-pi/skills/ca-pr/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-preview/SKILL.md +86 -0
- package/plugins/ca-pi/skills/ca-prune/SKILL.md +100 -0
- package/plugins/ca-pi/skills/ca-reconcile/SKILL.md +43 -0
- package/plugins/ca-pi/skills/ca-refactor/SKILL.md +43 -0
- package/plugins/ca-pi/skills/ca-release/SKILL.md +57 -0
- package/plugins/ca-pi/skills/ca-review/SKILL.md +63 -0
- package/plugins/ca-pi/skills/ca-spike/SKILL.md +41 -0
- package/plugins/ca-pi/skills/ca-sprint/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-standup/SKILL.md +112 -0
- package/plugins/ca-pi/skills/ca-status/SKILL.md +56 -0
- package/plugins/ca-pi/skills/ca-task/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-threat-model/SKILL.md +45 -0
- package/plugins/ca-pi/skills/ca-tribunal/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-watch/SKILL.md +78 -0
|
@@ -0,0 +1,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
|