@erclx/aitk 0.97.0 → 0.98.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "0.97.0",
4
+ "version": "0.98.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -109,7 +109,7 @@ Write no shape for a correction. A correction is a sentence, and a format for ad
109
109
  5. Hand off. The human opens a worker worktree with `claude-worktree` and runs `claude-autoship` against the plan. The orchestrator does not spawn workers.
110
110
  6. Review the PR. When a worker opens a PR, run `claude-pr-review` to post findings to it. This is the deep, independent pass. The worker's autoship self-review was only the green gate.
111
111
  - Learning that a PR moved is the mechanical half, so read `${CLAUDE_SKILL_DIR}/references/orchestrator-poll.md` and start the poll it carries on the first dispatch rather than checking the board by hand. That runbook holds the routing, and a summary of it here is a second source that drifts from it.
112
- 7. Dispatch the handback. A pass posting a critical or should-fix finding tells the session holding that branch to run `claude-address-review`, rather than waiting for a person to relay it. Re-review when the answer lands, then the human merges. Tell the trailing worker to rebase when its branch shares a seam with the merged one.
112
+ 7. Dispatch the handback. A pass posting a finding at any severity tells the session holding that branch to run `claude-address-review`, rather than waiting for a person to relay it. Read that off the counts on the summary line rather than off the heading, since a minors-only pass closes the heading and still owes a dispatch. Re-review when the answer lands, then the human merges. Tell the trailing worker to rebase when its branch shares a seam with the merged one.
113
113
  - Send only above that floor. A minor posts under the same heading and is visibility alone, so a rule keyed on any open finding sends a worker to act on a note
114
114
  - Resolve the target from a fresh session listing at the moment of sending, never from a mapping written down earlier, since names rotate as sessions end and one recorded earlier in a session has failed inside the hour
115
115
  - Open the message with the worktree and branch the sender believes the reader holds, asking to be corrected, whenever that mapping is inferred rather than confirmed
@@ -1,17 +1,17 @@
1
1
  ---
2
2
  title: Orchestrator parked row runbook
3
- description: Re-testing every parked blocker against the current tree, the trigger that separates this pass from the merge sweep, and the two ways a re-test goes wrong
3
+ description: Re-testing every parked blocker against the current tree, the two triggers that start the pass, and the two ways a re-test goes wrong
4
4
  ---
5
5
 
6
6
  Re-test every parked row as orchestrator. A blocker cell is a measurement taken the day the row was parked and nothing re-takes it, so a row can wait on a condition that stopped holding weeks earlier with no surface reporting the gap.
7
7
 
8
- Run this when nothing merged and the board is not moving. Workers are building, no pull request is waiting on a first pass, and the session is otherwise idle.
8
+ Two triggers start this pass. `orchestrator-sweep.md` ends by sending the rows `aitk tasks validate` listed as untested here, because a merge changes the tree under every parked row at once rather than under the rows naming it. The other is an idle session: nothing merged, workers are building, no pull request is waiting on a first pass, and the board is not moving.
9
9
 
10
- That trigger is the inverse of the sweep's. `orchestrator-sweep.md` runs after a batch of merges and asks which parked row to promote next, taking the blocker cell as read. This pass asks whether that cell is still true. Both walk the same rows and both can end in a promotion, so a session reading them as one pass runs whichever it remembers and re-tests nothing.
10
+ The two differ in scope rather than in procedure. A merge sends the untested rows, since the validator already re-took the two kinds it can settle. An idle session walks every parked row, because no event narrowed which of them to look at.
11
11
 
12
- A merge is still worth following with this pass even though the sweep already ran. A merge changes the tree under every parked row at once rather than under the rows naming it, and the sweep re-reads none of them.
12
+ Neither trigger is a scheduler. `orchestrator-poll.md` owns the one recurring trigger this skill has, and a second loop firing into a static board is the always-on failure that file already warns about.
13
13
 
14
- Nothing starts this pass, and it takes no scheduler of its own. `orchestrator-poll.md` owns the one recurring trigger this skill has, and a second loop firing into a static board is the always-on failure that file already warns about.
14
+ The sweep's own question stays distinct from this one. It asks which parked row to promote next, taking the blocker cell as read, and this pass asks whether that cell is still true. A session collapsing them runs whichever it remembers and re-tests nothing.
15
15
 
16
16
  ## Scope
17
17
 
@@ -21,10 +21,10 @@ Take the rows in board order and finish one before opening the next. Clearing a
21
21
 
22
22
  ## Re-testing a row
23
23
 
24
- The blocker cell states what the row waits on, and each kind is tested differently.
24
+ The blocker cell states what the row waits on, and each kind is tested differently. `aitk tasks validate` already re-takes the first two and reports the rest as untested, so run it first and re-take by hand only what it names.
25
25
 
26
- - Collision with a track in flight: intersect the row's Touches column with that of every `## Run now` row. A track that merged since the row was parked is no longer in flight, whatever the sets still share. A `## Needs a plan` row carries no Touches column, so read its file set off the task file before claiming a collision either way.
27
- - A dependency on another task: open that task. One whose outcomes are all `[x]`, or one already archived, holds nothing.
26
+ - Collision with a track in flight: the validator tests the file the cell cites against the Touches column of every `## Run now` row. A track that merged since the row was parked is no longer in flight, whatever the sets still share. A cell naming the file in prose rather than in backticks cites nothing, so write the collision the way the board format spells it and the check picks the row up on the next run.
27
+ - A dependency on another task: the validator opens the task a link in the cell names. One whose outcomes are all `[x]`, or one already archived, holds nothing. A cell naming the task in prose resolves to no file, so open it by hand and rewrite the cell as a link.
28
28
  - A condition about the tree, such as a count of some shape or the presence of a construct: measure it again, per Two ways a re-test goes wrong below.
29
29
  - Waiting on a plan: nothing external holds the row, so the pass writes the plan rather than testing anything. See The plan half below.
30
30
  - Waiting on an operator action, such as a run that happens from a shell: record it as untestable this pass and name what the operator has to do. A session cannot clear it, and re-measuring it every pass is waste.
@@ -34,12 +34,13 @@ Poll GitHub for pull request movement by running <POLL_SCRIPT>, then act on what
34
34
  - MOVED or RESPONSE on a pull request I have already reviewed: run the aitk:claude-pr-review skill on it immediately, narrow pass. Re-reviews read prior..head and gain nothing from waiting.
35
35
  - OPENED, or a pull request with no prior review pass: run the aitk:claude-pr-review skill on it. A draft counts, since every pull request here opens as one and skipping drafts skips everything.
36
36
  - SEEN: report it and stop. A pass already covers that head, whether it arrived out of band or before the poll first saw the pull request, so no review follows.
37
+ - STALLED: read the last pass and report what it carried. The pass has sat open for hours with nothing following it, so a worker mid-task is already ruled out and the dispatch either never went out or the session holding it is gone. Confirm and re-send it under the dispatch rule below. A pass carrying only minors is also under the wrong heading, so say so, and dispatch it either way. Do not re-run a review to correct the heading, since a pass on an unchanged head with no response behind it stops by design.
37
38
  - CONFLICT: report it and stop. The branch owner rebases, not this session.
