@erclx/aitk 0.41.1 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/docs/agents/commands.md +2 -1
- package/docs/agents/index.md +1 -1
- package/docs/agents/tasks.md +33 -1
- package/docs/ai-workflow.md +2 -0
- package/package.json +1 -1
- package/snippets/claude/orchestrator-sweep.md +2 -0
- package/src/commands/tasks.ts +113 -0
- package/src/tasks/archive.ts +5 -3
- package/src/tasks/validate.ts +401 -0
- package/standards/tasks.md +5 -0
package/docs/agents/commands.md
CHANGED
|
@@ -27,6 +27,7 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
|
|
|
27
27
|
| `aitk feedback` | Write toolkit feedback from stdin to `.claude/review/`, or open a GitHub issue with `--github` |
|
|
28
28
|
| `aitk transcripts <url>` | Fetch a YouTube transcript with metadata frontmatter (needs `yt-dlp`) |
|
|
29
29
|
| `aitk tasks archive` | Move a shipped task off the board, clear its ordering row, and regenerate the index |
|
|
30
|
+
| `aitk tasks validate` | Report board rows whose plan, task file, group, or file set does not hold (`--json`) |
|
|
30
31
|
| `aitk comments scan` | Measure comment density by language and comment kind, with a trend recomputed from git |
|
|
31
32
|
| `aitk context audit` | Report required sections, length, depth, bullet weight, cited paths, provenance, and drift |
|
|
32
33
|
| `aitk capture [source]` | Render HTML capture sources to PNG, toolkit-only and absent from an installed package |
|
|
@@ -45,7 +46,7 @@ Each domain exposes a consistent shape where applicable: `list`, `install`, `syn
|
|
|
45
46
|
| `wiki` | `init` |
|
|
46
47
|
| `design` | `render` |
|
|
47
48
|
| `slides` | `render`, `list` |
|
|
48
|
-
| `tasks` | `archive`
|
|
49
|
+
| `tasks` | `archive`, `validate` |
|
|
49
50
|
| `comments` | `scan` |
|
|
50
51
|
| `context` | `audit` |
|
|
51
52
|
|
package/docs/agents/index.md
CHANGED
|
@@ -20,4 +20,4 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
|
|
|
20
20
|
- [Overview](overview.md): What this folder covers, the invocation rules every command inherits, and where domain behavior is documented instead
|
|
21
21
|
- [Sandbox](sandbox.md): Scenario routing, the expectation scoring surface, and the coverage census over scenarios and skills
|
|
22
22
|
- [Scripting](scripting.md): The runtime catalogs that replace hardcoded names, what each carries, and a headless invocation per domain
|
|
23
|
-
- [Tasks](tasks.md): Selecting a shipped task by stem or pull request, the refusal reasons, and why the board root defaults to the main worktree
|
|
23
|
+
- [Tasks](tasks.md): Selecting a shipped task by stem or pull request, the refusal reasons, the board checks validate runs, and why the board root defaults to the main worktree
|
package/docs/agents/tasks.md
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Tasks
|
|
3
|
-
description: Selecting a shipped task by stem or pull request, the refusal reasons, and why the board root defaults to the main worktree
|
|
3
|
+
description: Selecting a shipped task by stem or pull request, the refusal reasons, the board checks validate runs, and why the board root defaults to the main worktree
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Tasks
|
|
7
7
|
|
|
8
|
+
## Archive
|
|
9
|
+
|
|
8
10
|
`aitk tasks archive` moves a shipped task from `.claude/tasks/` into `.claude/.tmp/task-archive/`, drops its row from `priority.md`, and regenerates the board index. The three run as one unit, so the attended and unattended callers cannot archive differently.
|
|
9
11
|
|
|
10
12
|
Name the task by its filename stem, or by the pull request it carries:
|
|
@@ -30,4 +32,34 @@ Skills branch on the reason rather than on the exit code:
|
|
|
30
32
|
aitk tasks archive --pull-request 673 --json | jq -r 'if .ok then .task else .reason end'
|
|
31
33
|
```
|
|
32
34
|
|
|
35
|
+
## Validate
|
|
36
|
+
|
|
37
|
+
`aitk tasks validate` reports what each row of `priority.md` claims against what the tree holds. It reads and never writes, because a row is a session's claim about readiness and a validator that repaired one would be asserting the claim it exists to test.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
aitk tasks validate
|
|
41
|
+
aitk tasks validate --json
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Four checks run. Plan and Collisions are the two halves of the `## Run now` test the board standard states. Mapping and Grouping test the folder contract and hold for every group:
|
|
45
|
+
|
|
46
|
+
| Check | What it reports |
|
|
47
|
+
| ---------- | --------------------------------------------------------------------------------- |
|
|
48
|
+
| Plan | A `## Run now` row whose Plan column carries no link, or one resolving to no file |
|
|
49
|
+
| Mapping | A row naming no task file, and a task file no row names |
|
|
50
|
+
| Grouping | A task carrying a row in more than one readiness group |
|
|
51
|
+
| Collisions | Two `## Run now` rows whose Touches columns name a path in common |
|
|
52
|
+
|
|
53
|
+
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.
|
|
54
|
+
|
|
55
|
+
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.
|
|
56
|
+
|
|
57
|
+
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`, `priority`, and `session` siblings are skipped, since none of the three is a task.
|
|
58
|
+
|
|
59
|
+
Skills branch on the findings rather than on the exit code:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
aitk tasks validate --json | jq -r '.findings[] | "\(.kind): \(.subject)"'
|
|
63
|
+
```
|
|
64
|
+
|
|
33
65
|
For the board format, the `Pull request:` line, and the archive rules, see `.claude/standards/tasks.md`.
|
package/docs/ai-workflow.md
CHANGED
|
@@ -86,6 +86,8 @@ The sweep reads the whole board rather than the tasks the session touched. It is
|
|
|
86
86
|
|
|
87
87
|
`.claude/tasks/` is gitignored and resolves at the main worktree root, so every session shares one board. One file per task is what keeps concurrent sessions from overwriting each other, since a gitignored board has no history to recover a lost write from. Its `index.md` is generated by a hook rather than by `bun run check`, because the whole-repo index walk skips gitignored folders. A hand-maintained `priority.md` sits beside it carrying execution order and what each task is waiting on, which the alphabetical index cannot express.
|
|
88
88
|
|
|
89
|
+
`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.
|
|
90
|
+
|
|
89
91
|
`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/.tmp/task-archive/`. Creation is where the origin invariant is enforced: every task names a plan, a groundwork folder, or an issue, since a task with no origin is either lost context or work nobody decided to do. 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.
|
|
90
92
|
|
|
91
93
|
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.
|
package/package.json
CHANGED
|
@@ -5,6 +5,8 @@ Sweep the board as orchestrator after merging. Run this once per batch of merges
|
|
|
5
5
|
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.
|
|
6
6
|
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.
|
|
7
7
|
|
|
8
|
+
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.
|
|
9
|
+
|
|
8
10
|
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.
|
|
9
11
|
|
|
10
12
|
Report what closed, what moved, and what is ready to hand a worker. Name the file set each ready task touches, so the reader can check the disjointness claim rather than take it.
|
package/src/commands/tasks.ts
CHANGED
|
@@ -2,6 +2,11 @@ import { relative } from 'node:path'
|
|
|
2
2
|
import { $ } from 'bun'
|
|
3
3
|
import type { Command } from 'commander'
|
|
4
4
|
import { type ArchiveOutcome, archiveTask } from '@/tasks/archive'
|
|
5
|
+
import {
|
|
6
|
+
type Finding,
|
|
7
|
+
type ValidateOutcome,
|
|
8
|
+
validateBoard,
|
|
9
|
+
} from '@/tasks/validate'
|
|
5
10
|
import {
|
|
6
11
|
intro,
|
|
7
12
|
logAdd,
|
|
@@ -9,16 +14,25 @@ import {
|
|
|
9
14
|
logInfo,
|
|
10
15
|
logRemove,
|
|
11
16
|
logStep,
|
|
17
|
+
logWarn,
|
|
12
18
|
outro,
|
|
13
19
|
pipeOutput,
|
|
14
20
|
} from '@/ui'
|
|
15
21
|
|
|
22
|
+
/** Returned when the board carries a finding, which is the gating result. */
|
|
23
|
+
const EXIT_FINDINGS = 2
|
|
24
|
+
|
|
16
25
|
interface ArchiveCommandOptions {
|
|
17
26
|
readonly json?: boolean
|
|
18
27
|
readonly pullRequest?: string
|
|
19
28
|
readonly root?: string
|
|
20
29
|
}
|
|
21
30
|
|
|
31
|
+
interface ValidateCommandOptions {
|
|
32
|
+
readonly json?: boolean
|
|
33
|
+
readonly root?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
22
36
|
/**
|
|
23
37
|
* The board is shared scratch at the main worktree root, and `git worktree
|
|
24
38
|
* list` puts that root first. A pull inside a linked worktree fires the same
|
|
@@ -71,6 +85,105 @@ export function register(program: Command): void {
|
|
|
71
85
|
.action(async (task: string | undefined, opts: ArchiveCommandOptions) => {
|
|
72
86
|
process.exitCode = await runArchive(task, opts)
|
|
73
87
|
})
|
|
88
|
+
|
|
89
|
+
tasks
|
|
90
|
+
.command('validate')
|
|
91
|
+
.description(
|
|
92
|
+
'Report what each board row claims against what the tree holds',
|
|
93
|
+
)
|
|
94
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
95
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
96
|
+
.option('--root <path>', 'Board root, defaulting to the main worktree')
|
|
97
|
+
.addHelpText(
|
|
98
|
+
'after',
|
|
99
|
+
[
|
|
100
|
+
'',
|
|
101
|
+
'Checks:',
|
|
102
|
+
' every Run now row points at a plan file that resolves',
|
|
103
|
+
' every row maps to a task file and every task file to a row',
|
|
104
|
+
' no task carries more than one row',
|
|
105
|
+
' no two Run now rows touch the same file',
|
|
106
|
+
'',
|
|
107
|
+
'Exit codes:',
|
|
108
|
+
' 0 every check passed',
|
|
109
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
110
|
+
' 2 the board carries at least one finding',
|
|
111
|
+
'',
|
|
112
|
+
'It reports and never writes. A row is a claim about readiness, so a',
|
|
113
|
+
'session fixes the row the report names.',
|
|
114
|
+
'',
|
|
115
|
+
'Examples:',
|
|
116
|
+
' aitk tasks validate',
|
|
117
|
+
' aitk tasks validate --json',
|
|
118
|
+
'',
|
|
119
|
+
].join('\n'),
|
|
120
|
+
)
|
|
121
|
+
.action(async (opts: ValidateCommandOptions) => {
|
|
122
|
+
process.exitCode = await runValidate(opts)
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function runValidate(opts: ValidateCommandOptions): Promise<number> {
|
|
127
|
+
const root = opts.root ?? (await mainWorktreeRoot())
|
|
128
|
+
const outcome = await validateBoard(root)
|
|
129
|
+
|
|
130
|
+
return reportValidation(outcome, opts.json ?? false, root)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function reportValidation(
|
|
134
|
+
outcome: ValidateOutcome,
|
|
135
|
+
emitJson: boolean,
|
|
136
|
+
root: string,
|
|
137
|
+
): number {
|
|
138
|
+
if (!outcome.ok) {
|
|
139
|
+
if (emitJson) {
|
|
140
|
+
process.stdout.write(
|
|
141
|
+
`${JSON.stringify({ ok: false, reason: outcome.reason, message: outcome.message })}\n`,
|
|
142
|
+
)
|
|
143
|
+
return 1
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
intro('aitk tasks validate')
|
|
147
|
+
logStep('Refused')
|
|
148
|
+
logError(outcome.message)
|
|
149
|
+
outro()
|
|
150
|
+
return 1
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!emitJson) {
|
|
154
|
+
intro('aitk tasks validate')
|
|
155
|
+
logStep('Board')
|
|
156
|
+
logInfo(
|
|
157
|
+
`${outcome.rows} row(s) across the readiness groups, ${outcome.tasks} task file(s)`,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
logStep(outcome.findings.length === 0 ? 'Clean' : 'Findings')
|
|
161
|
+
if (outcome.findings.length === 0) {
|
|
162
|
+
logInfo('every row resolves, maps one to one, and touches its own files')
|
|
163
|
+
} else {
|
|
164
|
+
for (const finding of outcome.findings) logWarn(describe(finding))
|
|
165
|
+
}
|
|
166
|
+
outro()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (emitJson) {
|
|
170
|
+
process.stdout.write(
|
|
171
|
+
`${JSON.stringify({
|
|
172
|
+
ok: true,
|
|
173
|
+
root,
|
|
174
|
+
rows: outcome.rows,
|
|
175
|
+
tasks: outcome.tasks,
|
|
176
|
+
findings: outcome.findings,
|
|
177
|
+
})}\n`,
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return outcome.findings.length > 0 ? EXIT_FINDINGS : 0
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function describe(finding: Finding): string {
|
|
185
|
+
const scope = finding.group ? `${finding.group}: ` : ''
|
|
186
|
+
return `${scope}${finding.subject} ${finding.message}`
|
|
74
187
|
}
|
|
75
188
|
|
|
76
189
|
async function runArchive(
|
package/src/tasks/archive.ts
CHANGED
|
@@ -8,10 +8,12 @@ const ARCHIVE_DIR = join('.claude', '.tmp', 'task-archive')
|
|
|
8
8
|
const PLANS_DIR = join('.claude', 'plans')
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* Siblings that sit on the board without being tasks: the generated index, the
|
|
12
|
+
* hand-maintained ordering, and the pre-compaction session map. `validate`
|
|
13
|
+
* reads the same list, so neither verb can count a sibling as a task the other
|
|
14
|
+
* does not.
|
|
13
15
|
*/
|
|
14
|
-
const RESERVED_STEMS = ['index', 'priority'] as const
|
|
16
|
+
export const RESERVED_STEMS = ['index', 'priority', 'session'] as const
|
|
15
17
|
|
|
16
18
|
export const ARCHIVE_REFUSALS = [
|
|
17
19
|
'no-board',
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
3
|
+
import { join, resolve } from 'node:path'
|
|
4
|
+
import { RESERVED_STEMS, tasksDir } from '@/tasks/archive'
|
|
5
|
+
|
|
6
|
+
const ORDERING_FILE = 'priority.md'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The readiness headings `.claude/standards/tasks.md` fixes. The names are the
|
|
10
|
+
* contract rather than a suggestion, so a board grouping under names of its own
|
|
11
|
+
* reads as carrying no group at all and the run refuses instead of reporting a
|
|
12
|
+
* clean board it never parsed.
|
|
13
|
+
*/
|
|
14
|
+
export const BOARD_GROUPS = ['Run now', 'Up next', 'Needs a plan'] as const
|
|
15
|
+
|
|
16
|
+
export type BoardGroup = (typeof BOARD_GROUPS)[number]
|
|
17
|
+
|
|
18
|
+
export const VALIDATE_REFUSALS = [
|
|
19
|
+
'no-board',
|
|
20
|
+
'no-ordering',
|
|
21
|
+
'no-groups',
|
|
22
|
+
] as const
|
|
23
|
+
|
|
24
|
+
export type ValidateRefusal = (typeof VALIDATE_REFUSALS)[number]
|
|
25
|
+
|
|
26
|
+
export const FINDING_KINDS = [
|
|
27
|
+
'plan-unstated',
|
|
28
|
+
'plan-unresolved',
|
|
29
|
+
'task-unresolved',
|
|
30
|
+
'row-missing',
|
|
31
|
+
'row-duplicated',
|
|
32
|
+
'touches-unstated',
|
|
33
|
+
'touches-collided',
|
|
34
|
+
] as const
|
|
35
|
+
|
|
36
|
+
export type FindingKind = (typeof FINDING_KINDS)[number]
|
|
37
|
+
|
|
38
|
+
export interface Finding {
|
|
39
|
+
readonly kind: FindingKind
|
|
40
|
+
readonly group: BoardGroup | undefined
|
|
41
|
+
readonly subject: string
|
|
42
|
+
readonly message: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface BoardRow {
|
|
46
|
+
readonly group: BoardGroup
|
|
47
|
+
readonly label: string
|
|
48
|
+
readonly stem: string | undefined
|
|
49
|
+
readonly plan: string | undefined
|
|
50
|
+
/** Absent when the group fixes no `Touches` column, empty when it read none. */
|
|
51
|
+
readonly touches: readonly string[] | undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ValidateReport {
|
|
55
|
+
readonly ok: true
|
|
56
|
+
readonly rows: number
|
|
57
|
+
readonly tasks: number
|
|
58
|
+
readonly findings: readonly Finding[]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ValidateRefused {
|
|
62
|
+
readonly ok: false
|
|
63
|
+
readonly reason: ValidateRefusal
|
|
64
|
+
readonly message: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type ValidateOutcome = ValidateReport | ValidateRefused
|
|
68
|
+
|
|
69
|
+
export function orderingPath(root: string): string {
|
|
70
|
+
return join(tasksDir(root), ORDERING_FILE)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Pulls the target out of a markdown link, which is how both the `Task` and the
|
|
75
|
+
* `Plan` column spell their pointer. A cell carrying prose instead of a link
|
|
76
|
+
* yields nothing, and that absence is the finding rather than a parse failure.
|
|
77
|
+
*/
|
|
78
|
+
function linkTarget(cell: string): string | undefined {
|
|
79
|
+
const match = /\[[^\]]*\]\(([^)]+)\)/.exec(cell)
|
|
80
|
+
return match ? match[1].trim() : undefined
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function linkText(cell: string): string {
|
|
84
|
+
const match = /\[([^\]]*)\]\([^)]+\)/.exec(cell)
|
|
85
|
+
return (match ? match[1] : cell).trim()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function stemOf(target: string): string | undefined {
|
|
89
|
+
const name = target.split('/').pop()
|
|
90
|
+
if (!name || !name.endsWith('.md')) return undefined
|
|
91
|
+
return name.slice(0, -'.md'.length)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Reads the file set out of a `Touches` cell written as prose. Paths are the
|
|
96
|
+
* backticked spans, which is the only marker the column carries, and a span
|
|
97
|
+
* naming a skill or a command rather than a file is dropped by the same test.
|
|
98
|
+
*
|
|
99
|
+
* An extension opens with a letter, which is what separates `commands.md` from
|
|
100
|
+
* a task version like `v40.2`. Reading the version as a path would collide two
|
|
101
|
+
* rows that merely cite the same task.
|
|
102
|
+
*/
|
|
103
|
+
export function readPaths(cell: string): string[] {
|
|
104
|
+
const spans = cell.match(/`[^`]+`/g) ?? []
|
|
105
|
+
const paths = spans
|
|
106
|
+
.map((span) => span.slice(1, -1).trim())
|
|
107
|
+
.filter(
|
|
108
|
+
(span) => span.includes('/') || /\.[A-Za-z][A-Za-z0-9]*$/.test(span),
|
|
109
|
+
)
|
|
110
|
+
.map((span) => span.replace(/^\.\//, '').replace(/\/+$/, ''))
|
|
111
|
+
|
|
112
|
+
return [...new Set(paths)]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Two paths touch the same work when they are equal or one contains the other.
|
|
117
|
+
* A row naming a directory and a row naming a file inside it collide, which a
|
|
118
|
+
* set intersection on the written strings alone would miss.
|
|
119
|
+
*/
|
|
120
|
+
function sharesPath(left: string, right: string): boolean {
|
|
121
|
+
return (
|
|
122
|
+
left === right ||
|
|
123
|
+
left.startsWith(`${right}/`) ||
|
|
124
|
+
right.startsWith(`${left}/`)
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function splitCells(line: string): string[] {
|
|
129
|
+
return line
|
|
130
|
+
.trim()
|
|
131
|
+
.replace(/^\|/, '')
|
|
132
|
+
.replace(/\|$/, '')
|
|
133
|
+
.split('|')
|
|
134
|
+
.map((cell) => cell.trim())
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isSeparator(cells: readonly string[]): boolean {
|
|
138
|
+
return cells.every((cell) => /^:?-{3,}:?$/.test(cell))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function columnIndex(header: readonly string[], name: string): number {
|
|
142
|
+
return header.findIndex((cell) => cell.toLowerCase() === name)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isGroup(heading: string): heading is BoardGroup {
|
|
146
|
+
return (BOARD_GROUPS as readonly string[]).includes(heading)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Parses the ordering file into rows keyed by readiness group. Columns are read
|
|
151
|
+
* from each table's own header rather than by position, so a group whose shape
|
|
152
|
+
* differs from this repository's is reported for what it lacks instead of
|
|
153
|
+
* having its second cell read as something it never was.
|
|
154
|
+
*/
|
|
155
|
+
export function readBoard(text: string): {
|
|
156
|
+
readonly rows: readonly BoardRow[]
|
|
157
|
+
readonly groups: readonly BoardGroup[]
|
|
158
|
+
} {
|
|
159
|
+
const rows: BoardRow[] = []
|
|
160
|
+
const groups: BoardGroup[] = []
|
|
161
|
+
|
|
162
|
+
let group: BoardGroup | undefined
|
|
163
|
+
let header: string[] | undefined
|
|
164
|
+
|
|
165
|
+
for (const line of text.split('\n')) {
|
|
166
|
+
const heading = /^##\s+(.+?)\s*$/.exec(line)
|
|
167
|
+
if (heading) {
|
|
168
|
+
const title = heading[1]
|
|
169
|
+
group = isGroup(title) ? title : undefined
|
|
170
|
+
header = undefined
|
|
171
|
+
if (group && !groups.includes(group)) groups.push(group)
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!group || !line.trimStart().startsWith('|')) continue
|
|
176
|
+
|
|
177
|
+
const cells = splitCells(line)
|
|
178
|
+
if (isSeparator(cells)) continue
|
|
179
|
+
|
|
180
|
+
if (!header) {
|
|
181
|
+
header = cells
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const taskAt = columnIndex(header, 'task')
|
|
186
|
+
const planAt = columnIndex(header, 'plan')
|
|
187
|
+
const touchesAt = columnIndex(header, 'touches')
|
|
188
|
+
|
|
189
|
+
const task = taskAt >= 0 ? (cells[taskAt] ?? '') : ''
|
|
190
|
+
const target = linkTarget(task)
|
|
191
|
+
const plan = planAt >= 0 ? linkTarget(cells[planAt] ?? '') : undefined
|
|
192
|
+
|
|
193
|
+
rows.push({
|
|
194
|
+
group,
|
|
195
|
+
label: linkText(task) || task,
|
|
196
|
+
stem: target ? stemOf(target) : undefined,
|
|
197
|
+
plan,
|
|
198
|
+
touches: touchesAt >= 0 ? readPaths(cells[touchesAt] ?? '') : undefined,
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { rows, groups }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function listTaskStems(dir: string): Promise<string[]> {
|
|
206
|
+
const entries = await readdir(dir)
|
|
207
|
+
const reserved: readonly string[] = RESERVED_STEMS
|
|
208
|
+
|
|
209
|
+
return entries
|
|
210
|
+
.filter((entry) => entry.endsWith('.md'))
|
|
211
|
+
.map((entry) => entry.slice(0, -'.md'.length))
|
|
212
|
+
.filter((stem) => !reserved.includes(stem))
|
|
213
|
+
.sort()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Resolves a pointer against the board and against the project root both, the
|
|
218
|
+
* way `claude-docs` reads the same line. A fragment is dropped first, since an
|
|
219
|
+
* anchor is part of the link and never part of the path.
|
|
220
|
+
*/
|
|
221
|
+
function resolves(target: string, dir: string, root: string): boolean {
|
|
222
|
+
const path = target.split('#')[0]
|
|
223
|
+
if (!path) return false
|
|
224
|
+
|
|
225
|
+
return existsSync(resolve(dir, path)) || existsSync(resolve(root, path))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function checkMapping(
|
|
229
|
+
rows: readonly BoardRow[],
|
|
230
|
+
stems: readonly string[],
|
|
231
|
+
dir: string,
|
|
232
|
+
): Finding[] {
|
|
233
|
+
const findings: Finding[] = []
|
|
234
|
+
const seen = new Map<string, number>()
|
|
235
|
+
|
|
236
|
+
for (const row of rows) {
|
|
237
|
+
if (!row.stem) {
|
|
238
|
+
findings.push({
|
|
239
|
+
kind: 'task-unresolved',
|
|
240
|
+
group: row.group,
|
|
241
|
+
subject: row.label,
|
|
242
|
+
message: 'names no task file, so the row points at nothing.',
|
|
243
|
+
})
|
|
244
|
+
continue
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
seen.set(row.stem, (seen.get(row.stem) ?? 0) + 1)
|
|
248
|
+
|
|
249
|
+
if (!existsSync(join(dir, `${row.stem}.md`))) {
|
|
250
|
+
findings.push({
|
|
251
|
+
kind: 'task-unresolved',
|
|
252
|
+
group: row.group,
|
|
253
|
+
subject: row.stem,
|
|
254
|
+
message: 'has a row and no task file.',
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
for (const [stem, count] of seen) {
|
|
260
|
+
if (count > 1) {
|
|
261
|
+
findings.push({
|
|
262
|
+
kind: 'row-duplicated',
|
|
263
|
+
group: undefined,
|
|
264
|
+
subject: stem,
|
|
265
|
+
message: `carries ${count} rows. A task belongs to exactly one group.`,
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
for (const stem of stems) {
|
|
271
|
+
if (!seen.has(stem)) {
|
|
272
|
+
findings.push({
|
|
273
|
+
kind: 'row-missing',
|
|
274
|
+
group: undefined,
|
|
275
|
+
subject: stem,
|
|
276
|
+
message: 'is a task file with no row on the board.',
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return findings
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function checkPlans(
|
|
285
|
+
rows: readonly BoardRow[],
|
|
286
|
+
dir: string,
|
|
287
|
+
root: string,
|
|
288
|
+
): Finding[] {
|
|
289
|
+
const findings: Finding[] = []
|
|
290
|
+
|
|
291
|
+
for (const row of rows) {
|
|
292
|
+
if (row.group !== 'Run now') continue
|
|
293
|
+
|
|
294
|
+
if (!row.plan) {
|
|
295
|
+
findings.push({
|
|
296
|
+
kind: 'plan-unstated',
|
|
297
|
+
group: row.group,
|
|
298
|
+
subject: row.stem ?? row.label,
|
|
299
|
+
message:
|
|
300
|
+
'states no plan pointer, so the readiness claim cannot be checked.',
|
|
301
|
+
})
|
|
302
|
+
continue
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (!resolves(row.plan, dir, root)) {
|
|
306
|
+
findings.push({
|
|
307
|
+
kind: 'plan-unresolved',
|
|
308
|
+
group: row.group,
|
|
309
|
+
subject: row.stem ?? row.label,
|
|
310
|
+
message: `points at ${row.plan}, which resolves to no file.`,
|
|
311
|
+
})
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return findings
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The half of the `## Run now` test a person cannot check by eye. Two rows a
|
|
320
|
+
* worker may be handed at once must touch disjoint files, and the `Touches`
|
|
321
|
+
* column is the only place either set is written down.
|
|
322
|
+
*/
|
|
323
|
+
function checkCollisions(rows: readonly BoardRow[]): Finding[] {
|
|
324
|
+
const findings: Finding[] = []
|
|
325
|
+
const ready = rows.filter((row) => row.group === 'Run now')
|
|
326
|
+
|
|
327
|
+
for (const row of ready) {
|
|
328
|
+
// An absent column and an unreadable one both leave the row untested by
|
|
329
|
+
// the loop below, so reporting only the second would pass a board whose
|
|
330
|
+
// `## Run now` table declares no file set at all.
|
|
331
|
+
if (!row.touches || row.touches.length === 0) {
|
|
332
|
+
findings.push({
|
|
333
|
+
kind: 'touches-unstated',
|
|
334
|
+
group: row.group,
|
|
335
|
+
subject: row.stem ?? row.label,
|
|
336
|
+
message: 'names no file, so its collisions cannot be read.',
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
for (let i = 0; i < ready.length; i += 1) {
|
|
342
|
+
for (let j = i + 1; j < ready.length; j += 1) {
|
|
343
|
+
const left = ready[i]
|
|
344
|
+
const right = ready[j]
|
|
345
|
+
const shared = (left.touches ?? []).filter((path) =>
|
|
346
|
+
(right.touches ?? []).some((other) => sharesPath(path, other)),
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
if (shared.length === 0) continue
|
|
350
|
+
|
|
351
|
+
findings.push({
|
|
352
|
+
kind: 'touches-collided',
|
|
353
|
+
group: 'Run now',
|
|
354
|
+
subject: `${left.stem ?? left.label} and ${right.stem ?? right.label}`,
|
|
355
|
+
message: `both touch ${shared.join(', ')}.`,
|
|
356
|
+
})
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return findings
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function refuse(reason: ValidateRefusal, message: string): ValidateRefused {
|
|
364
|
+
return { ok: false, reason, message }
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Reports what every board row claims against what the tree holds. It writes
|
|
369
|
+
* nothing: a row is a session's claim about readiness, and a validator that
|
|
370
|
+
* repaired one would be asserting the claim it exists to test.
|
|
371
|
+
*/
|
|
372
|
+
export async function validateBoard(root: string): Promise<ValidateOutcome> {
|
|
373
|
+
const dir = tasksDir(root)
|
|
374
|
+
if (!existsSync(dir)) {
|
|
375
|
+
return refuse('no-board', `No task board at ${dir}.`)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const ordering = orderingPath(root)
|
|
379
|
+
if (!existsSync(ordering)) {
|
|
380
|
+
return refuse('no-ordering', `No ordering file at ${ordering}.`)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const { rows, groups } = readBoard(await readFile(ordering, 'utf8'))
|
|
384
|
+
|
|
385
|
+
if (groups.length === 0) {
|
|
386
|
+
return refuse(
|
|
387
|
+
'no-groups',
|
|
388
|
+
`No readiness group in ${ORDERING_FILE}. Expected one of: ${BOARD_GROUPS.join(', ')}.`,
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const stems = await listTaskStems(dir)
|
|
393
|
+
|
|
394
|
+
const findings = [
|
|
395
|
+
...checkMapping(rows, stems, dir),
|
|
396
|
+
...checkPlans(rows, dir, root),
|
|
397
|
+
...checkCollisions(rows),
|
|
398
|
+
]
|
|
399
|
+
|
|
400
|
+
return { ok: true, rows: rows.length, tasks: stems.length, findings }
|
|
401
|
+
}
|
package/standards/tasks.md
CHANGED
|
@@ -26,12 +26,15 @@ Does not govern:
|
|
|
26
26
|
.claude/tasks/
|
|
27
27
|
├── index.md ← generated, never hand-edited
|
|
28
28
|
├── priority.md ← hand-maintained execution order
|
|
29
|
+
├── session.md ← optional, what a compaction is about to destroy
|
|
29
30
|
├── v09.0-sync-paths.md
|
|
30
31
|
└── v13.0-toolkit-drift.md
|
|
31
32
|
```
|
|
32
33
|
|
|
33
34
|
One file per task is what keeps the board safe under parallel sessions. Two sessions working different tasks never write the same file, which matters because a gitignored board has no history to recover a clobbered write from.
|
|
34
35
|
|
|
36
|
+
Three siblings sit in the folder without being tasks, and each earns its place by being governed somewhere. `index.md` and `priority.md` are governed here. `session.md` is the pre-compaction handoff, written by `orchestrator-handoff` and read by `orchestrator-resume`, and it is optional: a project running no orchestrator carries no such file. Anything reading the folder as a task list skips all three, so a name outside the set is a task whatever it holds.
|
|
37
|
+
|
|
35
38
|
`index.md` is generated from sibling frontmatter. The folder is gitignored, so the whole-repo index walk skips it and a hook passing the changed path regenerates it instead. Never hand-edit it.
|
|
36
39
|
|
|
37
40
|
The `claude-tasks` skill creates and archives task files. `claude-docs` marks outcomes `[x]` in an existing file and sweeps the plans those tasks cite. Neither does the other's job.
|
|
@@ -50,6 +53,8 @@ Readiness is three groups under fixed headings, `## Run now`, `## Up next`, and
|
|
|
50
53
|
|
|
51
54
|
Each group fixes its own columns, which follow from the test above it rather than from preference. Neither half of the `## Run now` test is checkable without the file set and the plan sitting beside the task. The blocker column under `## Up next` names whether a collision or a dependency holds the row. `## Needs a plan` states no file set at all, because a task with no plan has no bounded one to state. A group with no rows keeps its heading and its header row.
|
|
52
55
|
|
|
56
|
+
`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. 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.
|
|
57
|
+
|
|
53
58
|
```markdown
|
|
54
59
|
---
|
|
55
60
|
title: Priority
|