@erclx/aitk 3.49.0 → 3.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "3.49.0",
4
+ "version": "3.51.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -97,12 +97,30 @@ If all UI changes are covered by e2e tests, continue.
97
97
 
98
98
  ## Step 5: review
99
99
 
100
- Classify the diff first. Take the union of `git diff --name-only <base>` and `git ls-files --others --exclude-standard`, resolving `<base>` per Diff baseline. The classifier reads names only.
100
+ Classify the diff first. Take the union of `git diff --name-only <base>` and `git ls-files --others --exclude-standard`, resolving `<base>` per Diff baseline, then hand that set to the verb rather than reading it against the list below yourself:
101
+
102
+ ```bash
103
+ aitk autoship classify --json <path>...
104
+ ```
105
+
106
+ The verb reads names only and touches git not at all, so the set stays the one this step already computed and no second baseline resolves to disagree with the first. Branch on the record's `decision` rather than on the exit code, which a shell function wrapping `aitk` can flatten to zero.
107
+
108
+ - `skip`. Every path reads as prose and none states agent behavior. Skip review entirely and continue to Step 7.
109
+ - `review`. Invoke `aitk:claude-review`. The record names the `file` that decided it and the `test` it failed, `extension` for a path that is not prose and `behavior-path` for prose that states what an agent does.
110
+ - `refused`, carrying reason `no-changes`. The changed set was empty, so take the stop below.
111
+
112
+ Say in the run which of the two decided, the verb or the written fallback, since a reader otherwise cannot tell a classification from a judgment.
101
113
 
102
114
  An empty list stops the chain: `❌ No changed files to classify. Re-run when the plan has yet to produce its output. When the output is gitignored by design, autoship cannot ship it, so take the work out of the chain.` An empty list satisfies the prose-only test vacuously, so reading it as prose-only routes the branch past review instead of through it.
103
115
 
104
116
  The two causes want different responses. A plan that has yet to produce its output is a re-run once it has. A plan whose output is gitignored by design, such as a read pass writing to `.claude/.tmp/`, is work the chain cannot carry at all, since `git-stage` finds nothing to commit six steps later. Never advise removing the output from `.gitignore`, which trades a stopped run for scratch committed into the repository.
105
117
 
118
+ ### When the verb is absent
119
+
120
+ The verb ships with the CLI and this body ships with the plugin, so a target holding an older binary meets a missing subcommand. Apply the written test by hand there, and say the fallback decided it.
121
+
122
+ Never read an absent subcommand as a skip. Failing open is the exact defect the verb closes, and a shell that answers `command not found` reaching a body that skips on anything other than a `skip` record would ship every branch unreviewed.
123
+
106
124
  The skip needs both tests to pass: every changed file matches `*.md` or `*.txt`, and no changed file sits under a behavior path. On a pass, skip review entirely and continue to Step 7. Otherwise invoke `aitk:claude-review`.
107
125
 
108
126
  Behavior paths carry two spellings, the one a surface authors at and the one it reaches a session at, so the rule reads the same in a toolkit and in a project that consumed one:
@@ -120,6 +138,8 @@ Informational prose is already gated by `docs-sync`, `claude-standards-audit`, a
120
138
 
121
139
  The list covers this toolkit's authoring layout and the layout it installs, which is not every layout. A project keeping executable prose where neither spelling reaches adds the path, and until it does every branch touching it skips review silently.
122
140
 
141
+ The verb reads the same set from `src/autoship/paths.ts`, so a path added here belongs there too and a path added there belongs here. Two copies is what the fallback costs, and it stands until a release retires the written half.
142
+
123
143
  ## Step 6: evaluate findings
124
144
 
125
145
  Skip this step when Step 5 skipped review. Otherwise read `.claude/review/branch/review-<slug>.md` at the main worktree root. Split every finding by origin before parsing the summary line (`X critical, Y should-fix, Z minor`), since the stop exists for a defect the branch inherited rather than for one this run introduced.