38
39
  - GONE: report it, then sweep the board by invoking the aitk:claude-orchestrate skill and following its queue-refill sweep.
39
40
  - A line starting `poll:`: report it verbatim and treat that pull request as unread this run. It is a failed query, not a state.
40
41
  - Nothing changed: say exactly "No movement." and nothing else.
41
42
 
42
- After any pass that posts a critical or should-fix finding, tell the session holding that branch to run the aitk:claude-address-review skill. Resolve the target by running `aitk sessions list --branch <branch> --json` at that moment, which scopes the match to this repository, then route on how many sessions it returned. Zero: report the invocation for me and dispatch nobody. Exactly one, with the confidence field reading "confirmed": address that name directly. Any other count, any other confidence, or a command that is missing or refuses: fall back to picking from a session listing, open by naming the worktree and branch you believe the reader holds, and ask to be corrected. Two sessions can hold one branch, so read the count rather than the first row. A pass carrying only minor findings dispatches nobody.
43
+ After any pass that posts a finding, at any severity, tell the session holding that branch to run the aitk:claude-address-review skill. Resolve the target by running `aitk sessions list --branch <branch> --json` at that moment, which scopes the match to this repository, then route on how many sessions it returned. Zero: report the invocation for me and dispatch nobody. Exactly one, with the confidence field reading "confirmed": address that name directly. Any other count, any other confidence, or a command that is missing or refuses: fall back to picking from a session listing, open by naming the worktree and branch you believe the reader holds, and ask to be corrected. Two sessions can hold one branch, so read the count rather than the first row. The heading does not decide this: a minors-only pass posts the closed heading and still owes a dispatch, so read the counts on the summary line rather than the heading above them.
43
44
  ```
44
45
 
45
46
  ## Reading the output
@@ -50,4 +51,8 @@ The script exits non-zero and classifies nothing when the open pull request list
50
51
 
51
52
  The baseline lives at `.claude/.tmp/pr-poll/baseline.txt` under the main worktree root and is per-machine. A first run against a board already in flight reports each open pull request once before it settles.
52
53
 
53
- The four review headings the script matches are written by `claude-pr-review` and `claude-address-review`. A project that posts its reviews under different headings edits the two jq filters in the script to match, or every pull request reads as never reviewed.
54
+ The four review headings the script matches are written by `claude-pr-review` and `claude-address-review`. A project that posts its reviews under different headings edits the three jq filters in the script to match, or every pull request reads as never reviewed.
55
+
56
+ `STALLED` is the one state the script derives from a heading rather than from a commit or a count, since `claude-pr-review` posts the open heading only for a pass carrying a critical or a should-fix. The heading alone cannot carry it, because an open pass means a dispatch was owed and made, so the ordinary healthy thread is a worker still working. The age of that pass is the third test: the state fires when the open pass covers the head, nothing has followed it, and it is older than the `STALE_AFTER` seconds set at the top of the script. It reports once per entry and fires again after any commit or reply resets the thread. A project whose workers run longer than the default two hours raises that number.
57
+
58
+ What the state does not reach is a dispatch owed on a minor. The dispatch fires on a finding at any severity and the heading closes on a blocking one, so a minors-only pass owes a worker under a closed heading, and this state reads the open heading alone. It therefore catches a stalled blocking finding and never a stalled minor. That is a narrowing rather than a defect, and closing it would mean reading the counts off the summary line, which pins a second string this script does not own.
@@ -10,7 +10,9 @@ Sweep the board as orchestrator after merging. Run this once per batch of merges
10
10
  3. Re-verify every plan already written, not only the ones this sweep writes. A queued plan goes stale from whatever merged while it waited, and the loop's verify step fires at handoff rather than after a merge, so nothing else catches it. Grep each construct the plan names and count the sites against its claim, then open each file rather than trusting its account.
11
11
  4. Re-check any precondition a plan states about live state outside the repository. A remote branch, an open issue, or an installed version was true when the plan was written and is not a fact about the tree.
12
12
 
13
- Run `aitk tasks validate` once the board is rewritten and before reporting it. It resolves every plan pointer, maps rows against task files both ways, and tests the `## Run now` file sets for overlap, which is the half of the readiness claim a reading of the board cannot check. It reports and never writes, so fix each row it names and run it again. A finding it reports is a board defect rather than a task finding, so it goes nowhere but the board.
13
+ Run `aitk tasks validate` once the board is rewritten and before reporting it. It resolves every plan pointer, maps rows against task files both ways, tests the `## Run now` file sets for overlap, and re-takes the two blocker kinds a command can settle. It reports and never writes, so fix each row it names and run it again. A finding it reports is a board defect rather than a task finding, so it goes nowhere but the board.
14
+
15
+ Then run `orchestrator-parked.md` over the rows the validator listed as untested. Those carry the three blocker kinds no command settles, and the merge this sweep followed changed the tree under every one of them at once while the refill above re-read none. Take the untested rows alone rather than the whole board, since the validator already answered the rest.
14
16
 
15
17
  Place every finding the merged work produced before promoting anything. A finding that changes a rule goes to the standard or the rule that states it, one that changes another task goes to that task's Findings, and one that overturns a groundwork lean gets marked answered in that folder. A pull request body counts as nowhere, since the thread stops being read the moment it merges.
16
18
 
@@ -55,6 +55,44 @@ JQ_REPLY_COUNT='
55
55
  ] | length
56
56
  '
57
57
 
