@erclx/aitk 0.104.0 → 0.105.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/claude/skills/claude-docs/SKILL.md +4 -0
- package/claude/skills/claude-groundwork/SKILL.md +3 -2
- package/claude/skills/claude-worktree/SKILL.md +6 -1
- package/docs/agents/commands.md +2 -1
- package/docs/agents/index.md +1 -0
- package/docs/agents/tasks.md +28 -1
- package/docs/agents/test-order.md +56 -0
- package/docs/ai-workflow.md +4 -2
- package/governance/rules/core/070-planning.md +1 -0
- package/package.json +1 -1
- package/src/commands/gov.ts +135 -0
- package/src/commands/tasks.ts +108 -1
- package/src/gov/test-order.ts +383 -0
- package/src/tasks/archive.ts +158 -9
- package/src/tasks/trunk.ts +89 -0
- package/src/tasks/validate.ts +100 -29
- package/standards/groundwork.md +4 -0
- package/standards/tasks.md +15 -3
- package/tooling/astro/manifest.toml +5 -5
- package/tooling/vite-react/manifest.toml +4 -4
- package/tooling/web/configs/scripts/worktree-port.sh +44 -6
- package/tooling/web/manifest.toml +3 -3
- package/tooling/web/reference.md +4 -1
|
@@ -227,6 +227,10 @@ A board carrying one task written `../plans/x.md` and another written `.claude/p
|
|
|
227
227
|
|
|
228
228
|
Exclude the closing task explicitly. It sits on the board and cites the plan itself, so a scan that counts it never reaches zero and no plan is ever archived.
|
|
229
229
|
|
|
230
|
+
`aitk tasks plan-citations <stem> --json` answers this same question, and the archive gate already reads it. This body states the rule anyway rather than calling the verb, because a plugin skill reaches a target the moment it merges while the CLI reaches one only when a release publishes, so a target whose installed `aitk` predates the verb gets no record back and routes on nothing. Measured against the `claude:docs` `board-sweep` arm, where calling the verb archived neither plan and created no `.claude/plans-archive/`.
|
|
231
|
+
|
|
232
|
+
Nothing in the exit code reports that. Branch on the record's `ok` and `reason` fields and never on the exit, which is the rule every task verb already carries: an operator's shell profile may wrap `aitk` in a function that runs the binary and then another command, taking its status from the second, and one measured here masks every non-zero exit rather than only an absent verb. The binary itself exits 1 for an unknown subcommand and 1 for an ordinary refusal alike. Switching this body to the verb needs a release that carries it and a read of the record rather than the exit, which together retire the duplication.
|
|
233
|
+
|
|
230
234
|
A plan can serve more than one task, and archiving on the first task to close strands every other task's pointer at a path that has moved. `.claude/plans/` is gitignored, so that retarget would be the only record and there is nothing to recover it from.
|
|
231
235
|
|
|
232
236
|
- Target resolves inside `.claude/plans/`, the file exists, and no other task file cites it: create `.claude/plans-archive/`, move the file there under its original name, overwriting any file already sitting at that name. Then rewrite the task file's `Plan:` line to the archive path, so a completed task still leads to the reasoning behind it.
|
|
@@ -21,7 +21,8 @@ Read `.claude/standards/groundwork.md` from the project root before writing any
|
|
|
21
21
|
|
|
22
22
|
- Write only inside `.claude/groundwork/<slug>/`. A feature plan, source changes, a standard, a rule, and a reference doc all live outside that folder, so this one rule forbids every one of them.
|
|
23
23
|
- One exception, at close only: write one task file recording what the track concluded.
|
|
24
|
-
- A second exception, for
|
|
24
|
+
- A second exception, for what a spike reads: write an input under `.claude/.tmp/groundwork-fixtures/<slug>/`. Keep it out of `.claude/groundwork/` so mode detection never matches a fixture as a track. A fixture a headless run is pointed at goes outside the repository instead, per the rule in `## Running a spike`.
|
|
25
|
+
- What a spike produces stays inside the track rather than joining the two exceptions above: write evidence a spike file cites under `.claude/groundwork/<slug>/evidence/`, which the first rule already permits. Mode detection matches entries at the top level of the tracks directory, so the sibling the fixtures rule guards against cannot be a folder nested inside a track, and the scratch path holds only what can be deleted without loss, which a recording a finding rests on is not.
|
|
25
26
|
- Reading is not restricted. External research is in scope, so read documentation, comparable projects, and papers whenever a live question needs them.
|
|
26
27
|
- Every claim about a source outside the project carries a link to it. A source found and not read is listed as a lead and is never cited.
|
|
27
28
|
- Treat the folder as gitignored and unbacked. It dies with the machine, so `07-next-session.md` repeats what it needs instead of pointing at its siblings.
|
|
@@ -36,7 +37,7 @@ A track may run an experiment to settle an open question without stopping to ask
|
|
|
36
37
|
|
|
37
38
|
A fixture a headless run is pointed at goes outside the repository, under `mktemp -d`, and never under `.claude/.tmp/`. A session started anywhere beneath the project root loads that project's `CLAUDE.md`, `.claude/rules/`, and `.claude/standards/` through the ancestor chain, so an in-repo fixture measures the repository instead of the arm. Record the fixture as contaminated and re-run it outside if this is discovered after the fact, because the result reads as evidence either way.
|
|
38
39
|
|
|
39
|
-
Record method, result, measured cost, and caveats in `08-spikes.md`. Reach for a test harness the project already carries before building one, and copy how it isolates its fixture rather than only what it asserts.
|
|
40
|
+
Record method, result, measured cost, and caveats in `08-spikes.md`. Put whatever the run produced that the record cites in `evidence/` beside it, so a reader checking a claim opens the artifact rather than taking the prose for it. Reach for a test harness the project already carries before building one, and copy how it isolates its fixture rather than only what it asserts.
|
|
40
41
|
|
|
41
42
|
## Step 1: detect the mode
|
|
42
43
|
|
|
@@ -113,9 +113,14 @@ The last line is what keeps the step honest on a stack this skill cannot read. E
|
|
|
113
113
|
|
|
114
114
|
Then report the port this worktree derives, on a second line:
|
|
115
115
|
|
|
116
|
-
- `scripts/worktree-port.sh` present: run `bash scripts/worktree-port.sh` and emit `Port offset <n>. Every served port adds it to the stack default.`
|
|
116
|
+
- `scripts/worktree-port.sh` present and exiting zero: run `bash scripts/worktree-port.sh` and emit `Port offset <n>. Every served port adds it to the stack default.`
|
|
117
|
+
- Present and exiting non-zero: emit `The port helper refuses this directory, so no server here has a port. <its stderr>`
|
|
117
118
|
- Absent: `No port derivation installed, so every served port is the stack default.`
|
|
118
119
|
|
|
120
|
+
Branch on the exit rather than on the output, since the helper prints nothing to stdout when it refuses and reading that as a number reports an offset of zero, which is the main checkout's.
|
|
121
|
+
|
|
122
|
+
The helper refuses a folder left behind after its worktree was removed, which Step 4 cannot land on, since it registers whatever it creates. The branch is here so a refusal is never read back as an offset of zero, which is the main checkout's port and the collision the helper exists to prevent.
|
|
123
|
+
|
|
119
124
|
The offset is what `claude-orchestrate` sends a reader here to read rather than assign, and what an operator overrides through `WORKTREE_PORT_OFFSET` when two worktrees derive the same value. Deriving it correctly and printing it nowhere leaves both instructions naming a number no surface emits.
|
|
120
125
|
|
|
121
126
|
Do not invoke `ExitWorktree` from this skill. Exit is the user's call.
|
package/docs/agents/commands.md
CHANGED
|
@@ -45,6 +45,7 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
|
|
|
45
45
|
| `aitk markdown audit` | Fail any markdown path on a banned character, word, or spelling, and report the structural checkpoints |
|
|
46
46
|
| `aitk claude skills audit` | Report both skill corpora against the mechanical rules in `standards/skill.md` |
|
|
47
47
|
| `aitk claude skills drift` | Name the shipped skill bodies rewritten between a given ref and `HEAD` (`--json`) |
|
|
48
|
+
| `aitk gov test-order` | Report where an implementation reached history ahead of the test covering it (`--json`) |
|
|
48
49
|
| `aitk capture [source]` | Render HTML capture sources to PNG, toolkit-only and absent from an installed package |
|
|
49
50
|
|
|
50
51
|
## Domain commands
|
|
@@ -56,7 +57,7 @@ Each domain exposes a consistent shape where applicable: `list`, `install`, `syn
|
|
|
56
57
|
| `tooling` | `list`, `sync`, `ref`, `create`, `verify`, `inject`, `prune-gitignore` |
|
|
57
58
|
| `snippets` | `list`, `install`, `sync`, `create` |
|
|
58
59
|
| `standards` | `list`, `install`, `sync`, `<name>` |
|
|
59
|
-
| `gov` | `list`, `install`, `sync`, `build`
|
|
60
|
+
| `gov` | `list`, `install`, `sync`, `build`, `regen`, `test-order` |
|
|
60
61
|
| `claude` | `init`, `sync`, `seeds list`, `skills list`, `skills audit`, `skills drift`, `setup [dest]` |
|
|
61
62
|
| `wiki` | `init` |
|
|
62
63
|
| `design` | `render` |
|
package/docs/agents/index.md
CHANGED
|
@@ -27,3 +27,4 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
|
|
|
27
27
|
- [Skill audit](skills-audit.md): Measuring both skill corpora against standards/skill.md, the checks it reads, the requirement gate that is the only failing one, and the drift verb that names bodies rewritten since a ref
|
|
28
28
|
- [Tasks](tasks.md): Selecting a shipped task by stem or pull request, recording a number and closing an outcome, the refusal reasons, the board and backlog checks validate runs, and why the board root defaults to the main worktree
|
|
29
29
|
- [Teach](teach.md): Listing learning workspaces and the ordinal a new one takes, opening one with its required files, recording sources and glossary terms, resolving what the next lesson needs before it is written, the refusal reasons, and why every write here runs through a verb
|
|
30
|
+
- [Test order](test-order.md): Reading where an implementation reached history ahead of its test, how a pair is decided, the three verdicts, the coverage the pairing cannot reach, and why the check reports rather than gates
|
package/docs/agents/tasks.md
CHANGED
|
@@ -24,6 +24,8 @@ aitk tasks archive --pull-request 673 --json
|
|
|
24
24
|
|
|
25
25
|
Exit codes: `0` archived, `1` refused. Every gate is a refusal rather than a warning, because `.husky/post-merge` calls this with nobody watching. The `reason` field carries which gate fired: `no-board`, `no-match`, `ambiguous`, `no-outcomes`, `open-outcomes`, `plan-unswept`, or `bad-input`.
|
|
26
26
|
|
|
27
|
+
`plan-unswept` fires on the last task pointing at a live plan, never on every task pointing at one. The gate counts the other live tasks whose `Plan:` line resolves onto the same file, so a plan several tasks share archives its tasks freely and only the final one is held until `claude-docs` sweeps the plan. Reading the folder alone refused all of them, which deadlocked the board against a sweep correctly declining to move a plan another live task cites.
|
|
28
|
+
|
|
27
29
|
`bad-input` covers a malformed command line, which all three task verbs answer the same way. It is separate from `ambiguous` and `no-match` because those describe the board, and a caller that passed two selectors would otherwise be sent to repair a task citation that is fine.
|
|
28
30
|
|
|
29
31
|
The board is shared scratch at the main worktree root, so `--root` defaults to the first entry of `git worktree list` rather than the working directory. A linked worktree archives against the same board every other session reads.
|
|
@@ -34,6 +36,27 @@ Skills branch on the reason rather than on the exit code:
|
|
|
34
36
|
aitk tasks archive --pull-request 673 --json | jq -r 'if .ok then .task else .reason end'
|
|
35
37
|
```
|
|
36
38
|
|
|
39
|
+
## Plan citations
|
|
40
|
+
|
|
41
|
+
`aitk tasks plan-citations <stem>` answers where a task's plan sits and which other live tasks hold it. It reports and never writes.
|
|
42
|
+
|
|
43
|
+
| Option | Effect |
|
|
44
|
+
| --------------- | ------------------------------------------- |
|
|
45
|
+
| `--json` | Emit a machine-readable record on stdout |
|
|
46
|
+
| `--root <path>` | Board root, defaulting to the main worktree |
|
|
47
|
+
|
|
48
|
+
The record carries `location`, one of `unstated`, `live`, `archived`, or `outside`, and `citedBy`, the other live tasks whose `Plan:` line lands on the same file. Exit codes: `0` read, `1` refused with `no-board` or `no-match`.
|
|
49
|
+
|
|
50
|
+
`aitk tasks archive` gates on this same answer, so a caller wanting the count reads it here rather than scanning the board. The `claude-docs` plans sweep is the exception and still states the rule in its own body, because a plugin skill reaches a target on merge while the CLI reaches one on release, so a sweep calling a verb the installed `aitk` predates gets no record back and archives nothing.
|
|
51
|
+
|
|
52
|
+
Branch on `reason` rather than on the exit code, which is the rule the archive section above already states and which this verb needs for a second reason. An operator's shell profile may wrap `aitk` in a function that runs the binary and then another command and takes the second status, which masks every non-zero exit rather than only an absent verb. The binary exits 1 for an unknown subcommand and 1 for an ordinary refusal alike, so the record is the only signal that survives the wrapper.
|
|
53
|
+
|
|
54
|
+
A `live` location with an empty `citedBy` is the sweep to run. One whose `citedBy` names a sibling is a plan several tasks share, which the sweep leaves alone and the archive gate lets through.
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
aitk tasks plan-citations v28.1-trigger-escalation --json | jq -r '.location'
|
|
58
|
+
```
|
|
59
|
+
|
|
37
60
|
## Pull request
|
|
38
61
|
|
|
39
62
|
`aitk tasks pull-request` records the number a branch's pull request carries onto the task that branch closes. It adds `Pull request: #NNN` under the `Plan:`, `Groundwork:`, `Intake:`, or `Issue:` lines the task already holds, and corrects the number in place when the line exists.
|
|
@@ -106,7 +129,11 @@ A backlog line is a bullet carrying a link to a sibling task, since the backlog
|
|
|
106
129
|
|
|
107
130
|
The collision check is the one a person cannot run by eye. Paths come from the backticked spans in the Touches column, a span naming no file is dropped, and a directory collides with any file beneath it. A `## Run now` row whose column parses to nothing is reported rather than skipped, since a row stating no file set makes a claim nothing can check.
|
|
108
131
|
|
|
109
|
-
The blocker check re-takes a measurement the board records once and never repeats. Two of the five blocker kinds put a fact on disk: a dependency is settled by the cited task being archived or
|
|
132
|
+
The blocker check re-takes a measurement the board records once and never repeats. Two of the five blocker kinds put a fact on disk: a dependency is settled by the cited task being archived or by its work reaching the trunk, and a collision is settled by nothing under `## Run now` still holding the file the cell cites.
|
|
133
|
+
|
|
134
|
+
A closed outcome is not the fact the dependency half needs. The ship chain marks outcomes as its first step and opens the pull request several steps later, so a check reading the checkbox reports the row settled while the branch is still in review. A live task therefore settles the row only once it closed every outcome and carries a `Pull request:` line the trunk holds. One that names no pull request, and one whose number no trunk ref could answer for, land in the untested array below rather than being settled or left silent.
|
|
135
|
+
|
|
136
|
+
The trunk is read as the clone already holds it, `origin/main` first and local `main` behind it, and no run fetches. A validate runs several times a sweep and a fetch per run is a cost this command does not carry, so a clone behind its remote under-reports rather than claiming work landed.
|
|
110
137
|
|
|
111
138
|
Both halves gate on a citation inside the `Waiting on` cell, never on the columns beside it. The board format gives a collision cell the file held by the running task, so a row whose cell names no file was parked by something else, and testing its Touches column instead reports a cleared collision on a row no collision ever parked while counting that row as re-tested. A cited task is a bare sibling link, the way the Task column spells one, so a pointer into another folder names a plan rather than a task and settles nothing. A cited task carrying no outcome box settles nothing either, since a file the check could not parse is not evidence of a finished one.
|
|
112
139
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Test order
|
|
3
|
+
description: Reading where an implementation reached history ahead of its test, how a pair is decided, the three verdicts, the coverage the pairing cannot reach, and why the check reports rather than gates
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Test order
|
|
7
|
+
|
|
8
|
+
`aitk gov test-order` reports where an implementation reached a commit ahead of the test covering the same behavior. It answers the rule in `.claude/rules/core/070-planning.md` that asks for the test first, which until this verb existed was prose loaded on a glob match with nothing measuring it.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
aitk gov test-order
|
|
12
|
+
aitk gov test-order --base origin/main --json
|
|
13
|
+
aitk gov test-order --root ../my-app
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
| Option | Behavior |
|
|
17
|
+
| --------------- | ----------------------------------------------------------------- |
|
|
18
|
+
| `--base <ref>` | Far side of the range, defaulting to the merge base against trunk |
|
|
19
|
+
| `--root <path>` | Repository to read, defaulting to the current directory |
|
|
20
|
+
| `--json` | Add a machine-readable record on stdout, keeping the frame |
|
|
21
|
+
|
|
22
|
+
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. A consumer reading stdout sees pure data, and an operator reading the terminal sees why a run refused rather than a command that appeared to do nothing.
|
|
23
|
+
|
|
24
|
+
## The range it reads
|
|
25
|
+
|
|
26
|
+
The default range is the branch against the trunk, resolved as the merge base against `origin/main` and then local `main`. A repository carrying neither falls back to the root commit, so a fresh checkout still answers rather than refusing.
|
|
27
|
+
|
|
28
|
+
Reading all history on every run measures work nobody is reviewing and buries the finding that matters under the ones already merged. `--base` widens or narrows it when a reader wants a different window.
|
|
29
|
+
|
|
30
|
+
Only history is read. A file sitting in the working tree and in no commit is invisible here, which is the point: the verification run sees a tree at one moment, and the ordering exists nowhere but history.
|
|
31
|
+
|
|
32
|
+
## How a pair is decided
|
|
33
|
+
|
|
34
|
+
A test is paired to an implementation by the module path with the test suffix removed, so `src/parser.test.ts` covers `src/parser.ts`. Every record is keyed on the implementation path, which is what a reader goes looking for.
|
|
35
|
+
|
|
36
|
+
Pairing is the whole difficulty. A test and its implementation are related by behavior rather than by filename, so a behavior split across two modules pairs wrongly or not at all. That is what the unclassified verdict exists to catch rather than hide.
|
|
37
|
+
|
|
38
|
+
## The three verdicts
|
|
39
|
+
|
|
40
|
+
- **Satisfied.** The implementation was added in the range and its test reached history no later. A test that predates the range counts, and so does one commit carrying both sides, since the rule asks that the test not come after and a single commit is the shape a small change takes.
|
|
41
|
+
- **Implementation-first.** The implementation was added in the range and its test was added in a later commit of the same range. This is the only verdict that moves the exit code.
|
|
42
|
+
- **Unclassified.** Everything the pairing cannot read, each carrying its own reason. A module the range modified rather than added lands here, because a refactor and a new behavior cannot be told apart from history. So does an implementation no test names, and a test whose implementation the range never introduced.
|
|
43
|
+
|
|
44
|
+
## What it reads past
|
|
45
|
+
|
|
46
|
+
Only `.ts` and `.tsx` are paired, since a test sits beside its subject under one name across that corpus and a bash script has no such partner. Declaration files carry no behavior to test. Every other changed path is counted under `Read past` rather than dropped silently.
|
|
47
|
+
|
|
48
|
+
Coverage is narrower than the rule the check answers to. The rule speaks to every behavior, and the verb speaks to the behaviors it can identify, so the report states its own scope on every run rather than implying it looked at everything.
|
|
49
|
+
|
|
50
|
+
## Exit codes
|
|
51
|
+
|
|
52
|
+
Exit codes are `0` when nothing reached history ahead of its test, `1` for a refusal, and `2` for at least one implementation-first finding. Unclassified rows move no exit code.
|
|
53
|
+
|
|
54
|
+
Nothing wires this into `bun run check` or into a hook. The unclassified class is large and known, and gating a measure carrying a known false-positive class is what teaches contributors to route around the stage. `aitk tasks validate` is the precedent: it exits `2` on findings and is called where the claim is made rather than on every push.
|
|
55
|
+
|
|
56
|
+
An exit code says nothing about a call made from a session, since a shell profile may wrap the binary in a function taking its status from a later command. Read the JSON record's `findings` array rather than the exit when a skill consumes this.
|
package/docs/ai-workflow.md
CHANGED
|
@@ -55,6 +55,8 @@ The routing test is whether the repository can answer an item today. A session g
|
|
|
55
55
|
|
|
56
56
|
A groundwork track may run experiments to settle a question, writing a fixture it reads itself under `.claude/.tmp/groundwork-fixtures/<slug>/` and spawning up to three billed headless runs before it asks. A fixture a headless run is pointed at sits outside the repository, since a session started under the project root inherits that project's `CLAUDE.md` and rules and would measure them instead of the arm.
|
|
57
57
|
|
|
58
|
+
What a spike produces goes somewhere else again. An input the run reads is re-runnable and cited by nothing, so the scratch path above is the right lifetime for it, while a recording or a render the track cites as evidence for a finding is what a later reader opens to check the claim. Evidence therefore lives in `evidence/` inside the track beside the file citing it, since the scratch tree holds only what can be deleted without loss.
|
|
59
|
+
|
|
58
60
|
#### Session 2
|
|
59
61
|
|
|
60
62
|
Start a fresh Claude Code session. The diff is sufficient context for both review and ship.
|
|
@@ -105,11 +107,11 @@ The sweep reads the whole board rather than the tasks the session touched. It is
|
|
|
105
107
|
|
|
106
108
|
`.claude/memory/` carries the same arrangement, its own hook regenerating `index.md` from each entry's `title`, `description`, and `category`. A hand-maintained `priority.md` sits beside it carrying execution order and what each task is waiting on, which the alphabetical index cannot express.
|
|
107
109
|
|
|
108
|
-
`aitk tasks validate` checks what those rows claim against what the tree holds: every plan pointer resolves, every row
|
|
110
|
+
`aitk tasks validate` checks what those rows claim against what the tree holds: every plan pointer resolves, every task file is named by a board row or a backlog line and never by both, no task sits in two groups, and no two rows marked ready touch the same file. One check across both surfaces is what lets a task move between the board and the backlog without the move reading as a dropped file. That fourth check is the half a reader cannot run by eye, and it is what keeps two workers from being handed colliding work. A fifth re-takes what a parked row waits on, reporting one whose cited task reached the trunk and one whose cited file nothing running still holds. A cited task settles the row by being archived, or by closing every outcome and naming a pull request the trunk carries, since the checkbox alone is marked while the branch is still in review. Both halves read a citation out of the blocker cell, so a row citing neither is reported as untested rather than counted clean, and so is a cited task the trunk could not answer for. It reports and never writes, because a row is the orchestrator's claim and a validator repairing one would assert the claim it exists to test. Nothing fires it automatically, since the board is gitignored per-machine scratch with no shared moment to hang a hook on, so the orchestrator's sweep calls it at the point the readiness claim is made and follows it with the parked re-test.
|
|
109
111
|
|
|
110
112
|
`aitk:claude-tasks` owns the two operations that bracket a task's life. It creates the file, holding the filename convention and the frontmatter contract so a malformed write cannot break the index for every sibling, and it moves a shipped task to `.claude/task-archive/`. Creation is where the origin invariant is enforced: every task names a plan, a groundwork folder, an intake folder, or an issue, since a task with no origin is either lost context or work nobody decided to do.
|
|
111
113
|
|
|
112
|
-
Archiving a task leaves its plan alone, because `aitk:claude-docs` owns the plans sweep and already holds the last-live-citation rule. That makes the order load-bearing, so the archive verb refuses
|
|
114
|
+
Archiving a task leaves its plan alone, because `aitk:claude-docs` owns the plans sweep and already holds the last-live-citation rule. That makes the order load-bearing, so the archive verb refuses the last task pointing at a live plan. The sweep only reaches tasks still in the live folder, and archiving that task first would strand the plan there with nothing citing it. A task whose plan a sibling still cites archives freely, because the sweep is correct to leave that plan where it is and a gate reading the folder instead would park every task sharing one plan behind a sweep that will never move it.
|
|
113
115
|
|
|
114
116
|
Nothing chained that archive until the `post-merge` git hook landed. Every earlier step fires from `aitk:claude-autoship` or `aitk:git-ship`, both of which finish while the pull request is still open, so a task archived there would close for work that may be abandoned. The board is gitignored, which rules out reading it from anywhere but the machine that pulled. The hook names the board's archive candidates and stays silent otherwise, including on a project with no board.
|
|
115
117
|
|
|
@@ -13,4 +13,5 @@ description: Enforce planning standards before implementation
|
|
|
13
13
|
- Propose the simplest solution that satisfies the requirement before implementing complex patterns.
|
|
14
14
|
- Write or update tests as part of every implementation plan.
|
|
15
15
|
- Write the test for a behavior before the code that implements it. Confirm visual output after implementing it, not before.
|
|
16
|
+
- Run `aitk gov test-order` before shipping a branch. Fix what it names as reaching history ahead of its test.
|
|
16
17
|
- Do not modify code without a confirmed plan.
|
package/package.json
CHANGED
package/src/commands/gov.ts
CHANGED
|
@@ -14,6 +14,11 @@ import {
|
|
|
14
14
|
mergeExtraRules,
|
|
15
15
|
resolveRules,
|
|
16
16
|
} from '@/gov/stacks'
|
|
17
|
+
import {
|
|
18
|
+
type PairRecord,
|
|
19
|
+
readTestOrder,
|
|
20
|
+
type TestOrderReport,
|
|
21
|
+
} from '@/gov/test-order'
|
|
17
22
|
import { recordStamp, runDomainSync } from '@/sync/engine'
|
|
18
23
|
import { resolveTarget } from '@/target'
|
|
19
24
|
import {
|
|
@@ -48,6 +53,12 @@ interface ListOptions {
|
|
|
48
53
|
readonly json?: boolean
|
|
49
54
|
}
|
|
50
55
|
|
|
56
|
+
interface TestOrderOptions {
|
|
57
|
+
readonly base?: string
|
|
58
|
+
readonly root?: string
|
|
59
|
+
readonly json?: boolean
|
|
60
|
+
}
|
|
61
|
+
|
|
51
62
|
export function register(program: Command): void {
|
|
52
63
|
const gov = program
|
|
53
64
|
.command('gov')
|
|
@@ -133,6 +144,130 @@ export function register(program: Command): void {
|
|
|
133
144
|
.action((opts: ListOptions) => {
|
|
134
145
|
process.exitCode = runList(opts)
|
|
135
146
|
})
|
|
147
|
+
|
|
148
|
+
gov
|
|
149
|
+
.command('test-order')
|
|
150
|
+
.description(
|
|
151
|
+
'Report where an implementation reached history before its test',
|
|
152
|
+
)
|
|
153
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
154
|
+
.option('--base <ref>', 'Far side of the range, defaulting to the trunk')
|
|
155
|
+
.option('--root <path>', 'Repository to read, defaulting to the cwd')
|
|
156
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
157
|
+
.addHelpText(
|
|
158
|
+
'after',
|
|
159
|
+
[
|
|
160
|
+
'',
|
|
161
|
+
'Measures the rule in .claude/rules/core/070-planning.md that asks for',
|
|
162
|
+
'the test before the code. It reports and never gates, because pairing',
|
|
163
|
+
'a test to an implementation is a judgment.',
|
|
164
|
+
'',
|
|
165
|
+
'Coverage:',
|
|
166
|
+
' a test sits beside its subject under the same name, minus .test',
|
|
167
|
+
' only .ts and .tsx are paired, and every other path is named as read past',
|
|
168
|
+
' a module the range modified rather than added is unclassified, since',
|
|
169
|
+
' a refactor and a new behavior cannot be told apart from history',
|
|
170
|
+
'',
|
|
171
|
+
'Exit codes:',
|
|
172
|
+
' 0 no implementation reached history ahead of its test',
|
|
173
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
174
|
+
' 2 at least one implementation reached history ahead of its test',
|
|
175
|
+
'',
|
|
176
|
+
'Examples:',
|
|
177
|
+
' aitk gov test-order',
|
|
178
|
+
' aitk gov test-order --base origin/main --json',
|
|
179
|
+
'',
|
|
180
|
+
].join('\n'),
|
|
181
|
+
)
|
|
182
|
+
.action((opts: TestOrderOptions) => {
|
|
183
|
+
process.exitCode = runTestOrder(opts)
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Reports and never gates, so the finding count moves the exit code without
|
|
189
|
+
* anything wiring it into a push. `aitk tasks validate` set that shape: a
|
|
190
|
+
* measure carrying a known false-positive class is what forces contributors to
|
|
191
|
+
* route around a stage, and the unclassified bucket here is that class.
|
|
192
|
+
*/
|
|
193
|
+
function runTestOrder(opts: TestOrderOptions): number {
|
|
194
|
+
const root = resolve(opts.root ?? process.cwd())
|
|
195
|
+
const report = readTestOrder(root, { base: opts.base })
|
|
196
|
+
const emitJson = opts.json ?? false
|
|
197
|
+
|
|
198
|
+
// The frame renders on stderr in both modes and the record goes to stdout
|
|
199
|
+
// alone, which is the split `docs/agents/output-shape.md` fixes. A consumer
|
|
200
|
+
// reading stdout sees pure data either way, and an operator reading the
|
|
201
|
+
// terminal sees the refusal rather than a command that appeared to do nothing.
|
|
202
|
+
if (report.kind === 'unreadable') {
|
|
203
|
+
intro('aitk gov test-order')
|
|
204
|
+
logStep('Refused')
|
|
205
|
+
logError(report.reason)
|
|
206
|
+
outro()
|
|
207
|
+
|
|
208
|
+
if (emitJson) {
|
|
209
|
+
process.stdout.write(
|
|
210
|
+
`${JSON.stringify({ ok: false, reason: report.reason })}\n`,
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return 1
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
reportTestOrder(report, root)
|
|
218
|
+
|
|
219
|
+
if (emitJson) {
|
|
220
|
+
process.stdout.write(`${JSON.stringify({ ok: true, root, ...report })}\n`)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return report.findings.length > 0 ? 2 : 0
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function describePair(record: PairRecord): string {
|
|
227
|
+
const test = record.test === null ? 'no test' : record.test
|
|
228
|
+
return `${record.subject} → ${test}: ${record.reason}`
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function reportTestOrder(
|
|
232
|
+
report: Extract<TestOrderReport, { kind: 'measured' }>,
|
|
233
|
+
root: string,
|
|
234
|
+
): void {
|
|
235
|
+
intro('aitk gov test-order')
|
|
236
|
+
|
|
237
|
+
logStep('Range')
|
|
238
|
+
logInfo(`${report.base.slice(0, 8)}..${report.head.slice(0, 8)} in ${root}`)
|
|
239
|
+
|
|
240
|
+
logStep(report.findings.length === 0 ? 'Clean' : 'Findings')
|
|
241
|
+
if (report.findings.length === 0) {
|
|
242
|
+
logInfo('no implementation reached history ahead of the test covering it')
|
|
243
|
+
} else {
|
|
244
|
+
for (const finding of report.findings) logWarn(describePair(finding))
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
logStep('Satisfied')
|
|
248
|
+
logInfo(`${report.satisfied.length} pair(s) whose test came first`)
|
|
249
|
+
|
|
250
|
+
// The unclassified rows carry the warn glyph and move no exit code. A pass
|
|
251
|
+
// over a change the pairing could not read is the claim this check exists to
|
|
252
|
+
// avoid making, so the rows are named rather than counted into the clean line.
|
|
253
|
+
logStep('Unclassified')
|
|
254
|
+
if (report.unclassified.length === 0) {
|
|
255
|
+
logInfo('every changed module paired')
|
|
256
|
+
} else {
|
|
257
|
+
logWarn(
|
|
258
|
+
`${report.unclassified.length} change(s) the pairing could not read`,
|
|
259
|
+
)
|
|
260
|
+
for (const record of report.unclassified) logWarn(describePair(record))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Coverage is narrower than the rule, and a report that did not say so would
|
|
264
|
+
// read as a verdict over every behavior in the range.
|
|
265
|
+
logStep('Read past')
|
|
266
|
+
logInfo(
|
|
267
|
+
`${report.ignored.length} path(s) outside ${report.scope.extensions.join(', ')}`,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
outro()
|
|
136
271
|
}
|
|
137
272
|
|
|
138
273
|
/**
|
package/src/commands/tasks.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { relative } from 'node:path'
|
|
2
2
|
import type { Command } from 'commander'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type ArchiveOutcome,
|
|
5
|
+
archiveTask,
|
|
6
|
+
type CitationOutcome,
|
|
7
|
+
type PlanCitations,
|
|
8
|
+
planCitations,
|
|
9
|
+
} from '@/tasks/archive'
|
|
4
10
|
import {
|
|
5
11
|
type CloseOutcome,
|
|
6
12
|
closeOutcomes,
|
|
@@ -42,6 +48,11 @@ interface ValidateCommandOptions {
|
|
|
42
48
|
readonly root?: string
|
|
43
49
|
}
|
|
44
50
|
|
|
51
|
+
interface CitationsCommandOptions {
|
|
52
|
+
readonly json?: boolean
|
|
53
|
+
readonly root?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
45
56
|
interface PullRequestCommandOptions {
|
|
46
57
|
readonly json?: boolean
|
|
47
58
|
readonly plan?: string
|
|
@@ -126,6 +137,41 @@ export function register(program: Command): void {
|
|
|
126
137
|
process.exitCode = await runValidate(opts)
|
|
127
138
|
})
|
|
128
139
|
|
|
140
|
+
tasks
|
|
141
|
+
.command('plan-citations')
|
|
142
|
+
.description('Report where a task plan sits and which live tasks hold it')
|
|
143
|
+
.argument('<task>', 'Task filename stem, as in v28.1-trigger-escalation')
|
|
144
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
145
|
+
.option('--json', 'Emit a machine-readable record on stdout')
|
|
146
|
+
.option('--root <path>', 'Board root, defaulting to the main worktree')
|
|
147
|
+
.addHelpText(
|
|
148
|
+
'after',
|
|
149
|
+
[
|
|
150
|
+
'',
|
|
151
|
+
'Locations:',
|
|
152
|
+
' unstated the task carries no Plan: line',
|
|
153
|
+
' live the target resolves inside .claude/plans/',
|
|
154
|
+
' archived the target resolves inside .claude/plans-archive/',
|
|
155
|
+
' outside the target resolves somewhere else',
|
|
156
|
+
'',
|
|
157
|
+
'Exit codes:',
|
|
158
|
+
' 0 the citations were read',
|
|
159
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
160
|
+
'',
|
|
161
|
+
'It reports and never writes. A live plan whose citedBy list is empty',
|
|
162
|
+
'is the sweep to run, and one a sibling still holds is left alone.',
|
|
163
|
+
'The archive gate reads the same answer, so neither can drift.',
|
|
164
|
+
'',
|
|
165
|
+
'Examples:',
|
|
166
|
+
' aitk tasks plan-citations v28.1-trigger-escalation',
|
|
167
|
+
' aitk tasks plan-citations v28.1-trigger-escalation --json',
|
|
168
|
+
'',
|
|
169
|
+
].join('\n'),
|
|
170
|
+
)
|
|
171
|
+
.action(async (task: string, opts: CitationsCommandOptions) => {
|
|
172
|
+
process.exitCode = await runCitations(task, opts)
|
|
173
|
+
})
|
|
174
|
+
|
|
129
175
|
tasks
|
|
130
176
|
.command('pull-request')
|
|
131
177
|
.description('Record a pull request number on the task a branch closes')
|
|
@@ -426,6 +472,67 @@ async function runValidate(opts: ValidateCommandOptions): Promise<number> {
|
|
|
426
472
|
return reportValidation(outcome, opts.json ?? false, root)
|
|
427
473
|
}
|
|
428
474
|
|
|
475
|
+
async function runCitations(
|
|
476
|
+
task: string,
|
|
477
|
+
opts: CitationsCommandOptions,
|
|
478
|
+
): Promise<number> {
|
|
479
|
+
const root = opts.root ?? (await mainWorktreeRoot())
|
|
480
|
+
const outcome = await planCitations(root, task)
|
|
481
|
+
|
|
482
|
+
return reportCitations(outcome, opts.json ?? false, root)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function reportCitations(
|
|
486
|
+
outcome: CitationOutcome,
|
|
487
|
+
emitJson: boolean,
|
|
488
|
+
root: string,
|
|
489
|
+
): number {
|
|
490
|
+
if (!outcome.ok) {
|
|
491
|
+
if (emitJson) {
|
|
492
|
+
process.stdout.write(
|
|
493
|
+
`${JSON.stringify({ ok: false, reason: outcome.reason, message: outcome.message })}\n`,
|
|
494
|
+
)
|
|
495
|
+
return 1
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
intro('aitk tasks plan-citations')
|
|
499
|
+
logStep('Refused')
|
|
500
|
+
logError(outcome.message)
|
|
501
|
+
outro()
|
|
502
|
+
return 1
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (emitJson) {
|
|
506
|
+
process.stdout.write(`${JSON.stringify({ ...outcome, root })}\n`)
|
|
507
|
+
return 0
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
intro('aitk tasks plan-citations')
|
|
511
|
+
logStep(outcome.stem)
|
|
512
|
+
logInfo(describeCitations(outcome))
|
|
513
|
+
outro()
|
|
514
|
+
|
|
515
|
+
return 0
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function describeCitations(outcome: PlanCitations): string {
|
|
519
|
+
if (outcome.location === 'unstated') return 'carries no Plan: line.'
|
|
520
|
+
|
|
521
|
+
if (outcome.location === 'archived') {
|
|
522
|
+
return `points at ${outcome.target}, which an earlier sweep already archived.`
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (outcome.location === 'outside') {
|
|
526
|
+
return `points at ${outcome.target}, which resolves outside both plans folders.`
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (outcome.citedBy.length === 0) {
|
|
530
|
+
return `is the last live task citing ${outcome.target}, so the sweep may archive it.`
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return `shares ${outcome.target} with ${outcome.citedBy.join(', ')}, so the sweep leaves it.`
|
|
534
|
+
}
|
|
535
|
+
|
|
429
536
|
function reportValidation(
|
|
430
537
|
outcome: ValidateOutcome,
|
|
431
538
|
emitJson: boolean,
|