@@ -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`) |
@@ -60,6 +60,7 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
60
60
  | `aitk deps audit` | Report published advisories against the resolved dependency set, refusing rather than reporting clean when the index is unreachable (`--json`) |
61
61
  | `aitk labels audit` | Report the labels a changed set earns from the pull request label map and the paths no row reaches (`--json`) |
62
62
  | `aitk labels scan` | Fail a pull request whose title or body carries a phase label, sorting a release pull request's tokens as semver rather than as a leak (`--event`, `--json`) |
63
+ | `aitk autoship classify` | Decide whether a changed set needs the review pass, naming the file and the test that decided it (`--json`) |
63
64
  | `aitk census [path]` | Report tracked file count, a breakdown by extension, and a line total that skips whatever reads as binary (`--json`) |
64
65
  | `aitk audits run` | Run every audit as one set, report per check under one verdict, and compare each count to the recorded baseline (`--json`, `--record`) |
65
66
  | `aitk audits list` | List every audit the set runs, with the corpus each reads and whether it gates (`--json`) |
@@ -113,6 +114,7 @@ Each domain exposes a consistent shape where applicable: `list`, `install`, `syn
113
114
  | `secrets` | `scan` |
114
115
  | `deps` | `audit` |
115
116
  | `labels` | `audit` |
117
+ | `autoship` | `classify` |
116
118
  | `audits` | `run`, `list` |
117
119
 
118
120
  Common patterns:
@@ -27,6 +27,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
27
27
  - [Overview](overview.md): What this folder covers, the invocation rules every command inherits, and where domain behavior is documented instead
28
28
  - [Records](records.md): Validating the session records under .claude/ and the standards corpus, the per-kind checks, the refusal reasons, migrating a record a frontmatter change orphaned, reading each folder's size and growth, backing the folders to a private remote, and which root each kind defaults to
29
29
  - [Restated instructions](restated.md): Counting the instructions the always-loaded file and every path-scoped rule share with the seed, the shipped skill bodies, and each other, how a match is decided, the three classes, which surface a later edit starts from, and why the sweep reports rather than gates
30
+ - [Review classification](review-classification.md): Deciding whether a changed set needs the review pass, the two tests it runs, why an empty set refuses rather than skipping, and the written fallback a target on an older binary falls back to
30
31
  - [Routing report](routing.md): Reading per CLAUDE.md section how many bullets name a path, what counts as naming one, when a rule counts as covering it, the two refusals, and why the verb reports rather than gates
31
32
  - [Rule citations](rule-citations.md): Resolving every path a governance rule cites and every frontmatter glob the internal corpus declares, the three forms a citation is written in, the shapes that look like citations and are not, the two classes where an absent path is correct, why the glob half reads one corpus, and why this one gates
32
33
  - [Sandbox](sandbox.md): Scenario routing, the expectation scoring surface, and the coverage census over scenarios and skills