58
+ # `claude-pr-review` posts `## Review` only for a pass carrying a critical or a
59
+ # should-fix, so the heading of the last review is what says whether anything
60
+ # blocks the merge. Taking it as well as the commit is what separates a thread
61
+ # waiting on a worker from one a pass left open owing no dispatch. A project
62
+ # editing the two filters above for its own headings edits this one with them.
63
+ #
64
+ # The age of that review comes out of the same selection, because the heading
65
+ # alone cannot separate the two. Under the rule above an open heading means a
66
+ # dispatch was owed and made, so the ordinary healthy thread is a worker still
67
+ # working and every one of them would be reported minutes after the pass posted.
68
+ # jq computes the elapsed seconds itself, since `date -d` is GNU-only and this
69
+ # script runs wherever the plugin is installed. A review carrying no stamp reads
70
+ # as age zero and classifies nothing, which is the same answer the carry-forward
71
+ # path gives a pull request this run could not read.
72
+ JQ_LAST_REVIEW_STATE='
73
+ [ .reviews[]
74
+ | select((.body // "") | split("\n")[0] | rtrimstr("\r")
75
+ | . == "## Review" or . == "## Review closed")
76
+ ] | last
77
+ | if . == null then "none 0"
78
+ else ((.body | split("\n")[0] | rtrimstr("\r")
79
+ | if . == "## Review" then "open" else "closed" end)
80
+ + " "
81
+ + (if .submittedAt == null then "0"
82
+ else ((now - (.submittedAt | fromdateiso8601)) | floor | tostring)
83
+ end))
84
+ end
85
+ '
86
+
87
+ # An open pass this old has nobody on it. The cycle it has to clear is a review
88
+ # landing and a worker pushing a follow-up, measured between ten and thirty
89
+ # minutes across a day of runs on 2026-08-14, so two hours sits about four times
90
+ # past the slowest observed and still well inside a working session. Shorter
91
+ # starts catching the slow healthy case this threshold exists to exclude, and
92
+ # longer lets a dead dispatch sit past the point a person would have noticed.
93
+ # A project whose workers run longer than that raises it here.
94
+ STALE_AFTER=7200
95
+
58
96
  # A pull request this run could not read keeps the line it had, so the GONE
59
97
  # sweep below does not read the gap as a merge and the baseline does not lose
60
98
  # the head it already knew. Saying so on stderr is the point: a silent skip is
@@ -63,7 +101,14 @@ carry_forward() {
63
101
  local n=$1 reason=$2 old
64
102
  old=$(grep "^$n " "$STATE" || true)
65
103
  if [ -n "$old" ]; then
66
- echo "$old"
104
+ # Every other classification compares a field against itself on a carried
105
+ # line, so it fires nothing. STALLED reads a heading that is carried too, so
106
+ # the field is blanked to a value no branch matches. Echoing it intact would
107
+ # classify a pull request this run never reached while stderr below says the
108
+ # opposite, and one successful read restores it a run later. An already
109
+ # reported thread keeps its marker, or an unreadable run would let the same
110
+ # thread be reported a second time.
111
+ echo "$old" | awk '{ if ($6 != "reported") $6 = "carried"; print }'
67
112
  echo "poll: #$n $reason, so it keeps its last known state and goes unclassified" >&2
68
113
  else
69
114
  echo "poll: #$n $reason, and it has no last known state, so it goes unclassified" >&2
@@ -71,7 +116,7 @@ carry_forward() {
71
116
  }
72
117
 
73
118
  snapshot() {
74
- local numbers n payload head prior resp merges
119
+ local numbers n payload head prior resp merges review_state
75
120
  git fetch -q origin "$BASE_BRANCH" 2>/dev/null || true
76
121
 
77
122
  # A failed list reaches the caller as no open pull requests, and that reports
@@ -102,6 +147,9 @@ snapshot() {
102
147
 
103
148
  prior=$(jq -r "$JQ_LAST_REVIEWED_HEAD" <<<"$payload")
104
149
  resp=$(jq -r "$JQ_REPLY_COUNT" <<<"$payload")
150
+ # Two space-separated fields, so the line below carries them as its own
151
+ # sixth and seventh rather than needing a split.
152
+ review_state=$(jq -r "$JQ_LAST_REVIEW_STATE" <<<"$payload")
105
153
 
106
154
  # `gh pr view --json mergeable` reports UNKNOWN until GitHub finishes
107
155
  # computing it, which is exactly when a poll asks. merge-tree answers
@@ -120,7 +168,7 @@ snapshot() {
120
168
  merges=conflict
121
169
  fi
122
170
 
123
- echo "$n $head ${prior:-none} $resp $merges"
171
+ echo "$n $head ${prior:-none} $resp $merges $review_state"
124
172
  done
125
173
  }
126
174
 
@@ -131,9 +179,15 @@ if ! NEW=$(snapshot); then
131
179
  exit 1
132
180
  fi
133
181
  CHANGED=0
182
+ # The baseline is written from here rather than from the snapshot, because the
183
+ # heading field is the one column a classification rewrites. STALLED reports a
184
+ # standing condition rather than a transition, so without a written marker it
185
+ # would fire on every later run and the board would never read "No movement."
186
+ FINAL=""
134
187
 
135
- while read -r n head prior resp merges; do
188
+ while read -r n head prior resp merges heading age; do
136
189
  [ -z "$n" ] && continue
190
+ state=$heading
137
191
  old=$(grep "^$n " "$STATE" || true)
138
192
  if [ -z "$old" ]; then
139
193
  # A pull request first seen here may already carry a pass, when it opened
@@ -149,11 +203,17 @@ while read -r n head prior resp merges; do
149
203
  echo "OPENED #$n at ${head:0:7}, $merges against $BASE_BRANCH"
150
204
  fi
151
205
  CHANGED=1
206
+ FINAL+="$n $head $prior $resp $merges $state $age"$'\n'
152
207
  continue
153
208
  fi
154
209
  old_head=$(echo "$old" | cut -d' ' -f2)
155
210
  old_resp=$(echo "$old" | cut -d' ' -f4)
156
211
  old_merges=$(echo "$old" | cut -d' ' -f5)
212
+ # Only the marker is read off this field, so a baseline written before it
213
+ # existed yields empty and reads as not yet reported, which is what it is. The
214
+ # age decides the rest, so the first run after an upgrade needs no history and
215
+ # classifies a thread already past the threshold rather than waiting a run.
216
+ old_heading=$(echo "$old" | cut -d' ' -f6)
157
217
 
158
218
  # A conflict arrives from the base moving, not from the branch, so it is
159
219
  # reported on the transition rather than only when the head changes.
@@ -187,7 +247,26 @@ while read -r n head prior resp merges; do
187
247
  elif [ "$resp" -gt "$old_resp" ]; then
188
248
  echo "RESPONSE #$n answered with no new commit"
189
249
  CHANGED=1
250
+ elif [ "$heading" = open ] && [ "$old_heading" != reported ] &&
251
+ [ "$prior" = "$head" ] && [ "$age" -ge "$STALE_AFTER" ]; then
252
+ # Three things at once: the last pass is open, it covers the head so no
253
+ # commit followed it, and the two branches above found no reply either. The
254
+ # open heading is posted only for a critical or should-fix, so a pass owing
255
+ # no dispatch left it here. Any two of these describe an ordinary review
256
+ # waiting on a worker, which is why the age carries the third: without it
257
+ # every dispatched worker is reported minutes into the work it was sent to
258
+ # do, and a signal firing on the healthy path is one an operator learns to
259
+ # skip. The report still asks for a confirmation rather than asserting.
260
+ echo "STALLED #$n open at ${head:0:7}, no commit or reply in $((age / 3600))h"
261
+ state=reported
262
+ CHANGED=1
263
+ elif [ "$heading" = open ] && [ "$old_heading" = reported ]; then
264
+ # Nothing above fired, so the thread has not moved since it was reported.
265
+ # Carry the marker rather than the freshly derived heading, or the state
266
+ # re-enters next run and STALLED oscillates instead of reporting once.
267
+ state=reported
190
268
  fi
269
+ FINAL+="$n $head $prior $resp $merges $state"$'\n'
191
270
  done <<<"$NEW"
192
271
 
193
272
  while read -r n _rest; do
@@ -199,4 +278,4 @@ while read -r n _rest; do
199
278
  done <"$STATE"
200
279
 
201
280
  [ "$CHANGED" -eq 0 ] && echo "No movement."
202
- echo "$NEW" >"$STATE"
281
+ printf '%s' "$FINAL" >"$STATE"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: claude-pr-review
3
- description: Reviews an open pull request from an independent session and posts findings as a review comment on the PR. Posts a first pass against the whole change and every later pass against only the commits added since, under `## Review` while anything is open and `## Review closed` once nothing is. Reads project docs and the roadmap for cross-feature context a self-review lacks. Use when asked to "review the PR", "review this feature's PR", "post a PR review", "re-review the PR", "close out the review", "confirm the findings are fixed", or acting as the orchestrator reviewing a worker's PR. Do NOT use to review local uncommitted changes. That is `claude-review`.
3
+ description: Reviews an open pull request from an independent session and posts findings as a review comment on the PR. Posts a first pass against the whole change and every later pass against only the commits added since, under `## Review` while a critical or should-fix is open and `## Review closed` once nothing blocks the merge. Reads project docs and the roadmap for cross-feature context a self-review lacks. Use when asked to "review the PR", "review this feature's PR", "post a PR review", "re-review the PR", "close out the review", "confirm the findings are fixed", or acting as the orchestrator reviewing a worker's PR. Do NOT use to review local uncommitted changes. That is `claude-review`.
4
4
  ---
5
5
 
6
6
  # Claude PR review
@@ -13,10 +13,10 @@ findings to the PR, so the vantage is independent and the output is durable.
13
13
  It posts at least twice over a pull request's life. A first pass opens the
14
14
  review against the whole change, and every later pass reads only the commits
15
15
  added since. The heading reports state rather than pass number: a pass carrying
16
- findings takes `## Review`, and `## Review closed` is reserved for a pass
17
- carrying none, so the most recent comment's heading reports where the review
18
- stands. Every pass is this skill, and which one it is gets detected from the
19
- thread rather than named by the caller.
16
+ a critical or a should-fix takes `## Review`, and `## Review closed` covers
17
+ every other pass, so the most recent comment's heading reports whether anything
18
+ blocks the merge. Every pass is this skill, and which one it is gets detected
19
+ from the thread rather than named by the caller.
20
20
 
21
21
  ## Guards
22
22
 
@@ -85,9 +85,9 @@ Review the diff and files for the same axes as `claude-review` (bugs, edge cases
85
85
 
86
86
  Apply the high-signal filter: flag only what will cause incorrect behavior, break a documented rule, or mislead a downstream feature. If uncertain, do not flag.
87
87
 
88
- A later pass applies the same axes to the delta, and adds one check the first pass cannot make: did each prior finding land, and did the fix regress anything it touched. Findings of its own are normal findings, stated at the same severity and counted the same way. That count is what Step 4 reads to pick the heading, so a pass raising one of its own is not a close-out.
88
+ A later pass applies the same axes to the delta, and adds one check the first pass cannot make: did each prior finding land, and did the fix regress anything it touched. Findings of its own are normal findings, stated at the same severity and counted the same way. That count is what Step 4 reads to pick the heading, so a pass raising a critical or a should-fix of its own is not a close-out.
89
89
 
90
- A prior finding can also be settled by argument rather than by a fix. A reply naming the plan question that already declined it, or a constraint this session could not see, withdraws the finding or moves its grade. State that outcome in the body under the finding it changes, naming the fact that produced it, whether the argument arrived on the thread or through the channel that carried the dispatch. Dropping the finding from this body instead leaves a reader unable to tell a withdrawal from an oversight, and the reasoning goes with the session that heard it. A withdrawal removes the finding from the count, so a pass that withdrew every open item is a close-out. Write the withdrawal and its cause into that body rather than taking the short close-out line Step 4 supplies, which reports prior findings addressed and would credit a fix nobody made.
90
+ A prior finding can also be settled by argument rather than by a fix. A reply naming the plan question that already declined it, or a constraint this session could not see, withdraws the finding or moves its grade. State that outcome in the body under the finding it changes, naming the fact that produced it, whether the argument arrived on the thread or through the channel that carried the dispatch. Dropping the finding from this body instead leaves a reader unable to tell a withdrawal from an oversight, and the reasoning goes with the session that heard it. A withdrawal removes the finding from the count, so a pass that withdrew every critical and should-fix is a close-out. Write the withdrawal and its cause into that body rather than taking the short close-out line Step 4 supplies, which reports prior findings addressed and would credit a fix nobody made.
91
91
 
92
92
  Use severity: `critical` (blocks merge), `should-fix` (fix before merge), `minor` (visibility only).
93
93
 
@@ -144,9 +144,15 @@ Re-reviewed `<short-sha>`, N commits since the prior pass. X critical, Y should-
144
144
  🤖 Reviewed by Claude Code
145
145
  ```
146
146
 
147
- The heading reports whether anything is open and the summary line reports which pass this is. Post under `## Review` whenever the pass carries a finding at any severity, whether it is the first pass or the fourth. Reserve `## Review closed` for a pass carrying none, which makes it the marker worth scanning for rather than a label on a kind of pass. A pull request thread then reads as `## Review`, the worker's answer under `## Review response` from `claude-address-review`, another `## Review` while anything is still open, and `## Review closed` when nothing is.
147
+ The heading reports whether anything blocks the merge and the summary line reports which pass this is. Post under `## Review` whenever the pass carries a critical or a should-fix, whether it is the first pass or the fourth. Post `## Review closed` on every other pass, a pass carrying only minors included, and list those minors under it as follow-ups. A pull request thread then reads as `## Review`, the worker's answer under `## Review response` from `claude-address-review`, another `## Review` while a critical or should-fix is still open, and `## Review closed` when neither is.
148
148
 
149
- Read the state off the most recent review comment rather than off the presence of a closed one. A close-out does not close the pull request, so a commit pushed after it gets its own pass, and that pass reopens the review under `## Review` when it raises anything.
149
+ The heading and the dispatch answer two different questions, so a close-out does not mean the pass owes nobody. The heading says whether anything blocks the merge and the dispatch says whether any work is owed, and a pass carrying only minors closes the heading while still sending the session holding the branch. Keying the dispatch on the grade too was measured wrong: across 8 findings on one archived pass, 3 were posted as minor and 2 of those were defects a worker fixed rather than recorded, so a floor at should-fix loses real fixes to a grade that runs low.
150
+
151
+ The cost is that a clean thread no longer reads off the heading alone, since a close-out may carry minors. Take the merge decision from the heading and the counts from the summary line under it, which is where they already sit.
152
+
153
+ A minor the dispatched worker declines is what needs a surface that survives the merge, rather than every minor, since one that gets fixed on the branch needs no durable record. Write a declined minor into the `## Findings` section of the task the branch closes, which is where the queue-refill sweep already routes a finding that changes another task. A declined finding left on the thread alone is lost the moment the pull request merges.
154
+
155
+ Read the state off the most recent review comment rather than off the presence of a closed one. A close-out does not close the pull request, so a commit pushed after it gets its own pass, and that pass reopens the review under `## Review` when it raises a critical or a should-fix.
150
156
 
151
157
  Both of this skill's headings anchor as a section distinct from human threads. Do not invent one beyond those two and the `## Review response` a sibling owns, and do not append the PR number, which GitHub already renders above the comment.
152
158
 
@@ -166,11 +172,25 @@ Before posting, run the scan in `.claude/standards/publish.md` against the body,
166
172
  gh pr review <number> --comment --body-file .claude/.tmp/pr-review/body-<number>-<short-sha>.md
167
173
  ```
168
174
 
169
- A pass with no findings takes `## Review closed` and a short body, with the footer line included either way. On a first pass, post `✅ No blocking findings. Reviewed against project docs and roadmap.` On a later pass, post `✅ Prior findings addressed. Re-reviewed <short-sha>, N commits since the prior pass.`
175
+ A pass carrying nothing at all takes `## Review closed` and a short body, with the footer line included either way. On a first pass, post `✅ No blocking findings. Reviewed against project docs and roadmap.` On a later pass, post `✅ Prior findings addressed. Re-reviewed <short-sha>, N commits since the prior pass.`
176
+
177
+ A pass carrying only minors takes the same heading and the full shape rather than either short line, since the minors have to be readable and neither line reports them. Keep whichever scope sentence the pass owes on the summary line:
178
+
179
+ ```markdown
180
+ ## Review closed
181
+
182
+ 0 critical, 0 should-fix, Z minor. Nothing blocks the merge. Reviewed against project docs and roadmap.
183
+
184
+ **`path/to/file.ext`**
185
+
186
+ - **minor**: finding, and the fix it wants.
187
+
188
+ 🤖 Reviewed by Claude Code
189
+ ```
170
190
 
171
- A pass that closed by withdrawing a finding rather than by reading its fix takes neither line, per the withdrawal rule in Step 3. Both claim a fix landed, and the second names it, so posting either over a withdrawal credits work nobody did on the one comment a reader treats as the verdict. Write the withdrawal and the fact that settled it in place of the canned line, keeping the heading and the footer.
191
+ A pass that closed by withdrawing a finding rather than by reading its fix takes neither line, per the withdrawal rule in Step 3. Both claim a fix landed, and the second names it, so posting either over a withdrawal credits work nobody did on the one comment a reader treats as the verdict. Write the withdrawal and the fact that settled it in place of the canned line, keeping the heading and the footer.
172
192
 
173
- Post that one even when there is nothing to report. A review left with no closing comment reads as one nobody answered.
193
+ Post a close-out even when there is nothing to report. A review left with no closing comment reads as one nobody answered.
174
194
 
175
195
  ## Step 5: output
176
196
 
@@ -29,7 +29,7 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
29
29
  | `aitk tasks archive` | Move a shipped task off the board, clear its ordering row, and regenerate the index |
30
30
  | `aitk tasks pull-request` | Record a pull request number on the task a branch closes, by stem or `--plan` (`--json`) |
31
31
  | `aitk tasks outcome` | Mark outcomes `[x]` on a task by position, repeating `--close` (`--json`) |
32
- | `aitk tasks validate` | Report board rows whose plan, task file, group, or file set does not hold (`--json`) |
32
+ | `aitk tasks validate` | Report board rows whose plan, task file, group, file set, or blocker does not hold (`--json`) |
33
33
  | `aitk intake list` | Report intake folder counts, or one folder's items, keeping what is unread with `--unread` (`--json`) |
34
34
  | `aitk intake answer` | Write selections into one cluster's answer slots, repeating `--set <item>=<answer>` (`--json`) |
35
35
  | `aitk records validate` | Report a session record or a standard against the standard governing it, per kind (`--json`) |
@@ -90,17 +90,40 @@ aitk tasks validate
90
90
  aitk tasks validate --json
91
91
  ```
92
92
 
93
- Four checks run. Plan and Collisions reach one half each of the `## Run now` test the board standard states, and the second reaches only the collision reason that half names, since the other blocker kinds put no fact on disk to check against. Mapping and Grouping test the folder contract and hold for every group:
93
+ Five checks run. Plan and Collisions reach one half each of the `## Run now` test the board standard states. Mapping and Grouping test the folder contract and hold for every group. Blockers reaches the rows outside `## Run now`:
94
94
 
95
- | Check | What it reports |
96
- | ---------- | --------------------------------------------------------------------------------- |
97
- | Plan | A `## Run now` row whose Plan column carries no link, or one resolving to no file |
98
- | Mapping | A row naming no task file, and a task file no row names |
99
- | Grouping | A task carrying a row in more than one readiness group |
100
- | Collisions | Two `## Run now` rows whose Touches columns name a path in common |
95
+ | Check | What it reports |
96
+ | ---------- | ------------------------------------------------------------------------------------ |
97
+ | Plan | A `## Run now` row whose Plan column carries no link, or one resolving to no file |
98
+ | Mapping | A row naming no task file, and a task file no row names |
99
+ | Grouping | A task carrying a row in more than one readiness group |
100
+ | Collisions | Two `## Run now` rows whose Touches columns name a path in common |
101
+ | Blockers | A parked row whose blocker has stopped holding, or whose cited task resolves nowhere |
101
102
 
102
103
  The collision check is the one a person cannot run by eye. Paths come from the backticked spans in the Touches column, a span naming no file is dropped, and a directory collides with any file beneath it. A `## Run now` row whose column parses to nothing is reported rather than skipped, since a row stating no file set makes a claim nothing can check.
103
104
 
105
+ The blocker check re-takes a measurement the board records once and never repeats. Two of the five blocker kinds put a fact on disk: a dependency is settled by the cited task being archived or closing every outcome, and a collision is settled by nothing under `## Run now` still holding the file the cell cites.
106
+
107
+ Both halves gate on a citation inside the `Waiting on` cell, never on the columns beside it. The board format gives a collision cell the file held by the running task, so a row whose cell names no file was parked by something else, and testing its Touches column instead reports a cleared collision on a row no collision ever parked while counting that row as re-tested. A cited task is a bare sibling link, the way the Task column spells one, so a pointer into another folder names a plan rather than a task and settles nothing. A cited task carrying no outcome box settles nothing either, since a file the check could not parse is not evidence of a finished one.
108
+
109
+ A citation resolving in neither the board nor the archive is `blocker-unresolved` rather than a settled row. Reading an absent file as archived states a specific fact about a file nobody ever wrote, which is what a renamed task or a typo produces, and only a task that genuinely closed releases the row waiting on it.
110
+
111
+ The other three kinds rest on a person's judgment, so a row neither half reached lands in a second array rather than in the findings:
112
+
113
+ ```json
114
+ {
115
+ "untested": [
116
+ {
117
+ "group": "Needs a plan",
118
+ "subject": "v50.6-a-standard-no-skill-reads",
119
+ "message": "..."
120
+ }
121
+ ]
122
+ }
123
+ ```
124
+
125
+ An untested row is not a finding and moves no exit code. Reading a clean findings list as a clean board is the failure the array exists to prevent, and `orchestrator-parked.md` is the pass that takes those rows by hand.
126
+
104
127
  Exit codes: `0` every check passed, `1` refused, `2` at least one finding. The `reason` field carries which gate refused: `no-board`, `no-ordering`, or `no-groups`. A board grouping under headings of its own trips `no-groups` rather than being read against columns it never declared.
105
128
 
106
129
  Columns are read from each table's own header rather than by position, so a project whose board differs from this one is reported for what it lacks. The `index` and `priority` siblings are skipped, along with every pre-compaction handoff, which takes one file per session under a `session-` prefix. None of them is a task, and a handoff counted as one would be reported as a task carrying no row on every session that wrote one.
@@ -73,7 +73,7 @@ When features are independent, run them in parallel instead of sequentially. Use
73
73
  - Ship each worktree separately with `aitk:git-ship`
74
74
  - For full autonomy per worktree, invoke `aitk:claude-autoship` instead of the manual chain. Approve the plan, walk away, come back to draft PRs.
75
75
 
76
- To run several worktrees as a coordinated flow rather than ad hoc, assert the orchestrator role in one warm session with `aitk:claude-orchestrate`. It owns the roadmap, handing a needed draft or resequence to a worker that runs `aitk:claude-roadmap` in its branch, plans each feature, refills the ready queue so a free worker never waits, and reviews each worker's PR with `aitk:claude-pr-review`, then tells the session holding that branch to run `aitk:claude-address-review` whenever the pass posted a critical or should-fix finding. The human launches workers and merges. See [operating model](operating-model.md) for the full loop.
76
+ To run several worktrees as a coordinated flow rather than ad hoc, assert the orchestrator role in one warm session with `aitk:claude-orchestrate`. It owns the roadmap, handing a needed draft or resequence to a worker that runs `aitk:claude-roadmap` in its branch, plans each feature, refills the ready queue so a free worker never waits, and reviews each worker's PR with `aitk:claude-pr-review`, then tells the session holding that branch to run `aitk:claude-address-review` whenever the pass posted a finding at any severity, read off the counts on the summary line rather than off the heading above them. The human launches workers and merges. See [operating model](operating-model.md) for the full loop.
77
77
 
78
78
  Roadmap ownership holds while a scope exists to sequence. Once the MVP list in `.claude/REQUIREMENTS.md` has shipped, later work arrives as discrete items and the orchestrator reads `.claude/tasks/priority.md` for execution order instead.
79
79
 
@@ -103,7 +103,7 @@ The sweep reads the whole board rather than the tasks the session touched. It is
103
103
 
104
104
  `.claude/memory/` carries the same arrangement, its own hook regenerating `index.md` from each entry's `title`, `description`, and `category`. A hand-maintained `priority.md` sits beside it carrying execution order and what each task is waiting on, which the alphabetical index cannot express.
105
105
 
106
- `aitk tasks validate` checks what those rows claim against what the tree holds: every plan pointer resolves, every row and task file map one to one, no task sits in two groups, and no two rows marked ready touch the same file. That last check is the half a reader cannot run by eye, and it is what keeps two workers from being handed colliding work. It reports and never writes, because a row is the orchestrator's claim and a validator repairing one would assert the claim it exists to test. Nothing fires it automatically, since the board is gitignored per-machine scratch with no shared moment to hang a hook on, so the orchestrator's sweep calls it at the point the readiness claim is made.
106
+ `aitk tasks validate` checks what those rows claim against what the tree holds: every plan pointer resolves, every row and task file map one to one, no task sits in two groups, and no two rows marked ready touch the same file. That fourth check is the half a reader cannot run by eye, and it is what keeps two workers from being handed colliding work. A fifth re-takes what a parked row waits on, reporting one whose cited task has closed and one whose cited file nothing running still holds. Both halves read a citation out of the blocker cell, so a row citing neither is reported as untested rather than counted clean, which is where the three kinds resting on a person's judgment land. It reports and never writes, because a row is the orchestrator's claim and a validator repairing one would assert the claim it exists to test. Nothing fires it automatically, since the board is gitignored per-machine scratch with no shared moment to hang a hook on, so the orchestrator's sweep calls it at the point the readiness claim is made and follows it with the parked re-test.
107
107
 
108
108
  `aitk:claude-tasks` owns the two operations that bracket a task's life. It creates the file, holding the filename convention and the frontmatter contract so a malformed write cannot break the index for every sibling, and it moves a shipped task to `.claude/task-archive/`. Creation is where the origin invariant is enforced: every task names a plan, a groundwork folder, an intake folder, or an issue, since a task with no origin is either lost context or work nobody decided to do.
109
109
 
@@ -40,8 +40,8 @@ One feature travels this path end to end.
40
40
  2. Orchestrator plans the next feature with `claude-feature`, writing a plan to `.claude/plans/`. Planning stays in the warm session because good planning is cross-feature. It needs the contract other features consume and the shared wiring seam. A cold session would re-derive or guess.
41
41
  3. The human opens a worker worktree with `claude-worktree` and runs `claude-autoship` against the plan. The worker builds, self-checks, opens a PR, and stops at the PR boundary.
42
42
  4. Orchestrator reviews the PR with `claude-pr-review` and posts findings to it.
43
- 5. Orchestrator tells the session holding that branch to run `claude-address-review` once the pass posted a critical or should-fix finding, resolving the target from a session listing taken at that moment and reporting the invocation for the human when no live session holds it. The worker addresses the findings, rebases onto `origin/main` when a sibling landed first and left the branch unable to merge, then pushes a follow-up. A pass carrying only minor findings dispatches nobody and goes straight to step 7, since a minor is visibility alone and leaves nothing for a worker to act on.
44
- 6. Orchestrator closes the review out with `claude-pr-review` again. The second pass reads only the commits the follow-up added, or the worker's response alone when the follow-up added none, and posts under `## Review closed` when it finds nothing open or under `## Review` when it does, so a reader learns the state from the heading. Repeat from step 5 until the review closes or leaves only minor findings open.
43
+ 5. Orchestrator tells the session holding that branch to run `claude-address-review` once the pass posted a finding at any severity, resolving the target from a session listing taken at that moment and reporting the invocation for the human when no live session holds it. The worker addresses the findings, rebases onto `origin/main` when a sibling landed first and left the branch unable to merge, then pushes a follow-up. A pass carrying only minor findings dispatches too, since the grade runs low often enough that a floor at should-fix loses fixes a worker would have made.
44
+ 6. Orchestrator closes the review out with `claude-pr-review` again. The second pass reads only the commits the follow-up added, or the worker's response alone when the follow-up added none, and posts under `## Review` when it finds a critical or a should-fix and under `## Review closed` otherwise, so a reader learns the merge decision from the heading and the counts from the line under it. Repeat from step 5 until a pass closes the review.
45
45
  7. The human reads the result and merges. The orchestrator tells any trailing worker whose branch shares a seam with the merged one to run `claude-address-review`, which rebases whether or not the review left anything open.
46
46
 
47
47
  There is no loop construct here. Each worker is a single build that halts at the
@@ -102,9 +102,14 @@ closed clean and then went stale still rebases when the skill is invoked. The
102
102
  re-read costs a full pass rather than a delta, since the prior reviewed commit no
103
103
  longer reaches the head, and `claude-pr-review` detects that itself.
104
104
 
105
- The heading carries the state rather than the pass number. A pass with a finding
106
- takes `## Review` and a pass with none takes `## Review closed`, so a thread can
107
- be scanned for what is still open without opening a comment. The feedback
105
+ The heading carries the state rather than the pass number. A pass carrying a
106
+ critical or a should-fix takes `## Review` and every other pass takes
107
+ `## Review closed`, so a thread can be scanned for what still blocks a merge
108
+ without opening a comment. The heading and the dispatch answer two questions
109
+ rather than one: a minors-only pass closes the heading and still sends a worker,
110
+ so the dispatch reads the counts on the summary line rather than the heading
111
+ above them. A minor the worker declines goes to the findings of the task the
112
+ branch closes, since a thread does not survive the merge. The feedback
108
113
  becomes a durable artifact both sessions read, survives a session ending, and
109
114
  anchors to the change. That removes the copy-paste that otherwise routes review
110
115
  through the human between two sessions.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "0.97.0",
4
+ "version": "0.98.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -11,6 +11,7 @@ import {
11
11
  } from '@/tasks/record'
12
12
  import {
13
13
  type Finding,
14
+ type Untested,
14
15
  type ValidateOutcome,
15
16
  validateBoard,
16
17
  } from '@/tasks/validate'
@@ -458,6 +459,19 @@ function reportValidation(
458
459
  } else {
459
460
  for (const finding of outcome.findings) logWarn(describe(finding))
460
461
  }
462
+
463
+ // The untested rows carry the warn glyph rather than the pass glyph. They
464
+ // move no exit code, and a green tick on a row nothing re-took is the
465
+ // misread this section exists to prevent.
466
+ logStep('Parked rows')
467
+ if (outcome.untested.length === 0) {
468
+ logInfo('every parked row carried a citation or a file set to re-test')
469
+ } else {
470
+ logWarn(
471
+ `${outcome.untested.length} row(s) carry a blocker no check can settle`,
472
+ )
473
+ for (const row of outcome.untested) logWarn(describeUntested(row))
474
+ }
461
475
  outro()
462
476
  }
463
477
 
@@ -469,6 +483,7 @@ function reportValidation(
469
483
  rows: outcome.rows,
470
484
  tasks: outcome.tasks,
471
485
  findings: outcome.findings,
486
+ untested: outcome.untested,
472
487
  })}\n`,
473
488
  )
474
489
  }
@@ -481,6 +496,10 @@ function describe(finding: Finding): string {
481
496
  return `${scope}${finding.subject} ${finding.message}`
482
497
  }
483
498
 
499
+ function describeUntested(row: Untested): string {
500
+ return `${row.group}: ${row.subject} ${row.message}`
501
+ }
502
+
484
503
  async function runArchive(
485
504
  task: string | undefined,
486
505
  opts: ArchiveCommandOptions,
@@ -1,7 +1,12 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { readdir, readFile } from 'node:fs/promises'
3
3
  import { join, resolve } from 'node:path'
4
- import { isReservedStem, tasksDir } from '@/tasks/archive'
4
+ import {
5
+ archiveDir,
6
+ isReservedStem,
7
+ readOutcomes,
8
+ tasksDir,
9
+ } from '@/tasks/archive'
5
10
 
6
11
  const ORDERING_FILE = 'priority.md'
7
12
 
@@ -31,6 +36,8 @@ export const FINDING_KINDS = [
31
36
  'row-duplicated',
32
37
  'touches-unstated',
33
38
  'touches-collided',
39
+ 'blocker-settled',
40
+ 'blocker-unresolved',
34
41
  ] as const
35
42
 
36
43
  export type FindingKind = (typeof FINDING_KINDS)[number]
@@ -42,6 +49,17 @@ export interface Finding {
42
49
  readonly message: string
43
50
  }
44
51
 
52
+ /**
53
+ * A parked row neither half of the blocker check reached. Three of the five
54
+ * blocker kinds put no fact on disk, so a run reporting findings alone would
55
+ * read as a board with nothing stale on it.
56
+ */
57
+ export interface Untested {
58
+ readonly group: BoardGroup
59
+ readonly subject: string
60
+ readonly message: string
61
+ }
62
+
45
63
  export interface BoardRow {
46
64
  readonly group: BoardGroup
47
65
  readonly label: string
@@ -49,6 +67,8 @@ export interface BoardRow {
49
67
  readonly plan: string | undefined
50
68
  /** Absent when the group fixes no `Touches` column, empty when it read none. */
51
69
  readonly touches: readonly string[] | undefined
70
+ /** Absent when the group fixes no `Waiting on` column, which is `## Run now`. */
71
+ readonly waiting: string | undefined
52
72
  }
