@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-docs/SKILL.md +4 -0
- package/docs/agents/tasks.md +28 -1
- package/docs/ai-workflow.md +2 -2
- package/package.json +1 -1
- package/src/commands/tasks.ts +108 -1
- package/src/tasks/archive.ts +158 -9
- package/src/tasks/trunk.ts +89 -0
- package/src/tasks/validate.ts +100 -29
- package/standards/tasks.md +15 -3
|
@@ -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.
|
package/docs/agents/tasks.md
CHANGED
|
@@ -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
|
|
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
|
|
package/docs/ai-workflow.md
CHANGED
|
@@ -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
|
|
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
|
|
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
package/src/commands/tasks.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { relative } from 'node:path'
|
|
2
2
|
import type { Command } from 'commander'
|
|
3
|
-
import {
|
|
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,
|
package/src/tasks/archive.ts
CHANGED
|
@@ -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
|
|
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),
|
|
201
|
-
isUnder(resolve(root, target),
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
+
}
|
package/src/tasks/validate.ts
CHANGED
|
@@ -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
|
|
491
|
-
*
|
|
492
|
-
*
|
|
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
|
-
|
|
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-
|
|
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
|
|
535
|
-
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
554
|
+
const pullRequest = readPullRequest(text)
|
|
555
|
+
if (pullRequest === undefined) {
|
|
556
|
+
return untestedRow(
|
|
540
557
|
group,
|
|
541
558
|
subject,
|
|
542
|
-
|
|
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
|
-
|
|
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(
|
|
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),
|
package/standards/tasks.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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.
|