@@ -0,0 +1,77 @@
1
+ ---
2
+ title: Review classification
3
+ description: Deciding whether a changed set needs the review pass, the two tests it runs, why an empty set refuses rather than skipping, and the written fallback a target on an older binary falls back to
4
+ ---
5
+
6
+ # Review classification
7
+
8
+ `aitk autoship classify` answers the one decision the ship chain used to make by reading a bulleted list out of a skill body. It takes the names a branch changed and returns whether the review pass can be skipped, which file decided it, and which of the two tests that file failed.
9
+
10
+ ```bash
11
+ aitk autoship classify docs/index.md README.md
12
+ aitk autoship classify --json .claude/skills/deploy-check/SKILL.md
13
+ ```
14
+
15
+ | Option | Behavior |
16
+ | -------- | ---------------------------------------------------------- |
17
+ | `--json` | Add a machine-readable record on stdout, keeping the frame |
18
+
19
+ Positional paths are the whole input. The verb never touches git, so it reads no baseline of its own and the caller hands over the set it already computed. A second read here could resolve a different range than the one the chain measured, which is the stale-baseline failure a prior row closed.
20
+
21
+ Under `--json` the record holds stdout alone and the frame still renders on stderr, refusals included, which is the split `output-shape.md` fixes for every mode.
22
+
23
+ ## The two tests
24
+
25
+ A branch skips review only when both hold across every changed name:
26
+
27
+ - **Every path reads as prose.** The extension is `.md` or `.txt`, compared lowercased. Anything else is code, a config, or an asset.
28
+ - **No path sits under a behavior path.** Markdown under one of those surfaces states what an agent does, so a change there is a behavior change wearing a prose extension.
29
+
30
+ One failing file sends the whole branch to review. Documentation shipped beside a behavior change does not cancel it.
31
+
32
+ The record names the first file to fail, tested in the caller's own order, and each file is read for its extension before its path. A set failing both tests reports one file and one test, which is all a caller needs to route.
33
+
34
+ ## The behavior paths
35
+
36
+ Most carry two spellings, the one a surface authors at and the one it reaches a session at, so the rule reads the same in this toolkit and in a project that consumed one.
37
+
38
+ | Surface | Spellings |
39
+ | ----------------- | ------------------------------------- |
40
+ | Plugin skills | `claude/skills/`, `.claude/skills/` |
41
+ | Governance rules | `governance/rules/`, `.claude/rules/` |
42
+ | Standards | `standards/` |
43
+ | Snippets | `snippets/` |
44
+ | Internal and seed | `internal/`, `tooling/` |
45
+ | Root instructions | `CLAUDE.md` |
46
+
47
+ `standards/` and `snippets/` carry one spelling apiece. Neither installs into a project, reaching a reader by resolution and through the `claude/snippets` symlink respectively, so there is no consumed copy to name.
48
+
49
+ `CLAUDE.md` is matched as a whole path rather than as a prefix, because a prefix reaches nothing that sits in no folder. A nested `docs/CLAUDE.md` stays informational.
50
+
51
+ The set is data at `src/autoship/paths.ts`. That makes it permanently exempt from any later design that folds a machine-parsed list back into the surface citing it, per the machine-parsed clause in `.claude/ARCHITECTURE.md`.
52
+
53
+ ## Exit codes and refusals
54
+
55
+ | Code | Meaning |
56
+ | ---- | ------------------------------------------------------------ |
57
+ | `0` | prose-only, so the review pass can be skipped |
58
+ | `1` | refused, with `reason` naming the cause |
59
+ | `2` | review is needed, with `file` and `test` naming what decided |
60
+
61
+ Branch on the record's `decision` rather than on the exit code. A shell function wrapping `aitk` takes its status from whatever it runs last, so every non-zero exit can reach a caller as zero.
62
+
63
+ An empty set refuses with `no-changes` rather than skipping. Both tests are universally quantified, so an empty set satisfies them vacuously, and reading that as prose-only would route a branch past review for having produced no output at all.
64
+
65
+ ## Why it is a verb
66
+
67
+ The decision was three sentences in `claude-autoship` Step 5, applied by a session reading a bulleted list of paths. It failed three times. A driven arm on 2026-08-30 staged `.claude/skills/deploy-check/SKILL.md`, which that list names, and the chain skipped review and opened a draft pull request anyway. The rule was correct and the session did not apply it, and the two fixes before that one were each a rewrite of the same prose.
68
+
69
+ A rule a session can talk itself out of moves into a verb. That is the same argument the quiz-order draw in `aitk teach lesson` was decided on: an instruction is a hope where a verb is a check.
70
+
71
+ ## The written fallback
72
+
73
+ The verb ships with the CLI and Step 5 ships with the plugin, so a target holding an older binary meets a missing subcommand. The skill body keeps the written list and applies it by hand there, naming which of the two decided the run.
74
+
75
+ The fallback is never a skip. Failing open is the exact defect the verb closes, so a body that skips on anything other than a `skip` record would ship every branch unreviewed the moment the subcommand went absent.
76
+
77
+ Two copies of the path set is what the fallback costs, and it stands until a release retires the written half.
@@ -119,20 +119,26 @@ aitk tasks validate
119
119
  aitk tasks validate --json