53
73
 
54
74
  export interface ValidateReport {
@@ -56,6 +76,7 @@ export interface ValidateReport {
56
76
  readonly rows: number
57
77
  readonly tasks: number
58
78
  readonly findings: readonly Finding[]
79
+ readonly untested: readonly Untested[]
59
80
  }
60
81
 
61
82
  export interface ValidateRefused {
@@ -185,6 +206,7 @@ export function readBoard(text: string): {
185
206
  const taskAt = columnIndex(header, 'task')
186
207
  const planAt = columnIndex(header, 'plan')
187
208
  const touchesAt = columnIndex(header, 'touches')
209
+ const waitingAt = columnIndex(header, 'waiting on')
188
210
 
189
211
  const task = taskAt >= 0 ? (cells[taskAt] ?? '') : ''
190
212
  const target = linkTarget(task)
@@ -196,6 +218,7 @@ export function readBoard(text: string): {
196
218
  stem: target ? stemOf(target) : undefined,
197
219
  plan,
198
220
  touches: touchesAt >= 0 ? readPaths(cells[touchesAt] ?? '') : undefined,
221
+ waiting: waitingAt >= 0 ? (cells[waitingAt] ?? '') : undefined,
199
222
  })
200
223
  }
201
224
 
@@ -359,6 +382,142 @@ function checkCollisions(rows: readonly BoardRow[]): Finding[] {
359
382
  return findings
360
383
  }
361
384
 
385
+ /**
386
+ * Reads the task a blocker cell cites. A task pointer is a bare sibling
387
+ * filename, the way every `Task` column spells one, so a target carrying a
388
+ * directory names something else and yields nothing. A row waiting on a plan
389
+ * links that plan, and reading its stem as a task would report the row settled
390
+ * against a folder the plan does not sit in.
391
+ */
392
+ function citedStem(cell: string): string | undefined {
393
+ const target = linkTarget(cell)?.split('#')[0]
394
+ if (!target || target.includes('/')) return undefined
395
+ return stemOf(target)
396
+ }
397
+
398
+ /**
399
+ * Reports what a cited task does to the row waiting on it. A live file whose
400
+ * outcomes are all closed settles the row, and so does one sitting in the
401
+ * archive. A file carrying no outcome box settles nothing, since a file the
402
+ * check could not parse is not evidence of a finished one.
403
+ *
404
+ * A citation resolving in neither folder is a broken pointer rather than a
405
+ * closed task, and the two take different findings. Reading an absent file as
406
+ * archived states a specific fact about a file nobody ever wrote, which is what
407
+ * a renamed task or a typo produces.
408
+ *
409
+ * The outcome list comes off `readOutcomes` rather than a pattern of its own,
410
+ * so this check cannot disagree with the archive and outcome verbs about which
411
+ * checkboxes are outcomes and which sit inside a block a task displays.
412
+ */
413
+ async function checkCitedTask(
414
+ group: BoardGroup,
415
+ subject: string,
416
+ cited: string,
417
+ root: string,
418
+ ): Promise<Finding[]> {
419
+ const live = join(tasksDir(root), `${cited}.md`)
420
+
421
+ if (!existsSync(live)) {
422
+ if (existsSync(join(archiveDir(root), `${cited}.md`))) {
423
+ return [
424
+ {
425
+ kind: 'blocker-settled',
426
+ group,
427
+ subject,
428
+ message: `waits on ${cited}, which is archived.`,
429
+ },
430
+ ]
431
+ }
432
+
433
+ return [
434
+ {
435
+ kind: 'blocker-unresolved',
436
+ group,
437
+ subject,
438
+ message: `waits on ${cited}, which is neither on the board nor archived.`,
439
+ },
440
+ ]
441
+ }
442
+
443
+ const { open, closed } = readOutcomes(await readFile(live, 'utf8'))
444
+ if (open.length > 0 || closed.length === 0) return []
445
+
446
+ return [
447
+ {
448
+ kind: 'blocker-settled',
449
+ group,
450
+ subject,
451
+ message: `waits on ${cited}, which carries no open outcome.`,
452
+ },
453
+ ]
454
+ }
455
+
456
+ /**
457
+ * Re-takes the two blocker kinds a command can settle, over every row outside
458
+ * `## Run now`. A cited task is settled by being archived or by closing every
459
+ * outcome, and a cited path is settled by no `## Run now` row still holding it.
460
+ *
461
+ * Both halves gate on a citation the cell carries, never on a column beside it.
462
+ * The board standard gives a collision cell the file held by the running task,
463
+ * so a row whose cell names no file was parked by something else and its
464
+ * `Touches` column says nothing about what holds it. Reading that column
465
+ * instead reports a cleared collision on a row no collision ever parked, and
466
+ * counts the row as re-tested, which is the more expensive half of that error.
467
+ *
468
+ * The cell is read for citations rather than parsed into fields, since the
469
+ * standard fixes three forms for it and leaves it prose. A row neither half
470
+ * reached is returned as untested, because the other three kinds are a person's
471
+ * to judge and silence about them reads as a board with nothing stale on it.
472
+ */
473
+ async function checkParked(
474
+ rows: readonly BoardRow[],
475
+ root: string,
476
+ ): Promise<{ findings: Finding[]; untested: Untested[] }> {
477
+ const findings: Finding[] = []
478
+ const untested: Untested[] = []
479
+ const running = rows.filter((row) => row.group === 'Run now')
480
+
481
+ for (const row of rows) {
482
+ if (row.group === 'Run now') continue
483
+
484
+ const subject = row.stem ?? row.label
485
+ const cell = row.waiting ?? ''
486
+ const cited = citedStem(cell)
487
+ const contested = readPaths(cell)
488
+
489
+ if (cited) {
490
+ findings.push(...(await checkCitedTask(row.group, subject, cited, root)))
491
+ }
492
+
493
+ const held = contested.filter((path) =>
494
+ running.some((run) =>
495
+ (run.touches ?? []).some((other) => sharesPath(path, other)),
496
+ ),
497
+ )
498
+
499
+ if (contested.length > 0 && held.length === 0) {
500
+ findings.push({
501
+ kind: 'blocker-settled',
502
+ group: row.group,
503
+ subject,
504
+ message: `waits on ${contested.join(', ')}, which nothing under Run now holds.`,
505
+ })
506
+ }
507
+
508
+ if (!cited && contested.length === 0) {
509
+ untested.push({
510
+ group: row.group,
511
+ subject,
512
+ message:
513
+ 'cites no task and no file, so neither half of its blocker is mechanical.',
514
+ })
515
+ }
516
+ }
517
+
518
+ return { findings, untested }
519
+ }
520
+
362
521
  function refuse(reason: ValidateRefusal, message: string): ValidateRefused {
363
522
  return { ok: false, reason, message }
364
523
  }
@@ -389,12 +548,20 @@ export async function validateBoard(root: string): Promise<ValidateOutcome> {
389
548
  }
390
549
 
391
550
  const stems = await listTaskStems(dir)
551
+ const parked = await checkParked(rows, root)
392
552
 
393
553
  const findings = [
394
554
  ...checkMapping(rows, stems, dir),
395
555
  ...checkPlans(rows, dir, root),
396
556
  ...checkCollisions(rows),
557
+ ...parked.findings,
397
558
  ]
398
559
 
399
- return { ok: true, rows: rows.length, tasks: stems.length, findings }
560
+ return {
561
+ ok: true,
562
+ rows: rows.length,
563
+ tasks: stems.length,
564
+ findings,
565
+ untested: parked.untested,
566
+ }
400
567
  }
