@erclx/aitk 0.104.0 → 0.104.1

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.104.0",
4
+ "version": "0.104.1",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -227,6 +227,10 @@ A board carrying one task written `../plans/x.md` and another written `.claude/p
227
227
 
228
228
  Exclude the closing task explicitly. It sits on the board and cites the plan itself, so a scan that counts it never reaches zero and no plan is ever archived.
229
229
 
230
+ `aitk tasks plan-citations <stem> --json` answers this same question, and the archive gate already reads it. This body states the rule anyway rather than calling the verb, because a plugin skill reaches a target the moment it merges while the CLI reaches one only when a release publishes, so a target whose installed `aitk` predates the verb gets no record back and routes on nothing. Measured against the `claude:docs` `board-sweep` arm, where calling the verb archived neither plan and created no `.claude/plans-archive/`.
231
+
232
+ Nothing in the exit code reports that. Branch on the record's `ok` and `reason` fields and never on the exit, which is the rule every task verb already carries: an operator's shell profile may wrap `aitk` in a function that runs the binary and then another command, taking its status from the second, and one measured here masks every non-zero exit rather than only an absent verb. The binary itself exits 1 for an unknown subcommand and 1 for an ordinary refusal alike. Switching this body to the verb needs a release that carries it and a read of the record rather than the exit, which together retire the duplication.
233
+
230
234
  A plan can serve more than one task, and archiving on the first task to close strands every other task's pointer at a path that has moved. `.claude/plans/` is gitignored, so that retarget would be the only record and there is nothing to recover it from.
231
235
 
232
236
  - Target resolves inside `.claude/plans/`, the file exists, and no other task file cites it: create `.claude/plans-archive/`, move the file there under its original name, overwriting any file already sitting at that name. Then rewrite the task file's `Plan:` line to the archive path, so a completed task still leads to the reasoning behind it.
@@ -24,6 +24,8 @@ aitk tasks archive --pull-request 673 --json
24
24
 
25
25
  Exit codes: `0` archived, `1` refused. Every gate is a refusal rather than a warning, because `.husky/post-merge` calls this with nobody watching. The `reason` field carries which gate fired: `no-board`, `no-match`, `ambiguous`, `no-outcomes`, `open-outcomes`, `plan-unswept`, or `bad-input`.
26
26
 
27
+ `plan-unswept` fires on the last task pointing at a live plan, never on every task pointing at one. The gate counts the other live tasks whose `Plan:` line resolves onto the same file, so a plan several tasks share archives its tasks freely and only the final one is held until `claude-docs` sweeps the plan. Reading the folder alone refused all of them, which deadlocked the board against a sweep correctly declining to move a plan another live task cites.
28
+
27
29
  `bad-input` covers a malformed command line, which all three task verbs answer the same way. It is separate from `ambiguous` and `no-match` because those describe the board, and a caller that passed two selectors would otherwise be sent to repair a task citation that is fine.
28
30
 
29
31
  The board is shared scratch at the main worktree root, so `--root` defaults to the first entry of `git worktree list` rather than the working directory. A linked worktree archives against the same board every other session reads.
@@ -34,6 +36,27 @@ Skills branch on the reason rather than on the exit code:
34
36
  aitk tasks archive --pull-request 673 --json | jq -r 'if .ok then .task else .reason end'
35
37
  ```
36
38
 
39
+ ## Plan citations
40
+
41
+ `aitk tasks plan-citations <stem>` answers where a task's plan sits and which other live tasks hold it. It reports and never writes.
42
+
43
+ | Option | Effect |
44
+ | --------------- | ------------------------------------------- |
45
+ | `--json` | Emit a machine-readable record on stdout |
46
+ | `--root <path>` | Board root, defaulting to the main worktree |
47
+
48
+ The record carries `location`, one of `unstated`, `live`, `archived`, or `outside`, and `citedBy`, the other live tasks whose `Plan:` line lands on the same file. Exit codes: `0` read, `1` refused with `no-board` or `no-match`.
49
+
50
+ `aitk tasks archive` gates on this same answer, so a caller wanting the count reads it here rather than scanning the board. The `claude-docs` plans sweep is the exception and still states the rule in its own body, because a plugin skill reaches a target on merge while the CLI reaches one on release, so a sweep calling a verb the installed `aitk` predates gets no record back and archives nothing.
51
+
52
+ Branch on `reason` rather than on the exit code, which is the rule the archive section above already states and which this verb needs for a second reason. An operator's shell profile may wrap `aitk` in a function that runs the binary and then another command and takes the second status, which masks every non-zero exit rather than only an absent verb. The binary exits 1 for an unknown subcommand and 1 for an ordinary refusal alike, so the record is the only signal that survives the wrapper.
53
+
54
+ A `live` location with an empty `citedBy` is the sweep to run. One whose `citedBy` names a sibling is a plan several tasks share, which the sweep leaves alone and the archive gate lets through.
55
+
56
+ ```bash
57
+ aitk tasks plan-citations v28.1-trigger-escalation --json | jq -r '.location'
58
+ ```
59
+
37
60
  ## Pull request