120
120
  ```
121
121
 
122
- Five checks run. Plan and Collisions reach one half each of the `## Run now` test the board standard states. Mapping and Grouping test the folder contract and hold for every group. Blockers reaches the rows outside `## Run now`:
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
- | Plan | A `## Run now` row whose Plan column carries no link, or one resolving to no file |
127
- | Mapping | A row or backlog line naming no task file, and a task file neither surface names |
128
- | Grouping | A task carrying a row in more than one readiness group, or on both surfaces |
129
- | Collisions | Two `## Run now` rows whose Touches columns name a path in common |
130
- | Blockers | A parked row whose blocker has stopped holding, or whose cited task resolves nowhere |
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.
@@ -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` 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.
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
 
@@ -144,6 +146,10 @@ Markdown under one states what an agent does, so a branch touching it reaches re
144
146
 
145
147
  An empty changed-file list stops the chain rather than counting as prose-only. The filename test passes vacuously on an empty set, which routed a branch past review instead of through it.
146
148
 
149
+ `aitk autoship classify` answers that decision now, and the chain branches on the record it returns rather than on a session applying the list above. Three runs read past the list while it was prose, the last of them a driven arm that staged a file the list names and shipped a draft pull request with no review. The verb takes the names the chain already computed, so no second diff baseline resolves, and it names the file and the test that decided. [Review classification](agents/review-classification.md) carries the record shape and the exit codes.
150
+
151
+ The list stays written in the skill body as the fallback for a target whose installed CLI predates the verb, since the two ship at different speeds. That fallback is never a skip: failing open is the defect the verb closes, so an absent subcommand routes to review rather than past it.
152
+
147
153
  #### Memory in the chain
148
154
 
149
155
  `git-ship` runs its verify gate and then opens on `claude-memory-capture`, which sends what the session learned to the surface that owns it. `autoship` reaches the same step by invoking that skill at its Step 7 rather than restating the order. A fact about a domain carrying an entry in `.claude/context/index.md` is routed to that entry, and `claude-docs` folds it in on the next step, so it ships in the same pull request. Anything no entry owns stays a file in `.claude/memory/`.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "3.49.0",
4
+ "version": "3.51.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -0,0 +1,75 @@
1
+ import {
2
+ BEHAVIOR_FILES,
3
+ BEHAVIOR_PREFIXES,
4
+ PROSE_EXTENSIONS,
5
+ } from '@/autoship/paths'
6
+
7
+ /**
8
+ * Which of the two tests a file failed.
9
+ *
10
+ * Named apart rather than folded into one boolean, because the repair differs.
11
+ * A non-prose extension means the branch carries code and review is ordinary. A
12
+ * behavior path means prose that states what an agent does, which is the half
13
+ * three sessions read past.
14
+ */
15
+ export type FailedTest = 'extension' | 'behavior-path'
16
+
17
+ /** Why a classification produced no reading. */
18
+ export type ClassifyRefusal = 'no-changes'
19
+
20
+ export type Classification =
21
+ | { readonly kind: 'skip' }
22
+ | {
23
+ readonly kind: 'review'
24
+ readonly test: FailedTest
25
+ readonly file: string
26
+ }
27
+ | { readonly kind: 'refused'; readonly reason: ClassifyRefusal }
28
+
29
+ /**
30
+ * Prefix-anchored against the folders, matching how `resolveCoverage` reads the
31
+ * label map, and equality against the whole paths a prefix cannot reach.
32
+ */
33
+ function underBehaviorPath(path: string): boolean {
34
+ if (BEHAVIOR_FILES.some((file) => file === path)) return true
35
+ return BEHAVIOR_PREFIXES.some((prefix) => path.startsWith(prefix))
36
+ }
37
+
38
+ function readsAsProse(path: string): boolean {
39
+ const lowered = path.toLowerCase()
40
+ return PROSE_EXTENSIONS.some((extension) => lowered.endsWith(extension))
41
+ }
42
+
43
+ /**
44
+ * Decides whether a branch's changed set skips the review pass.
45
+ *
46
+ * The skip needs both tests to pass: every name reads as prose, and no name
47
+ * sits under a behavior path. One behavior file sends the whole branch to
48
+ * review, since documentation shipped beside a behavior change does not cancel
49
+ * it.
50
+ *
51
+ * An empty set refuses rather than skipping. Both tests are universally
52
+ * quantified, so an empty set satisfies them vacuously, and reading that as
53
+ * prose-only would route a branch past review for having produced no output at
54
+ * all.
55
+ *
56
+ * Names only. The caller hands the set the ship chain already computed, so no
57
+ * second baseline is read here and the stale-baseline half stays closed.
58
+ */
59
+ export function classifyChanges(paths: readonly string[]): Classification {
60
+ if (paths.length === 0) return { kind: 'refused', reason: 'no-changes' }
61
+
62
+ // First failure in the caller's order wins, and each file is tested for its
63
+ // extension before its path. A set failing both tests reports one file and
64
+ // one test, which is all the branch needs to route.
65
+ for (const path of paths) {
66
+ if (!readsAsProse(path)) {
67
+ return { kind: 'review', test: 'extension', file: path }
68
+ }
69
+ if (underBehaviorPath(path)) {
70
+ return { kind: 'review', test: 'behavior-path', file: path }
71
+ }
72
+ }
73
+
74
+ return { kind: 'skip' }
75
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The surfaces where markdown states what an agent does, and the extensions
3
+ * that read as prose, held as data one command parses.
4
+ *
5
+ * Lifted verbatim in content from the list `claude-autoship/SKILL.md` Step 5
6
+ * carried, which a session was asked to apply by hand. It failed that
7
+ * application three times, so the set moved here and the body now calls a verb
8
+ * that reads it. Being machine-parsed makes it permanently exempt from any
9
+ * later design that folds a standard back into the surface citing it, per the
10
+ * machine-parsed clause in `.claude/ARCHITECTURE.md`.
11
+ */
12
+
13
+ /**
14
+ * Path prefixes under which a markdown change is a behavior change.
15
+ *
16
+ * Most carry two spellings, the one a surface authors at and the one it reaches
17
+ * a session at, so the rule reads the same in this toolkit and in a project
18
+ * that consumed one. `standards/` and `snippets/` carry one apiece: neither
19
+ * installs into a project, reaching a reader by resolution and through the
20
+ * `claude/snippets` symlink respectively, so no `.claude/` spelling exists to
21
+ * name.
22
+ */
23
+ export const BEHAVIOR_PREFIXES = [
24
+ 'claude/skills/',
25
+ '.claude/skills/',
26
+ 'governance/rules/',
27
+ '.claude/rules/',
28
+ 'standards/',
29
+ 'snippets/',
30
+ 'internal/',
31
+ 'tooling/',
32
+ ] as const
33
+
34
+ /**
35
+ * Behavior surfaces named as whole paths rather than as prefixes.
36
+ *
37
+ * A path prefix reaches nothing that sits in no folder, which is what the root
38
+ * instruction file is. Matching is equality rather than a suffix test, so a
39
+ * nested `docs/CLAUDE.md` stays informational the way the body's "at the
40
+ * repository root" qualifier says it should.
41
+ */
42
+ export const BEHAVIOR_FILES = ['CLAUDE.md'] as const
43
+
44
+ /**
45
+ * Extensions a changed file may carry for the branch to read as prose-only.
46
+ *
47
+ * Compared lowercased, so a `README.MD` is not routed to review for its
48
+ * spelling. Everything else is code, a config, or an asset, and one of them
49
+ * sends the whole branch to review.
50
+ */
51
+ export const PROSE_EXTENSIONS = ['.md', '.txt'] as const
package/src/cli.ts CHANGED
@@ -33,6 +33,7 @@ import { register as audits } from '@/commands/audits'
33
33
  import { register as secrets } from '@/commands/secrets'
34
34
  import { register as deps } from '@/commands/deps'
35
35
  import { register as labels } from '@/commands/labels'
36
+ import { register as autoship } from '@/commands/autoship'
36
37
  import { register as census } from '@/commands/census'
37
38
  import { register as targets } from '@/commands/targets'
38
39
  import { register as upgrade } from '@/commands/upgrade'
@@ -80,6 +81,7 @@ function showHelp(): void {
80
81
  `${GREY}│${NC} secrets [cmd] ${GREY}# Read the shipped tree for credential-shaped values (scan)${NC}`,
