@erclx/aitk 3.50.0 โ 3.51.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-autoship/REQUIREMENT.md +1 -0
- package/claude/skills/claude-autoship/SKILL.md +2 -0
- package/claude/skills/claude-docs/REQUIREMENT.md +2 -1
- package/claude/skills/claude-docs/SKILL.md +4 -5
- package/claude/skills/claude-review/SKILL.md +1 -1
- package/docs/agents/commands.md +1 -1
- package/docs/agents/tasks.md +14 -8
- package/docs/ai-workflow.md +4 -2
- package/package.json +1 -1
- package/src/tasks/validate.ts +162 -5
|
@@ -17,6 +17,7 @@ Review is the step that varies most. It gets skipped on a diff that needed one,
|
|
|
17
17
|
- Give every step a stop condition, and leave the code on the branch and the receipts on disk at each one
|
|
18
18
|
- Classify the changed-file list by path as well as by extension, so informational prose skips a code review with no signal on it and executable prose still reaches one
|
|
19
19
|
- Split findings by origin, stopping on a critical or should-fix one the branch inherited and repairing one this run caused
|
|
20
|
+
- Own the review receipt's lifetime, since this chain writes it, cites it in its own closing block, and is the only body that can read whether the step keeping it is still using it
|
|
20
21
|
- Delegate the ship sequence to `git-ship` rather than restating it, and name only what this chain adds to it
|
|
21
22
|
- Open the pull request as a draft before the continuous integration watch begins, since a pull request marked after it is mergeable for the length of the run
|
|
22
23
|
- Name the recovery for the stop it took, since the value of stopping is that the user knows where to resume
|
|
@@ -152,6 +152,8 @@ Read origin as causation rather than authorship. Staleness this run induced in a
|
|
|
152
152
|
|
|
153
153
|
Bound the repair at one pass, the way Step 3 bounds verify. When that re-read shows the finding still standing, stop: `โ A self-introduced finding survived one fix pass. See .claude/review/branch/review-<slug>.md. Fix and run /git-ship.`
|
|
154
154
|
|
|
155
|
+
This chain owns the receipt's lifetime, which is what makes the Output block's citation resolve on a run that reaches it. `claude-docs` used to delete the current slug's receipt while running under Step 7 below, so the closing line named a file the same run had already removed. That sweep now reaches only reports whose branch is gone, which collects this one a branch later rather than during the run that wrote it. The cost is one receipt per live branch left in `.claude/review/branch/`, bounded by the branch count rather than by the lifetime of the checkout.
|
|
156
|
+
|
|
155
157
|
## Step 7: ship
|
|
156
158
|
|
|
157
159
|
Invoke `aitk:git-ship`. That body owns the sequence, being the verify gate, memory capture, both doc syncs, staging, the commit grouping, the branch rename, the pull request, the CI watch, and the scoped memory review, along with the reason each step sits where it does. This step used to restate that list and the two drifted apart with nothing comparing them, so read the order there and never here.
|
|
@@ -13,7 +13,7 @@ A decision's verification anchor has the same shape of gap in the other directio
|
|
|
13
13
|
|
|
14
14
|
A handoff file has the same shape of gap as an unmarked outcome. A page a learning workspace produced and an operator already picked a destination for sits in gitignored scratch until something lands it, and the session that produced it is gone by the time a branch exists to carry the write. Left unfolded it reads as promoted while the destination holds nothing.
|
|
15
15
|
|
|
16
|
-
The receipt half of that sweep was missing entirely. A review receipt
|
|
16
|
+
The receipt half of that sweep was missing entirely. A review receipt was deleted per shipped branch and a memory-review receipt was skipped, because the exclusion protecting the checklist and audit prefixes caught a third by accident. Nothing but an operator asking collected it, and a collection that waits on someone remembering to ask is one a folder outgrows, so the skipped population grew per shipped branch and never shrank until it was most of the folder.
|
|
17
17
|
|
|
18
18
|
The trigger side carries a gap of its own. "Sync the docs" names either corpus to the person saying it, so a description leaving its corpus to the opening clause alone competes with its public-facing sibling on nothing the routing field states, and the planning surface the request was about goes untouched.
|
|
19
19
|
|
|
@@ -31,6 +31,7 @@ The trigger side carries a gap of its own. "Sync the docs" names either corpus t
|
|
|
31
31
|
- Scan every memory-review receipt rather than the one matching this slug, since the skill that writes them runs after this one in the ship chain and a slug is unique per feature
|
|
32
32
|
- Collect a memory-review receipt whose items are all decided, folding each skip into its memory entry first, since a declined item is recorded nowhere else and a promoted one is already in git
|
|
33
33
|
- Leave a memory-review receipt holding a pending item, and report the count. A branch shipping is not an operator deciding what the receipt proposed.
|
|
34
|
+
- Leave the current branch's review receipt alone, since the chain that wrote it cites it in its own closing line and this skill cannot read whether that citation is still live
|
|
34
35
|
- Land each block of a promotion handoff at the destination its heading names, then delete the file so a later run does not fold it twice
|
|
35
36
|
- Take a promotion destination as already decided, since the operator confirmed it where the page was produced
|
|
36
37
|
|
|
@@ -245,15 +245,15 @@ Write the retarget as a markdown link, `Plan: [feature-<slug>](../plans/archive/
|
|
|
245
245
|
|
|
246
246
|
### Reviews
|
|
247
247
|
|
|
248
|
-
|
|
248
|
+
Leave the current branch's review receipt where it is. `claude-autoship` Step 6 keeps minor findings in `.claude/review/branch/review-<slug>.md` and its closing block hands the reader that path, so deleting it here removes the file the chain that invoked this skill is still citing. Seven runs recorded that collision across two days before a sandbox fixture asserted the receipt and could pass only on a run the chain stopped early.
|
|
249
249
|
|
|
250
|
-
|
|
250
|
+
The body that writes a receipt owns its lifetime. This skill sweeps on behalf of whatever called it and has no way to read whether a file is still in use, where the chain that wrote this one cites it in its own output and knows. What reaps it is the branch sweep below, one branch later, once the branch it names is gone.
|
|
251
251
|
|
|
252
|
-
|
|
252
|
+
Sweep the branch reports this session never opened. List `.claude/review/branch/review-*.md`, run the slug transform in `${CLAUDE_SKILL_DIR}/../../standards/slug.md` over every name `git branch --format='%(refname:short)'` prints, and delete a report whose slug matches none of them. Take the names from that format rather than from `git branch --list`, which marks the current branch with `* ` and a branch checked out in another worktree with `+ `, so a transform reading the marked lines as written turns a live branch into a slug nothing matches and sweeps a report a sibling worktree is still working from. A branch report is read once, by the session addressing it, and the durable record of what a review found is the comment `claude-pr-review` posts on the pull request, so a report outliving its branch is holding nothing. Skipping this leaves them accumulating for the life of the checkout, since a slug is unique per feature and no later branch ever looks for one.
|
|
253
253
|
|
|
254
254
|
What that removes is a local-only review on a branch deleted before it opened a pull request. `claude-review` says so where a reader meets the report, and the sweep runs anyway rather than keeping every report against the one case, since nothing else ever clears them.
|
|
255
255
|
|
|
256
|
-
Memory receipts sweep board-wide, like
|
|
256
|
+
Memory receipts sweep board-wide, like both halves of this step above them. Scan every `.claude/review/memory/memory-review-*.md`, not only the one matching this slug. `claude-memory-review` writes its receipt after this skill has run in every ship chain, so a sweep keyed on the current slug looks for a file that does not exist yet, and no later branch looks for it either because a slug is unique per feature. Scanning the folder is what makes the sweep fire at all.
|
|
257
257
|
|
|
258
258
|
For each receipt, count the H2 items still marked ๐ pending:
|
|
259
259
|
|
|
@@ -268,7 +268,6 @@ Output one line per file swept:
|
|
|
268
268
|
|
|
269
269
|
- `๐ฆ Archived: <path>` for a plan moved into `.claude/plans/archive/`
|
|
270
270
|
- `โญ Kept: <path>, still cited by <task-file>` for a plan another live task shares
|
|
271
|
-
- `๐งน Deleted: <path>` for a swept branch report
|
|
272
271
|
- `๐งน Deleted: <path>, branch gone` for a branch report whose branch no longer exists
|
|
273
272
|
- `๐งน Deleted: <path>, folded <n> skips` for a swept memory receipt
|
|
274
273
|
- `โญ Kept: <path>, <n> items pending` for a memory receipt still holding decisions
|
|
@@ -128,7 +128,7 @@ If there are no findings, write `โ
No findings.` to the file with a timestamp.
|
|
|
128
128
|
|
|
129
129
|
The `.claude/review/` directory is gitignored. Do not stage or commit the file.
|
|
130
130
|
|
|
131
|
-
The report is disposable.
|
|
131
|
+
The report is disposable. It outlives the ship chain that reads it, and `claude-docs` sweeps it once the branch it names is gone, because the durable record of what a review found is the comment `claude-pr-review` posts on the pull request. A review run on a branch that never opens one leaves nothing behind once that branch is gone, so fold anything worth keeping into the pull request body or a task finding while the report is still on disk.
|
|
132
132
|
|
|
133
133
|
### Chat output
|
|
134
134
|
|
package/docs/agents/commands.md
CHANGED
|
@@ -29,7 +29,7 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
|
|
|
29
29
|
| `aitk tasks archive` | Move a shipped task off the board, clear its ordering row, and regenerate the index |
|
|
30
30
|
| `aitk tasks pull-request` | Record a pull request number on the task a branch closes, by stem or `--plan` (`--json`) |
|
|
31
31
|
| `aitk tasks outcome` | Mark outcomes `[x]` on a task by position, repeating `--close` (`--json`) |
|
|
32
|
-
| `aitk tasks validate` | Report board rows whose plan, task file, group, file set, or blocker does not hold (`--json`)
|
|
32
|
+
| `aitk tasks validate` | Report board rows whose shape, order, plan, task file, group, file set, or blocker does not hold (`--json`) |
|
|
33
33
|
| `aitk intake list` | Report intake folder counts, or one folder's items, keeping what is unread with `--unread` (`--json`) |
|
|
34
34
|
| `aitk intake answer` | Write selections into one cluster's answer slots, repeating `--set <item>=<answer>` (`--json`) |
|
|
35
35
|
| `aitk teach list` | Report learning workspaces and the ordinal a new one takes, or what one workspace holds (`--json`) |
|
package/docs/agents/tasks.md
CHANGED
|
@@ -119,20 +119,26 @@ aitk tasks validate
|
|
|
119
119
|
aitk tasks validate --json
|
|
120
120
|
```
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
Seven checks run. Plan and Collisions reach one half each of the `## Run now` test the board standard states. Mapping and Grouping test the folder contract and hold for every group, and Shape holds for every group too, ahead of the four. Ordering reaches only the `## Needs a plan` rows, and Blockers reaches every row outside `## Run now`:
|
|
123
123
|
|
|
124
|
-
| Check | What it reports
|
|
125
|
-
| ---------- |
|
|
126
|
-
|
|
|
127
|
-
|
|
|
128
|
-
|
|
|
129
|
-
|
|
|
130
|
-
|
|
|
124
|
+
| Check | What it reports |
|
|
125
|
+
| ---------- | -------------------------------------------------------------------------------------------------------------------- |
|
|
126
|
+
| Shape | A row whose cell count disagrees with its table's header, or one stranded behind a table a blank line already closed |
|
|
127
|
+
| Plan | A `## Run now` row whose Plan column carries no link, or one resolving to no file |
|
|
128
|
+
| Mapping | A row or backlog line naming no task file, and a task file neither surface names |
|
|
129
|
+
| Grouping | A task carrying a row in more than one readiness group, or on both surfaces |
|
|
130
|
+
| Ordering | A `## Needs a plan` row whose stated position disagrees with where it actually sits |
|
|
131
|
+
| Collisions | Two `## Run now` rows whose Touches columns name a path in common |
|
|
132
|
+
| Blockers | A parked row whose blocker has stopped holding, or whose cited task resolves nowhere |
|
|
133
|
+
|
|
134
|
+
Shape runs before any other check reads a row, since a row failing it carries no dependable fields for the rest to check. A blank or prose line closes the table above it, so the walk treats the next pipe line as a fresh header candidate rather than as a continuation. That candidate counts as a header only when the line behind it is a separator carrying the same cell count, and one that fails is `row-untabled`, stranded behind a table that already closed. Cell count still has to match the header on every row that clears that test, and a row whose count disagrees is `row-misshapen`, the shape a dropped pipe or a merged column produces.
|
|
131
135
|
|
|
132
136
|
Mapping spans two surfaces, because a task sits on `priority.md` when it would plausibly be planned soon and on `backlog.md` otherwise. A task file either surface names is accounted for, a file neither names is `row-missing`, and a file both name is `row-duplicated` for the reason a task in two groups is: it claims two things about itself and only one can hold. One check across both is what lets a task move between them without the move reading as a dropped file.
|
|
133
137
|
|
|
134
138
|
A backlog line is a bullet carrying a link to a sibling task, since the backlog is a flat unordered list rather than a table. A bullet holding prose is skipped rather than reported, which keeps the file's own intro out of the findings, and the task that bullet meant to name is still reported as reaching neither surface. A project carrying no `backlog.md` reads as an empty backlog rather than a refusal, which leaves the one-to-one mapping this check ran before the second surface existed.
|
|
135
139
|
|
|
140
|
+
Ordering reads only a `## Needs a plan` row whose `Waiting on` cell states a position at all. That phrase is prose rather than data, searched for anywhere in the cell rather than at its start, since every live row states its position at the end of a sentence rather than at the front. The vocabulary stops at `first` through `twentieth` plus `last`, since a parser strict enough to catch a gap would otherwise flag a row phrased correctly and differently, and bounding it to those words is what keeps a cell reading `Untestable from here` from matching on `from`. A row that does state a position is checked against where it actually sits, and that one comparison catches a gap, a duplicate, and a sequence starting somewhere other than first alike.
|
|
141
|
+
|
|
136
142
|
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.
|
|
137
143
|
|
|
138
144
|
Where a directory holds the other row's file, the finding names the row that claimed it, reading `both touch src/tasks, which v2.0-second claims as a folder.` The shared strings alone leave an over-broad cell and a genuine overlap identical, which is how a correct report was once read as the verb comparing folders rather than files.
|
package/docs/ai-workflow.md
CHANGED
|
@@ -103,7 +103,7 @@ A person points it at a private repository once and both verbs refuse until they
|
|
|
103
103
|
|
|
104
104
|
A plan that ships is archived, never deleted. `aitk:claude-docs` moves it to `.claude/plans/archive/` and retargets the task file's `Plan:` line at the new location, so a completed task still leads to the reasoning behind it. An archive sits inside the record folder it archives rather than beside it, so one ignore entry and one backed-folder entry cover a record and everything it has retired. The folder is gitignored, which is why a deleted plan had no recovery path. A plan cited by more than one task stays put until the last of them closes, since moving it early would strand every other pointer.
|
|
105
105
|
|
|
106
|
-
A branch review report takes the other route and is swept rather than archived. `claude-review` writes it to `.claude/review/branch/`, the session addressing it reads it once, and the durable record of what a review found is the comment `claude-pr-review` posts on the pull request, so `claude-docs` deletes the report
|
|
106
|
+
A branch review report takes the other route and is swept rather than archived. `claude-review` writes it to `.claude/review/branch/`, the session addressing it reads it once, and the durable record of what a review found is the comment `claude-pr-review` posts on the pull request, so `claude-docs` deletes any report whose branch is gone. The body that writes a report owns how long it lives, which leaves the shipping branch's own report on disk through the run that cites it and collects it a branch later. What that loses is a local-only review on a branch that never opened a pull request, which is why the report says so where a reader meets it.
|
|
107
107
|
|
|
108
108
|
The plans sweep reads the whole board rather than the tasks the session touched. It is the one place the skill reaches past its own rule against editing a task file the session did not change, because a task that closed while an earlier run missed its archive is exactly what the sweep exists to clear. Reaching it is safe: the archive moves the plan and retargets the pointer in the same pass, so an untouched task ends up with a working link rather than a broken one.
|
|
109
109
|
|
|
@@ -113,7 +113,9 @@ The plans sweep reads the whole board rather than the tasks the session touched.
|
|
|
113
113
|
|
|
114
114
|
`.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.
|
|
115
115
|
|
|
116
|
-
`aitk tasks validate`
|
|
116
|
+
`aitk tasks validate` reads a row against its own table before it reads anything the row claims. A blank or prose line closes the table above it, so a row stranded there is checked against the line behind it rather than parsed as a continuation, and a row that clears that test still has its cell count checked against its header. A `## Needs a plan` row that states its own position, searched for `<ordinal> here` or the bare word `last` anywhere in the cell rather than at its start, is checked against where it actually sits, which is what catches a gap, a duplicate, and a sequence starting somewhere other than first alike.
|
|
117
|
+
|
|
118
|
+
Past that shape, it checks what a surviving row claims 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. The collision check is the half a reader cannot run by eye, and it is what keeps two workers from being handed colliding work. Blockers 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.
|
|
117
119
|
|
|
118
120
|
`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/tasks/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.
|
|
119
121
|
|
package/package.json
CHANGED
package/src/tasks/validate.ts
CHANGED
|
@@ -37,6 +37,9 @@ export const FINDING_KINDS = [
|
|
|
37
37
|
'task-unresolved',
|
|
38
38
|
'row-missing',
|
|
39
39
|
'row-duplicated',
|
|
40
|
+
'row-misshapen',
|
|
41
|
+
'row-untabled',
|
|
42
|
+
'row-misordered',
|
|
40
43
|
'touches-unstated',
|
|
41
44
|
'touches-collided',
|
|
42
45
|
'blocker-settled',
|
|
@@ -95,6 +98,8 @@ export interface BoardRow {
|
|
|
95
98
|
readonly touches: readonly string[] | undefined
|
|
96
99
|
/** Absent when the group fixes no `Waiting on` column, which is `## Run now`. */
|
|
97
100
|
readonly waiting: string | undefined
|
|
101
|
+
/** The ordinal phrase a `Waiting on` cell states about its own position, undefined when the cell carries none. */
|
|
102
|
+
readonly ordinal: OrdinalWord | 'last' | undefined
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
export interface ValidateReport {
|
|
@@ -216,23 +221,107 @@ function isGroup(heading: string): heading is BoardGroup {
|
|
|
216
221
|
return (BOARD_GROUPS as readonly string[]).includes(heading)
|
|
217
222
|
}
|
|
218
223
|
|
|
224
|
+
const ORDINAL_WORDS = [
|
|
225
|
+
'first',
|
|
226
|
+
'second',
|
|
227
|
+
'third',
|
|
228
|
+
'fourth',
|
|
229
|
+
'fifth',
|
|
230
|
+
'sixth',
|
|
231
|
+
'seventh',
|
|
232
|
+
'eighth',
|
|
233
|
+
'ninth',
|
|
234
|
+
'tenth',
|
|
235
|
+
'eleventh',
|
|
236
|
+
'twelfth',
|
|
237
|
+
'thirteenth',
|
|
238
|
+
'fourteenth',
|
|
239
|
+
'fifteenth',
|
|
240
|
+
'sixteenth',
|
|
241
|
+
'seventeenth',
|
|
242
|
+
'eighteenth',
|
|
243
|
+
'nineteenth',
|
|
244
|
+
'twentieth',
|
|
245
|
+
] as const
|
|
246
|
+
|
|
247
|
+
type OrdinalWord = (typeof ORDINAL_WORDS)[number]
|
|
248
|
+
|
|
249
|
+
const ORDINAL_HERE = new RegExp(
|
|
250
|
+
`\\b(${ORDINAL_WORDS.join('|')})\\s+here\\b`,
|
|
251
|
+
'i',
|
|
252
|
+
)
|
|
253
|
+
const LAST_AT_END = /\blast\.?\s*$/i
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Reads the ordinal phrase a `Waiting on` cell states about its own position,
|
|
257
|
+
* searching the whole cell for `<ordinal> here` or the bare word `last` rather
|
|
258
|
+
* than anchoring to where the cell opens. The phrase sits at the end of the
|
|
259
|
+
* sentence on every row that carries one, as in `nothing, cleared 2026-08-31
|
|
260
|
+
* when it merged. Third here`. The two-word phrase is safe to find anywhere,
|
|
261
|
+
* since it needs both a closed vocabulary word and `here` beside it, but a
|
|
262
|
+
* bare `last` is one of the commonest words in English and this corpus writes
|
|
263
|
+
* it constantly, as in `the last of the two instruments this rename needs`.
|
|
264
|
+
* Anchoring it to the end of the cell is what keeps that prose out of the
|
|
265
|
+
* findings, since every row that actually declares itself last puts the word
|
|
266
|
+
* there and nowhere else. A cell carrying neither reads as unordered rather
|
|
267
|
+
* than as an error, which keeps a legitimately-phrased row out of the
|
|
268
|
+
* findings.
|
|
269
|
+
*/
|
|
270
|
+
function readOrdinal(cell: string): OrdinalWord | 'last' | undefined {
|
|
271
|
+
const here = ORDINAL_HERE.exec(cell)
|
|
272
|
+
if (here?.[1]) return here[1].toLowerCase() as OrdinalWord
|
|
273
|
+
|
|
274
|
+
return LAST_AT_END.test(cell) ? 'last' : undefined
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function isRowLine(line: string): boolean {
|
|
278
|
+
return line.trimStart().startsWith('|')
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Whether a header candidate genuinely opens a table, meaning the line right
|
|
283
|
+
* behind it is a separator carrying the same cell count. A row landing where a
|
|
284
|
+
* blank line already closed the table above it looks identical to a fresh
|
|
285
|
+
* header until this read, since both are the first pipe line the walk has
|
|
286
|
+
* seen since the reset.
|
|
287
|
+
*/
|
|
288
|
+
function opensTable(
|
|
289
|
+
header: readonly string[],
|
|
290
|
+
next: string | undefined,
|
|
291
|
+
): boolean {
|
|
292
|
+
if (next === undefined || !isRowLine(next)) return false
|
|
293
|
+
const cells = splitCells(next)
|
|
294
|
+
return isSeparator(cells) && cells.length === header.length
|
|
295
|
+
}
|
|
296
|
+
|
|
219
297
|
/**
|
|
220
298
|
* Parses the ordering file into rows keyed by readiness group. Columns are read
|
|
221
299
|
* from each table's own header rather than by position, so a group whose shape
|
|
222
300
|
* differs from this repository's is reported for what it lacks instead of
|
|
223
301
|
* having its second cell read as something it never was.
|
|
302
|
+
*
|
|
303
|
+
* The walk carries one more piece of state than a header: whether the table
|
|
304
|
+
* that header opened is still open. A blank or prose line closes it, so a row
|
|
305
|
+
* stranded behind that close is read as one candidate header among many rather
|
|
306
|
+
* than as a continuation of the table above, and each surviving row's cell
|
|
307
|
+
* count is checked against the header it actually followed.
|
|
224
308
|
*/
|
|
225
309
|
export function readBoard(text: string): {
|
|
226
310
|
readonly rows: readonly BoardRow[]
|
|
227
311
|
readonly groups: readonly BoardGroup[]
|
|
312
|
+
readonly findings: readonly Finding[]
|
|
228
313
|
} {
|
|
229
314
|
const rows: BoardRow[] = []
|
|
230
315
|
const groups: BoardGroup[] = []
|
|
316
|
+
const findings: Finding[] = []
|
|
317
|
+
|
|
318
|
+
const lines = text.split('\n')
|
|
231
319
|
|
|
232
320
|
let group: BoardGroup | undefined
|
|
233
321
|
let header: string[] | undefined
|
|
234
322
|
|
|
235
|
-
for (
|
|
323
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
324
|
+
const line = lines[i] ?? ''
|
|
236
325
|
const heading = /^##\s+(.+?)\s*$/.exec(line)
|
|
237
326
|
if (heading) {
|
|
238
327
|
const title = heading[1]
|
|
@@ -242,16 +331,42 @@ export function readBoard(text: string): {
|
|
|
242
331
|
continue
|
|
243
332
|
}
|
|
244
333
|
|
|
245
|
-
if (!group
|
|
334
|
+
if (!group) continue
|
|
335
|
+
|
|
336
|
+
if (!isRowLine(line)) {
|
|
337
|
+
header = undefined
|
|
338
|
+
continue
|
|
339
|
+
}
|
|
246
340
|
|
|
247
341
|
const cells = splitCells(line)
|
|
248
342
|
if (isSeparator(cells)) continue
|
|
249
343
|
|
|
250
344
|
if (!header) {
|
|
345
|
+
if (!opensTable(cells, lines[i + 1])) {
|
|
346
|
+
findings.push({
|
|
347
|
+
kind: 'row-untabled',
|
|
348
|
+
group,
|
|
349
|
+
subject: line.trim(),
|
|
350
|
+
message:
|
|
351
|
+
'sits outside a table. The table above it already closed at the line before it.',
|
|
352
|
+
})
|
|
353
|
+
continue
|
|
354
|
+
}
|
|
355
|
+
|
|
251
356
|
header = cells
|
|
252
357
|
continue
|
|
253
358
|
}
|
|
254
359
|
|
|
360
|
+
if (cells.length !== header.length) {
|
|
361
|
+
findings.push({
|
|
362
|
+
kind: 'row-misshapen',
|
|
363
|
+
group,
|
|
364
|
+
subject: line.trim(),
|
|
365
|
+
message: `carries ${cells.length} cell(s) against the ${header.length}-cell header above it.`,
|
|
366
|
+
})
|
|
367
|
+
continue
|
|
368
|
+
}
|
|
369
|
+
|
|
255
370
|
const taskAt = columnIndex(header, 'task')
|
|
256
371
|
const planAt = columnIndex(header, 'plan')
|
|
257
372
|
const touchesAt = columnIndex(header, 'touches')
|
|
@@ -260,6 +375,7 @@ export function readBoard(text: string): {
|
|
|
260
375
|
const task = taskAt >= 0 ? (cells[taskAt] ?? '') : ''
|
|
261
376
|
const target = linkTarget(task)
|
|
262
377
|
const plan = planAt >= 0 ? linkTarget(cells[planAt] ?? '') : undefined
|
|
378
|
+
const waiting = waitingAt >= 0 ? (cells[waitingAt] ?? '') : undefined
|
|
263
379
|
|
|
264
380
|
rows.push({
|
|
265
381
|
group,
|
|
@@ -267,11 +383,46 @@ export function readBoard(text: string): {
|
|
|
267
383
|
stem: target ? stemOf(target) : undefined,
|
|
268
384
|
plan,
|
|
269
385
|
touches: touchesAt >= 0 ? readPaths(cells[touchesAt] ?? '') : undefined,
|
|
270
|
-
waiting
|
|
386
|
+
waiting,
|
|
387
|
+
ordinal: waiting ? readOrdinal(waiting) : undefined,
|
|
271
388
|
})
|
|
272
389
|
}
|
|
273
390
|
|
|
274
|
-
return { rows, groups }
|
|
391
|
+
return { rows, groups, findings }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Reports a `## Needs a plan` row whose stated ordinal disagrees with where it
|
|
396
|
+
* actually sits in that group. Reading the sequence for gaps and duplicates on
|
|
397
|
+
* its own would detect less, since row position is contiguous by
|
|
398
|
+
* construction, and a hand-renumbered sequence is exactly a case where the
|
|
399
|
+
* prose and the position have come apart.
|
|
400
|
+
*/
|
|
401
|
+
function checkOrdinals(rows: readonly BoardRow[]): Finding[] {
|
|
402
|
+
const findings: Finding[] = []
|
|
403
|
+
const parked = rows.filter((row) => row.group === 'Needs a plan')
|
|
404
|
+
|
|
405
|
+
parked.forEach((row, index) => {
|
|
406
|
+
if (!row.ordinal) return
|
|
407
|
+
|
|
408
|
+
const position = index + 1
|
|
409
|
+
const expected =
|
|
410
|
+
row.ordinal === 'last'
|
|
411
|
+
? parked.length
|
|
412
|
+
: ORDINAL_WORDS.indexOf(row.ordinal) + 1
|
|
413
|
+
|
|
414
|
+
if (expected === position) return
|
|
415
|
+
|
|
416
|
+
const phrase = row.ordinal === 'last' ? 'last' : `${row.ordinal} here`
|
|
417
|
+
findings.push({
|
|
418
|
+
kind: 'row-misordered',
|
|
419
|
+
group: row.group,
|
|
420
|
+
subject: subjectOf(row),
|
|
421
|
+
message: `declares itself "${phrase}", but sits at position ${position} of ${parked.length} in ${row.group}.`,
|
|
422
|
+
})
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
return findings
|
|
275
426
|
}
|
|
276
427
|
|
|
277
428
|
/**
|
|
@@ -805,7 +956,11 @@ export async function validateBoard(
|
|
|
805
956
|
return refuse('no-ordering', `No ordering file at ${ordering}.`)
|
|
806
957
|
}
|
|
807
958
|
|
|
808
|
-
const {
|
|
959
|
+
const {
|
|
960
|
+
rows,
|
|
961
|
+
groups,
|
|
962
|
+
findings: shapeFindings,
|
|
963
|
+
} = readBoard(await readFile(ordering, 'utf8'))
|
|
809
964
|
|
|
810
965
|
if (groups.length === 0) {
|
|
811
966
|
return refuse(
|
|
@@ -826,9 +981,11 @@ export async function validateBoard(
|
|
|
826
981
|
const parked = await checkParked(rows, root, trunk)
|
|
827
982
|
|
|
828
983
|
const findings = [
|
|
984
|
+
...shapeFindings,
|
|
829
985
|
...checkMapping(rows, backlog, stems, dir),
|
|
830
986
|
...checkPlans(rows, dir, root),
|
|
831
987
|
...checkCollisions(rows),
|
|
988
|
+
...checkOrdinals(rows),
|
|
832
989
|
...parked.findings,
|
|
833
990
|
]
|
|
834
991
|
|