38
61
 
39
62
  `aitk tasks pull-request` records the number a branch's pull request carries onto the task that branch closes. It adds `Pull request: #NNN` under the `Plan:`, `Groundwork:`, `Intake:`, or `Issue:` lines the task already holds, and corrects the number in place when the line exists.
@@ -106,7 +129,11 @@ A backlog line is a bullet carrying a link to a sibling task, since the backlog
106
129
 
107
130
  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.
108
131
 
109
- 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.
132
+ 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 by its work reaching the trunk, and a collision is settled by nothing under `## Run now` still holding the file the cell cites.
133
+
134
+ A closed outcome is not the fact the dependency half needs. The ship chain marks outcomes as its first step and opens the pull request several steps later, so a check reading the checkbox reports the row settled while the branch is still in review. A live task therefore settles the row only once it closed every outcome and carries a `Pull request:` line the trunk holds. One that names no pull request, and one whose number no trunk ref could answer for, land in the untested array below rather than being settled or left silent.
135
+
136
+ The trunk is read as the clone already holds it, `origin/main` first and local `main` behind it, and no run fetches. A validate runs several times a sweep and a fetch per run is a cost this command does not carry, so a clone behind its remote under-reports rather than claiming work landed.
110
137
 
111
138
  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.
112
139
 
@@ -105,11 +105,11 @@ The sweep reads the whole board rather than the tasks the session touched. It is
105
105
 
106
106
  `.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.
107
107
 
108
- `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.
108
+ `aitk tasks validate` checks what those rows claim against what the tree holds: every plan pointer resolves, every task file is named by a board row or a backlog line and never by both, no task sits in two groups, and no two rows marked ready touch the same file. One check across both surfaces is what lets a task move between the board and the backlog without the move reading as a dropped 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 reached the trunk and one whose cited file nothing running still holds. A cited task settles the row by being archived, or by closing every outcome and naming a pull request the trunk carries, since the checkbox alone is marked while the branch is still in review. Both halves read a citation out of the blocker cell, so a row citing neither is reported as untested rather than counted clean, and so is a cited task the trunk could not answer for. 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.
109
109
 