81
82
  `${GREY}│${NC} deps [cmd] ${GREY}# Read the resolved dependency set for advisories (audit)${NC}`,
82
83
  `${GREY}│${NC} labels [cmd] ${GREY}# Read a changed set against the pull request label map (audit)${NC}`,
84
+ `${GREY}│${NC} autoship [cmd] ${GREY}# Decide whether a changed set needs the review pass (classify)${NC}`,
83
85
  `${GREY}│${NC} census [path] ${GREY}# Report tracked file count, extension breakdown, and line totals${NC}`,
84
86
  `${GREY}│${NC} audits [cmd] ${GREY}# Run every health check as one set (run, list)${NC}`,
85
87
  `${GREY}│${NC} upgrade ${GREY}# Reinstall the CLI globally with the manager that installed it${NC}`,
@@ -181,6 +183,7 @@ worktrees(program)
181
183
  secrets(program)
182
184
  deps(program)
183
185
  labels(program)
186
+ autoship(program)
184
187
  census(program)
185
188
  audits(program)
186
189
  upgrade(program)
@@ -0,0 +1,129 @@
1
+ import type { Command } from 'commander'
2
+ import {
3
+ type ClassifyRefusal,
4
+ type FailedTest,
5
+ classifyChanges,
6
+ } from '@/autoship/classify'
7
+ import { intro, logInfo, logStep, logWarn, outro, plural } from '@/ui'
8
+
9
+ interface ClassifyOptions {
10
+ readonly json?: boolean
11
+ }
12
+
13
+ /** What a reader does about the one way the classification produced no reading. */
14
+ const REFUSALS: Record<ClassifyRefusal, string> = {
15
+ 'no-changes':
16
+ 'No changed files were supplied, so there is no set to classify. Pass the set the ship chain already computed.',
17
+ }
18
+
19
+ /** Why each failed test sends the branch to review, in the reader's terms. */
20
+ const TESTS: Record<FailedTest, string> = {
21
+ extension: 'is not prose, so this branch carries a change review reads',
22
+ 'behavior-path':
23
+ 'sits under a behavior path, so its prose states what an agent does',
24
+ }
25
+
26
+ export function register(program: Command): void {
27
+ const autoship = program
28
+ .command('autoship')
29
+ .description('Answer the decisions the ship chain used to make in prose')
30
+ .helpOption('-h, --help', 'Show this help message')
31
+
32
+ autoship
33
+ .command('classify')
34
+ .description('Decide whether a changed set needs the review pass')
35
+ .argument('[paths...]', 'Changed set to classify, as names')
36
+ .helpOption('-h, --help', 'Show this help message')
37
+ .option('--json', 'Add a machine-readable record on stdout')
38
+ .addHelpText(
39
+ 'after',
40
+ [
41
+ '',
42
+ 'Reads names only and never touches git, so the caller hands over the',
43
+ 'set it already computed rather than paying for a second baseline that',
44
+ 'can disagree with the first.',
45
+ '',
46
+ 'The skip needs both tests to pass. Every name reads as prose, and no',
47
+ 'name sits under a behavior path, which is where markdown states what',
48
+ 'an agent does. One behavior file sends the whole branch to review,',
49
+ 'since documentation shipped beside a behavior change does not cancel',
50
+ 'it.',
51
+ '',
52
+ 'An empty set refuses rather than skipping. Both tests are universally',
53
+ 'quantified, so an empty set satisfies them vacuously, and reading that',
54
+ 'as prose-only would route a branch past review for producing no output.',
55
+ '',
56
+ 'Exit codes:',
57
+ ' 0 prose-only, so the review pass can be skipped',
58
+ ' 1 refused, with the reason on stderr or in the JSON record',
59
+ ' 2 review is needed, with the file and the test it failed named',
60
+ '',
61
+ 'Examples:',
62
+ ' aitk autoship classify docs/index.md README.md',
63
+ ' aitk autoship classify --json .claude/skills/deploy-check/SKILL.md',
64
+ '',
65
+ ].join('\n'),
66
+ )
67
+ .action((paths: string[], opts: ClassifyOptions) => {
68
+ process.exitCode = runClassify(paths, opts)
69
+ })
70
+ }
71
+
72
+ function runClassify(paths: string[], opts: ClassifyOptions): number {
73
+ const emitJson = opts.json ?? false
74
+ const result = classifyChanges(paths)
75
+
76
+ intro('aitk autoship classify')
77
+
78
+ // The frame renders on stderr in both modes and the record goes to stdout
79
+ // alone, so an operator reading the terminal sees the refusal rather than a
80
+ // command that appeared to do nothing.
81
+ if (result.kind === 'refused') {
82
+ logStep('Refused')
83
+ logWarn(REFUSALS[result.reason])
84
+ outro()
85
+
86
+ if (emitJson) {
87
+ process.stdout.write(
88
+ `${JSON.stringify({
89
+ decision: 'refused',
90
+ reason: result.reason,
91
+ message: REFUSALS[result.reason],
92
+ })}\n`,
93
+ )
94
+ }
95
+ return 1
96
+ }
97
+
98
+ logStep('Scope')
99
+ logInfo(`${plural(paths.length, 'path')} supplied by the caller`)
100
+
101
+ if (result.kind === 'skip') {
102
+ logStep('Prose only')
103
+ logInfo('every path reads as prose and none states agent behavior')
104
+ outro()
105
+
106
+ if (emitJson) {
107
+ process.stdout.write(
108
+ `${JSON.stringify({ decision: 'skip', changed: paths })}\n`,
109
+ )
110
+ }
111
+ return 0
112
+ }
113
+
114
+ logStep('Review')
115
+ logWarn(`${result.file} ${TESTS[result.test]}.`)
116
+ outro()
117
+
118
+ if (emitJson) {
119
+ process.stdout.write(
120
+ `${JSON.stringify({
121
+ decision: 'review',
122
+ test: result.test,
123
+ file: result.file,
124
+ changed: paths,
125
+ })}\n`,
126
+ )
127
+ }
128
+ return 2
129
+ }
@@ -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 (const line of text.split('\n')) {
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 || !line.trimStart().startsWith('|')) continue
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: waitingAt >= 0 ? (cells[waitingAt] ?? '') : undefined,
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 { rows, groups } = readBoard(await readFile(ordering, 'utf8'))
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