@erclx/aitk 0.97.1 → 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.1",
4
+ "version": "0.98.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -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.
@@ -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
 
@@ -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.
@@ -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
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "0.97.1",
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
  ---