110
110
  `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.
111
111
 
112
- Archiving a task leaves its plan alone, because `aitk:claude-docs` owns the plans sweep and already holds the last-live-citation rule. That makes the order load-bearing, so the archive verb refuses to run while the `Plan:` line still points into `.claude/plans/`. The sweep only reaches tasks still in the live folder, and archiving the task first would strand the plan there with nothing citing it.
112
+ Archiving a task leaves its plan alone, because `aitk:claude-docs` owns the plans sweep and already holds the last-live-citation rule. That makes the order load-bearing, so the archive verb refuses the last task pointing at a live plan. The sweep only reaches tasks still in the live folder, and archiving that task first would strand the plan there with nothing citing it. A task whose plan a sibling still cites archives freely, because the sweep is correct to leave that plan where it is and a gate reading the folder instead would park every task sharing one plan behind a sweep that will never move it.
113
113
 
114
114
  Nothing chained that archive until the `post-merge` git hook landed. Every earlier step fires from `aitk:claude-autoship` or `aitk:git-ship`, both of which finish while the pull request is still open, so a task archived there would close for work that may be abandoned. The board is gitignored, which rules out reading it from anywhere but the machine that pulled. The hook names the board's archive candidates and stays silent otherwise, including on a project with no board.
115
115
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "0.104.0",
4
+ "version": "0.104.1",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -1,6 +1,12 @@
1
1
  import { relative } from 'node:path'
2
2
  import type { Command } from 'commander'
3
- import { type ArchiveOutcome, archiveTask } from '@/tasks/archive'
3
+ import {
4
+ type ArchiveOutcome,
5
+ archiveTask,
6
+ type CitationOutcome,
7
+ type PlanCitations,
8
+ planCitations,
9
+ } from '@/tasks/archive'
4
10
  import {
5
11
  type CloseOutcome,
6
12
  closeOutcomes,
@@ -42,6 +48,11 @@ interface ValidateCommandOptions {
42
48
  readonly root?: string
43
49
  }
44
50
 
51
+ interface CitationsCommandOptions {
52
+ readonly json?: boolean
53
+ readonly root?: string
54
+ }
55
+
45
56
  interface PullRequestCommandOptions {
46
57
  readonly json?: boolean
47
58
  readonly plan?: string
@@ -126,6 +137,41 @@ export function register(program: Command): void {
126
137
  process.exitCode = await runValidate(opts)
127
138
  })
128
139
 
140
+ tasks
141
+ .command('plan-citations')
142
+ .description('Report where a task plan sits and which live tasks hold it')
143
+ .argument('<task>', 'Task filename stem, as in v28.1-trigger-escalation')
144
+ .helpOption('-h, --help', 'Show this help message')
145
+ .option('--json', 'Emit a machine-readable record on stdout')
146
+ .option('--root <path>', 'Board root, defaulting to the main worktree')
147
+ .addHelpText(
148
+ 'after',
149
+ [
150
+ '',
151
+ 'Locations:',
152
+ ' unstated the task carries no Plan: line',
153
+ ' live the target resolves inside .claude/plans/',
154
+ ' archived the target resolves inside .claude/plans-archive/',
155
+ ' outside the target resolves somewhere else',
156
+ '',
157
+ 'Exit codes:',
158
+ ' 0 the citations were read',
159
+ ' 1 refused, with the reason on stderr or in the JSON record',
160
+ '',
161
+ 'It reports and never writes. A live plan whose citedBy list is empty',
162
+ 'is the sweep to run, and one a sibling still holds is left alone.',
163
+ 'The archive gate reads the same answer, so neither can drift.',
164
+ '',
165
+ 'Examples:',
166
+ ' aitk tasks plan-citations v28.1-trigger-escalation',
167
+ ' aitk tasks plan-citations v28.1-trigger-escalation --json',
168
+ '',
169
+ ].join('\n'),
170
+ )
171
+ .action(async (task: string, opts: CitationsCommandOptions) => {
172
+ process.exitCode = await runCitations(task, opts)
173
+ })
174
+
129
175
  tasks
130
176
  .command('pull-request')
131
177
  .description('Record a pull request number on the task a branch closes')
@@ -426,6 +472,67 @@ async function runValidate(opts: ValidateCommandOptions): Promise<number> {
426
472
  return reportValidation(outcome, opts.json ?? false, root)
427
473
  }
428
474
 
475
+ async function runCitations(
476
+ task: string,
477
+ opts: CitationsCommandOptions,
478
+ ): Promise<number> {
479
+ const root = opts.root ?? (await mainWorktreeRoot())
480
+ const outcome = await planCitations(root, task)
481
+
482
+ return reportCitations(outcome, opts.json ?? false, root)
483
+ }
484
+
485
+ function reportCitations(
486
+ outcome: CitationOutcome,
487
+ emitJson: boolean,
488
+ root: string,
489
+ ): number {
490
+ if (!outcome.ok) {
491
+ if (emitJson) {
492
+ process.stdout.write(
493
+ `${JSON.stringify({ ok: false, reason: outcome.reason, message: outcome.message })}\n`,
494
+ )
495
+ return 1
496
+ }
497
+
498
+ intro('aitk tasks plan-citations')
499
+ logStep('Refused')
500
+ logError(outcome.message)
501
+ outro()
502
+ return 1
503
+ }
504
+
505
+ if (emitJson) {
506
+ process.stdout.write(`${JSON.stringify({ ...outcome, root })}\n`)
507
+ return 0
508
+ }
509
+
510
+ intro('aitk tasks plan-citations')
511
+ logStep(outcome.stem)
512
+ logInfo(describeCitations(outcome))
513
+ outro()
514
+
515
+ return 0
516
+ }
517
+
518
+ function describeCitations(outcome: PlanCitations): string {
519
+ if (outcome.location === 'unstated') return 'carries no Plan: line.'
520
+
521
+ if (outcome.location === 'archived') {
522
+ return `points at ${outcome.target}, which an earlier sweep already archived.`
523
+ }
524
+
525
+ if (outcome.location === 'outside') {
526
+ return `points at ${outcome.target}, which resolves outside both plans folders.`
527
+ }
528
+
529
+ if (outcome.citedBy.length === 0) {
530
+ return `is the last live task citing ${outcome.target}, so the sweep may archive it.`
531
+ }
532
+
533
+ return `shares ${outcome.target} with ${outcome.citedBy.join(', ')}, so the sweep leaves it.`
534
+ }
535
+
429
536
  function reportValidation(
430
537
  outcome: ValidateOutcome,
431
538
  emitJson: boolean,
@@ -6,6 +6,7 @@ import { regenOne } from '@/indexes/regen'
6
6
  const TASKS_DIR = join('.claude', 'tasks')
7
7
  const ARCHIVE_DIR = join('.claude', 'task-archive')
8
8
  const PLANS_DIR = join('.claude', 'plans')
9
+ const PLANS_ARCHIVE_DIR = join('.claude', 'plans-archive')
9
10
 
10
11
  /**
11
12
  * Siblings that sit on the board without being tasks: the generated index, the
@@ -192,13 +193,152 @@ function isUnder(path: string, dir: string): boolean {
192
193
  * both, which is how `claude-docs` reads the same line. It accepts `../plans/x.md`
193
194
  * and `.claude/plans/x.md` as one file, so a gate reading only the first form
194
195
  * would pass the second and strand the plan this exists to protect.
196
+ *
197
+ * The resolved path is returned rather than a boolean, because the citation
198
+ * count below compares two tasks by where their targets land and not by the
199
+ * strings they wrote. A target outside the live plans folder yields nothing,
200
+ * which is an archived plan or a pointer into somewhere else entirely.
195
201
  */
196
- function isLivePlan(target: string, dir: string, root: string): boolean {
202
+ export function resolveLivePlan(
203
+ target: string,
204
+ dir: string,
205
+ root: string,
206
+ ): string | undefined {
197
207
  const plans = join(root, PLANS_DIR)
208
+ const fromBoard = resolve(dir, target)
209
+ const fromRoot = resolve(root, target)
210
+
211
+ if (isUnder(fromBoard, plans)) return fromBoard
212
+ if (isUnder(fromRoot, plans)) return fromRoot
213
+ return undefined
214
+ }
215
+
216
+ /**
217
+ * Names the other live tasks whose `Plan:` line lands on the same file. This is
218
+ * the rule `claude-docs` applies before it archives a plan, held here so one
219
+ * question has one implementation: a plan another live task still cites is a
220
+ * plan the sweep is correct to leave, and a guard that read the folder instead
221
+ * refused every task sharing one plan and deadlocked the board against the
222
+ * sweep that was behaving correctly.
223
+ *
224
+ * The closing task is excluded by name. It cites the plan itself, so counting
225
+ * it would never reach zero and the count would answer nothing.
226
+ */
227
+ export async function otherTasksCitingPlan(
228
+ dir: string,
229
+ root: string,
230
+ plan: string,
231
+ closing: string,
232
+ ): Promise<string[]> {
233
+ const stems = (await listTaskStems(dir)).filter((stem) => stem !== closing)
234
+
235
+ const read = await Promise.all(
236
+ stems.map(async (stem) => {
237
+ const target = readPlanTarget(
238
+ await readFile(join(dir, `${stem}.md`), 'utf8'),
239
+ )
240
+ const resolved = target && resolveLivePlan(target, dir, root)
241
+ return resolved === plan ? stem : undefined
242
+ }),
243
+ )
244
+
245
+ return read.filter((stem): stem is string => stem !== undefined)
246
+ }
247
+
248
+ /**
249
+ * Where a task's `Plan:` target resolves, which is what decides whether the
250
+ * plan is the sweep's to move. `unstated` is a task carrying no line at all,
251
+ * and it is distinct from a line resolving somewhere unexpected.
252
+ */
253
+ export const CITATION_LOCATIONS = [
254
+ 'unstated',
255
+ 'live',
256
+ 'archived',
257
+ 'outside',
258
+ ] as const
259
+
260
+ export type CitationLocation = (typeof CITATION_LOCATIONS)[number]
261
+
262
+ export interface PlanCitations {
263
+ readonly ok: true
264
+ readonly stem: string
265
+ readonly target: string | undefined
266
+ readonly location: CitationLocation
267
+ /** Other live tasks landing on the same file. Empty unless `location` is `live`. */
268
+ readonly citedBy: readonly string[]
269
+ }
270
+
271
+ export type CitationOutcome = PlanCitations | ArchiveRefused
272
+
273
+ /**
274
+ * Answers where one task's plan sits and who else holds it, which is the whole
275
+ * of the last-live-citation rule. `claude-docs` reads this rather than scanning
276
+ * the board itself, so the sweep that moves a plan and the gate that refuses a
277
+ * task archive cannot drift into disagreeing about which plan is free.
278
+ *
279
+ * It reports and never writes. The move, the retarget, and the ordering the two
280
+ * happen in belong to the caller, and a verb that performed them would be
281
+ * deciding a question the sweep is there to decide.
282
+ */
283
+ export async function planCitations(
284
+ root: string,
285
+ stem: string,
286
+ ): Promise<CitationOutcome> {
287
+ const dir = tasksDir(root)
288
+
289
+ if (!existsSync(dir)) {
290
+ return refuse('no-board', `No task board at ${relative(root, dir)}.`)
291
+ }
292
+
293
+ const stems = await listTaskStems(dir)
294
+ if (!stems.includes(stem)) {
295
+ return refuse('no-match', `No task named ${stem} on the board.`, stems)
296
+ }
297
+
298
+ const target = readPlanTarget(await readFile(join(dir, `${stem}.md`), 'utf8'))
299
+ if (!target) {
300
+ return {
301
+ ok: true,
302
+ stem,
303
+ target: undefined,
304
+ location: 'unstated',
305
+ citedBy: [],
306
+ }
307
+ }
308
+
309
+ const live = resolveLivePlan(target, dir, root)
310
+ if (!live) {
311
+ const location = resolvesUnder(target, dir, root, PLANS_ARCHIVE_DIR)
312
+ ? 'archived'
313
+ : 'outside'
314
+ return { ok: true, stem, target, location, citedBy: [] }
315
+ }
316
+
317
+ return {
318
+ ok: true,
319
+ stem,
320
+ target,
321
+ location: 'live',
322
+ citedBy: await otherTasksCitingPlan(dir, root, live, stem),
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Runs the two-spelling resolution `resolveLivePlan` applies against a folder
328
+ * other than the live one, so an archived plan is read as archived whichever
329
+ * root the task wrote its path against.
330
+ */
331
+ function resolvesUnder(
332
+ target: string,
333
+ dir: string,
334
+ root: string,
335
+ folder: string,
336
+ ): boolean {
337
+ const resolved = join(root, folder)
198
338
 
199
339
  return (
200
- isUnder(resolve(dir, target), plans) ||
201
- isUnder(resolve(root, target), plans)
340
+ isUnder(resolve(dir, target), resolved) ||
341
+ isUnder(resolve(root, target), resolved)
202
342
  )
203
343
  }
204
344
 
@@ -310,12 +450,21 @@ export async function archiveTask(
310
450
  }
311
451
 
312
452
  const planTarget = readPlanTarget(text)
313
- if (planTarget && isLivePlan(planTarget, dir, root)) {
314
- return refuse(
315
- 'plan-unswept',
316
- `${stem} still points at a live plan. Run /claude-docs to sweep it first, then archive.`,
317
- [planTarget],
318
- )
453
+ const livePlan = planTarget && resolveLivePlan(planTarget, dir, root)
454
+
455
+ // A live plan is unswept only when nothing else on the board holds it. A plan
456
+ // several tasks share stays live by design, so refusing on the folder alone
457
+ // parked every one of those tasks behind a sweep that was right to decline.
458
+ if (livePlan) {
459
+ const shared = await otherTasksCitingPlan(dir, root, livePlan, stem)
460
+
461
+ if (shared.length === 0) {
462
+ return refuse(
463
+ 'plan-unswept',
464
+ `${stem} is the last task pointing at a live plan. Run /claude-docs to sweep it first, then archive.`,
465
+ [planTarget],
466
+ )
467
+ }
319
468
  }
320
469
 
321
470
  const destination = archiveDir(root)
@@ -0,0 +1,89 @@
1
+ import { execa } from 'execa'
2
+ import { gitEnv } from '@/git-env'
3
+
4
+ const GIT_TIMEOUT_MS = 10_000
5
+
6
+ /** Preferred first. A clone with no remote still answers off its local trunk. */
7
+ const TRUNK_REFS = ['origin/main', 'main'] as const
8
+
9
+ /**
10
+ * Reports whether a pull request's work reached the trunk. `undefined` means
11
+ * the trunk could not be read, which is a different answer from `false` and has
12
+ * to stay one: a check that degraded to "not landed" on an unreachable trunk
13
+ * would report a board silently rather than say what it failed to test.
14
+ */
15
+ export type TrunkReader = (pullRequest: number) => Promise<boolean | undefined>
16
+
17
+ /**
18
+ * Matches the two subjects a landed pull request leaves on the trunk: the
19
+ * `(#NNN)` suffix a squash merge writes, and the subject GitHub writes for a
20
+ * merge commit. Both carry the number in a form no other commit spells, so a
21
+ * pull request numbered 12 cannot match one numbered 123.
22
+ */
23
+ function grepArgs(pullRequest: number): string[] {
24
+ return [
25
+ '--extended-regexp',
26
+ '--grep',
27
+ `\\(#${pullRequest}\\)`,
28
+ '--grep',
29
+ `Merge pull request #${pullRequest} from`,
30
+ ]
31
+ }
32
+
33
+ /**
34
+ * Reads the trunk as this clone already holds it and never fetches. A validate
35
+ * run happens several times a sweep and a fetch per run is a cost the check
36
+ * does not carry today, so a clone behind the remote reports the row untested
37
+ * or leaves it parked rather than claiming work landed.
38
+ */
39
+ export function gitTrunkReader(root: string): TrunkReader {
40
+ const answered = new Map<number, boolean | undefined>()
41
+
42
+ return async (pullRequest) => {
43
+ if (answered.has(pullRequest)) return answered.get(pullRequest)
44
+
45
+ const landed = await readTrunk(root, pullRequest)
46
+ answered.set(pullRequest, landed)
47
+ return landed
48
+ }
49
+ }
50
+
51
+ async function readTrunk(
52
+ root: string,
53
+ pullRequest: number,
54
+ ): Promise<boolean | undefined> {
55
+ for (const ref of TRUNK_REFS) {
56
+ const result = await execa(
57
+ 'git',
58
+ [
59
+ '-C',
60
+ root,
61
+ 'log',
62
+ ref,
63
+ '-n',
64
+ '1',
65
+ '--format=%H',
66
+ ...grepArgs(pullRequest),
67
+ // The ref is a revision, and a repository tracking a path under the
68
+ // same name would otherwise fail the whole read as ambiguous.
69
+ '--',
70
+ ],
71
+ // `post-merge` drives the task verbs, and a hook exports the repository
72
+ // variables git reads ahead of `-C`, so the ambient environment would
73
+ // answer for whatever repository fired the hook.
74
+ {
75
+ reject: false,
76
+ timeout: GIT_TIMEOUT_MS,
77
+ env: gitEnv(),
78
+ extendEnv: false,
79
+ },
80
+ )
81
+
82
+ // A missing ref exits non-zero, which is the next ref's turn rather than an
83
+ // answer. An exit of zero with no commit is the ref saying the work is not
84
+ // on it, which is an answer and stops the walk.
85
+ if (result.exitCode === 0) return result.stdout.trim().length > 0
86
+ }
87
+
88
+ return undefined
89
+ }
@@ -5,8 +5,10 @@ import {
5
5
  archiveDir,
6
6
  isReservedStem,
7
7
  readOutcomes,
8
+ readPullRequest,
8
9
  tasksDir,
9
10
  } from '@/tasks/archive'
11
+ import { gitTrunkReader, type TrunkReader } from '@/tasks/trunk'
10
12
 
11
13
  const ORDERING_FILE = 'priority.md'
12
14
  const BACKLOG_FILE = 'backlog.md'
@@ -486,17 +488,34 @@ function citedStem(cell: string): string | undefined {
486
488
  return stemOf(target)
487
489
  }
488
490
 
491
+ /** What one blocker citation produced, since a row can be neither settled nor open. */
492
+ interface CitedResult {
493
+ readonly findings: readonly Finding[]
494
+ readonly untested: readonly Untested[]
495
+ }
496
+
497
+ function nothing(): CitedResult {
498
+ return { findings: [], untested: [] }
499
+ }
500
+
489
501
  /**
490
- * Reports what a cited task does to the row waiting on it. A live file whose
491
- * outcomes are all closed settles the row, and so does one sitting in the
492
- * archive. A file carrying no outcome box settles nothing, since a file the
493
- * check could not parse is not evidence of a finished one.
502
+ * Reports what a cited task does to the row waiting on it. A file sitting in
503
+ * the archive settles the row, and a live file settles it only once the work it
504
+ * carries is on the trunk. A file carrying no outcome box settles nothing,
505
+ * since a file the check could not parse is not evidence of a finished one.
494
506
  *
495
507
  * A citation resolving in neither folder is a broken pointer rather than a
496
508
  * closed task, and the two take different findings. Reading an absent file as
497
509
  * archived states a specific fact about a file nobody ever wrote, which is what
498
510
  * a renamed task or a typo produces.
499
511
  *
512
+ * A closed outcome is not the same fact as landed work. The ship chain marks
513
+ * outcomes as its first step and opens the pull request several steps later, so
514
+ * a check reading the checkbox reports the row settled while the branch is
515
+ * still in review. The pull request the task names is what the trunk is asked
516
+ * about, and a task naming none leaves the row untested rather than settled,
517
+ * because the only local signal left is the checkbox that produced the defect.
518
+ *
500
519
  * The outcome list comes off `readOutcomes` rather than a pattern of its own,
501
520
  * so this check cannot disagree with the archive and outcome verbs about which
502
521
  * checkboxes are outcomes and which sit inside a block a task displays.
@@ -506,42 +525,76 @@ async function checkCitedTask(
506
525
  subject: string,
507
526
  cited: string,
508
527
  root: string,
509
- ): Promise<Finding[]> {
528
+ trunk: TrunkReader,
529
+ ): Promise<CitedResult> {
510
530
  const live = join(tasksDir(root), `${cited}.md`)
511
531
 
512
532
  if (!existsSync(live)) {
513
533
  if (existsSync(join(archiveDir(root), `${cited}.md`))) {
514
- return [
534
+ return settled(group, subject, `waits on ${cited}, which is archived.`)
535
+ }
536
+
537
+ return {
538
+ findings: [
515
539
  {
516
- kind: 'blocker-settled',
540
+ kind: 'blocker-unresolved',
517
541
  group,
518
542
  subject,
519
- message: `waits on ${cited}, which is archived.`,
543
+ message: `waits on ${cited}, which is neither on the board nor archived.`,
520
544
  },
521
- ]
545
+ ],
546
+ untested: [],
522
547
  }
523
-
524
- return [
525
- {
526
- kind: 'blocker-unresolved',
527
- group,
528
- subject,
529
- message: `waits on ${cited}, which is neither on the board nor archived.`,
530
- },
531
- ]
532
548
  }
533
549
 
534
- const { open, closed } = readOutcomes(await readFile(live, 'utf8'))
535
- if (open.length > 0 || closed.length === 0) return []
550
+ const text = await readFile(live, 'utf8')
551
+ const { open, closed } = readOutcomes(text)
552
+ if (open.length > 0 || closed.length === 0) return nothing()
536
553
 
537
- return [
538
- {
539
- kind: 'blocker-settled',
554
+ const pullRequest = readPullRequest(text)
555
+ if (pullRequest === undefined) {
556
+ return untestedRow(
540
557
  group,
541
558
  subject,
542
- message: `waits on ${cited}, which carries no open outcome.`,
543
- },
544
- ]
559
+ `waits on ${cited}, which closed every outcome but names no pull request, so nothing tests whether the work reached the trunk.`,
560
+ )
561
+ }
562
+
563
+ const landed = await trunk(pullRequest)
564
+ if (landed === undefined) {
565
+ return untestedRow(
566
+ group,
567
+ subject,
568
+ `waits on ${cited}, whose pull request #${pullRequest} could not be read against the trunk.`,
569
+ )
570
+ }
571
+
572
+ if (!landed) return nothing()
573
+
574
+ return settled(
575
+ group,
576
+ subject,
577
+ `waits on ${cited}, whose pull request #${pullRequest} reached the trunk.`,
578
+ )
579
+ }
580
+
581
+ function settled(
582
+ group: BoardGroup,
583
+ subject: string,
584
+ message: string,
585
+ ): CitedResult {
586
+ return {
587
+ findings: [{ kind: 'blocker-settled', group, subject, message }],
588
+ untested: [],
589
+ }
590
+ }
591
+
592
+ function untestedRow(
593
+ group: BoardGroup,
594
+ subject: string,
595
+ message: string,
596
+ ): CitedResult {
597
+ return { findings: [], untested: [{ group, subject, message }] }
545
598
  }
546
599
 
547
600
  /**
@@ -564,6 +617,7 @@ async function checkCitedTask(
564
617
  async function checkParked(
565
618
  rows: readonly BoardRow[],
566
619
  root: string,
620
+ trunk: TrunkReader,
567
621
  ): Promise<{ findings: Finding[]; untested: Untested[] }> {
568
622
  const findings: Finding[] = []
569
623
  const untested: Untested[] = []
@@ -578,7 +632,15 @@ async function checkParked(
578
632
  const contested = readPaths(cell)
579
633
 
580
634
  if (cited) {
581
- findings.push(...(await checkCitedTask(row.group, subject, cited, root)))
635
+ const result = await checkCitedTask(
636
+ row.group,
637
+ subject,
638
+ cited,
639
+ root,
640
+ trunk,
641
+ )
642
+ findings.push(...result.findings)
643
+ untested.push(...result.untested)
582
644
  }
583
645
 
584
646
  const held = contested.filter((path) =>
@@ -613,12 +675,21 @@ function refuse(reason: ValidateRefusal, message: string): ValidateRefused {
613
675
  return { ok: false, reason, message }
614
676
  }
615
677
 
678
+ export interface ValidateOptions {
679
+ /** Overridden by tests, which supply the trunk rather than reaching for git. */
680
+ readonly trunk?: TrunkReader
681
+ }
682
+
616
683
  /**
617
684
  * Reports what every board row claims against what the tree holds. It writes
618
685
  * nothing: a row is a session's claim about readiness, and a validator that
619
686
  * repaired one would be asserting the claim it exists to test.
620
687
  */