@@ -65,7 +65,7 @@ The `Waiting on` column under `## Up next` carries that reason in one of three f
65
65
 
66
66
  Under `## Up next` a collision names the file held by the task already running, a sibling task names that task, and an external condition names both the condition and what would satisfy it. Naming what would satisfy it is what separates a blocked row from one nobody has examined, so a cell stating a condition with no way out of it fails the test. The header text is the contract the way the group names are, because anything reading the cell resolves the column by header rather than by position.
67
67
 
68
- `aitk tasks validate` reads those columns and reports where a row's claim and the tree disagree: a plan pointer resolving to no file, a row and a task file that do not map one to one, a task in two groups, and two `## Run now` rows touching a path in common. It does not read the `Waiting on` cell, so that test holds on reading alone. Run it when the readiness claim is made rather than on a schedule, since the board is gitignored per-machine scratch and no shared moment exists to hang it on. It reports and never writes, so a session fixes the row it names.
68
+ `aitk tasks validate` reads those columns and reports where a row's claim and the tree disagree: a plan pointer resolving to no file, a row and a task file that do not map one to one, a task in two groups, and two `## Run now` rows touching a path in common. It also re-takes the two blocker kinds a command can settle, reporting a parked row whose cited task is archived or has closed every outcome and one whose cited file nothing under `## Run now` still holds. Both halves read a citation out of the cell rather than parsing it into fields, and a row citing neither is reported as untested, which is where the three kinds resting on a person's judgment land. Run it when the readiness claim is made rather than on a schedule, since the board is gitignored per-machine scratch and no shared moment exists to hang it on. It reports and never writes, so a session fixes the row it names.
69
69
 
70
70
  ```markdown
71
71
  ---