621
- export async function validateBoard(root: string): Promise<ValidateOutcome> {
688
+ export async function validateBoard(
689
+ root: string,
690
+ options: ValidateOptions = {},
691
+ ): Promise<ValidateOutcome> {
692
+ const trunk = options.trunk ?? gitTrunkReader(root)
622
693
  const dir = tasksDir(root)
623
694
  if (!existsSync(dir)) {
624
695
  return refuse('no-board', `No task board at ${dir}.`)
@@ -647,7 +718,7 @@ export async function validateBoard(root: string): Promise<ValidateOutcome> {
647
718
  : []
648
719
 
649
720
  const stems = await listTaskStems(dir)
650
- const parked = await checkParked(rows, root)
721
+ const parked = await checkParked(rows, root, trunk)
651
722
 
652
723
  const findings = [
653
724
  ...checkMapping(rows, backlog, stems, dir),
@@ -132,7 +132,11 @@ Add no fourth readiness group in place of this file. The three group names are t
132
132
 
133
133
  ## Validation
134
134
 
135
- `aitk tasks validate` reads the columns above and reports where a row's claim and the tree disagree: a plan pointer resolving to no file, a task file reached by neither surface, a task on both surfaces or 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.
135
+ `aitk tasks validate` reads the columns above and reports where a row's claim and the tree disagree: a plan pointer resolving to no file, a task file reached by neither surface, a task on both surfaces or 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 reached the trunk 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.
136
+
137
+ A cited task is settled by being archived, or by closing every outcome and carrying a `Pull request:` line the trunk holds. The closed checkbox alone settles nothing, because the ship chain marks outcomes as its first step and opens the pull request several steps later, so a row read off the checkbox reports settled while the branch is still in review. A task that closed every outcome and names no pull request, and one whose pull request the run could not read against the trunk, are both reported as untested. Degrading either back to the checkbox would reproduce the defect under a name claiming it was fixed.
138
+
139
+ The trunk is read as the clone already holds it, `origin/main` first and local `main` behind it, and no run fetches. A validate happens several times a sweep and a fetch per run is a cost this check does not carry, so a clone behind the remote under-reports rather than claiming work landed.
136
140
 
137
141
  A task file is accounted for when a row on `priority.md` or a line on `backlog.md` names it, and reported when neither does. One check across both surfaces is what lets a task move between them without the move looking like a dropped file, and a task named by both is reported for the same reason a task in two groups is: it claims two things about itself and only one of them can hold. A project carrying no `backlog.md` is read as an empty backlog rather than refused, which leaves the one-to-one mapping this check ran before the second surface existed.
138
142
 
@@ -252,8 +256,16 @@ Archiving a task does not archive its plan. `claude-docs` owns the plans sweep a
252
256
 
253
257
  The row is matched by the link in its first cell rather than by a pattern against the whole line. A row names the task it is about in the first cell, so a link anywhere after that is a reference, such as a blocker pointing at what it waits on. Matching the line would delete the referring task's row too, on a board that is gitignored and has nothing to recover it from.
254
258
 
255
- Sweep the plan before archiving the task. The sweep finds its work by scanning the live folder, so a task archived first is beyond its reach for good, and the plan is left with no live task citing it and an archived task pointing at a path nothing will retarget. The archive refuses a task whose `Plan:` line still resolves inside `.claude/plans/` for that reason, which puts the ordering under a gate rather than under a convention the unattended caller cannot follow.
259
+ Sweep the plan before archiving the task. The sweep finds its work by scanning the live folder, so a task archived first is beyond its reach for good, and the plan is left with no live task citing it and an archived task pointing at a path nothing will retarget. The archive refuses the last task pointing at a live plan for that reason, which puts the ordering under a gate rather than under a convention the unattended caller cannot follow.
260
+
261
+ The gate counts the other live tasks citing the same plan rather than reading which folder the plan sits in. A plan several tasks share stays in the live folder by design, because the sweep is correct to leave a plan another live task still cites, so a gate reading the folder alone refuses every one of those tasks and the board and the sweep block each other with neither in the wrong. Counting the citations asks the question the gate means: a plan nothing else holds is one the sweep has yet to reach, and a plan a sibling still holds is one the sweep already decided about.
262
+
263
+ The count resolves the target against `.claude/tasks/` and against the project root both, so `../plans/x.md` and `.claude/plans/x.md` land on the same file and one plan two tasks spelled differently counts once. `aitk tasks plan-citations` exposes that count for a caller that wants it, and the gate reads it.
264
+
265
+ The `claude-docs` sweep states the rule rather than calling that verb, which is a duplication accepted with a reason rather than an oversight. A skill reaches a target the moment it merges and the CLI reaches one only when a release publishes, so a body calling a verb the installed `aitk` predates gets no record back and sweeps nothing. The two spellings therefore have to agree by hand until a release carries the verb, and the failure they guard against is a plan stranded by the form its citation was written in.
266
+
267
+ A caller reads the outcome off the record's `reason` field and never off the exit code. An operator's shell profile may wrap `aitk` in a function that runs the binary and then another command, taking its status from the second, which masks an ordinary refusal exactly as it masks an absent verb.
256
268
 
257
- That gate resolves the target against `.claude/tasks/` and against the project root both, so `../plans/x.md` and `.claude/plans/x.md` land on the same file. `claude-docs` reads the line the same way, and two halves of one ordering that parsed it differently would leave a plan stranded by the form it was written in.
269
+ Surviving a shared plan is not the same as sanctioning one. `standards/plan.md` puts one concern in one plan file, so a plan serving several tasks is a shape to correct rather than to build on, and the gate only stops it from deadlocking the board.
258
270
 
259
271
  A task with an open outcome stays on the board. Close it, or cut it from the task when the work is being abandoned, so what was dropped is recorded rather than inferred from an archived file. The sweep is gated on the same condition, so archiving around an open outcome also leaves the plan behind.