@erclx/aitk 0.68.0 → 0.70.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": "0.68.0",
4
+ "version": "0.70.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -11,6 +11,10 @@ Without this skill, the session holding the cross-feature picture starts buildin
11
11
 
12
12
  The queue fails in both directions. It empties and a free worker waits, or it fills with whatever is oldest rather than with what establishes a mechanism. A plan handed over unverified is the quiet one, since it goes stale from whatever merged after it was written, and a worker that trusts its account of the tree builds against a shape that no longer exists. A second orchestrator makes all of this unrecoverable, because the board is gitignored and neither session can read the other's writes.
13
13
 
14
+ Refilling on a merge and on a thinning ready list leaves the window between them unwatched. A wave mid-build has merged nothing and has moved no rows, so both conditions stay silent across the whole build and the wave finishes into an empty queue, which lands planning on the critical path directly after the stretch where it would have cost nothing.
15
+
16
+ Writing those plans against the tree alone is the second half, since several branches are already changing the shape a plan describes. A plan carrying a bare path list rather than a constraint per in-flight track leaves the worker guessing which act each path forbids, which is the dangling citation the rule against bare paths already exists to prevent.
17
+
14
18
  The session also writes surfaces it is told only how to read. Refilling the queue promotes and demotes rows in the priority file with no stated method, so a session invents one, and an inline rewrite exits clean when it matches nothing and leaves the board wrong with nothing reporting it. A row carrying prose where a plan pointer belongs costs a worker dispatch, because the ship chain refuses at its guard after the worktree is already open. A plan archived from a worker's own branch strands the pointer the board still carries, and the row reads as correct until someone follows it.
15
19
 
16
20
  The ban on writing at all fails on a different axis, which is that nothing enforces it and a session weighs proportionality against it. A one-line prose correction found while orchestrating satisfies the root instruction to handle a small edit immediately and violates this rule, and a session given no statement of which one wins takes the smaller apparent cost and authors the change. That removes the independent pass the repository built to catch what a self-review misses, and the vantage does not come back, since no later session can review the change without re-deriving the context that produced it. A correction no open task owns has nowhere to go either, so the route matters as much as the ban.
@@ -30,6 +34,8 @@ The session also records nothing of what it learns. Both other callers of memory
30
34
  - Place every finding a merged pull request produced against the surface that owns it
31
35
  - Date the roadmap line from that file's last commit, so an old sequence reads as old
32
36
  - Keep one planned, non-conflicting task in reserve beyond what is running
37
+ - Refill the queue while a wave is still building, rather than on a merge and a thinning list alone, since neither of those fires across the window planning costs nothing
38
+ - Carry a constraint naming each in-flight track's file set in every plan written from here, stating per set which of the two acts it forbids, since a bare path list leaves a broken citation in place
33
39
  - Write the priority file with an editing tool that errors on a non-match, since the board is where a silent failure costs a dispatch
34
40
  - Carry a plan pointer in the Plan column and a file set in the Touches column, so a row's readiness and disjointness claims stay checkable
35
41
  - Re-resolve the board's plan pointers after any archive, since the archiving skill rewrites the task file and knows nothing about the board
@@ -94,13 +94,15 @@ Write no shape for a correction. A correction is a sentence, and a format for ad
94
94
  ## The loop
95
95
 
96
96
  1. Own the roadmap while a scope exists to sequence. Capture a needed draft or resequence of `.claude/ROADMAP.md` in the plan or a task file, naming the MVP list in `.claude/REQUIREMENTS.md` as the source, so a worker runs `claude-roadmap` in its branch and the tracked edit ships in a PR rather than dirtying main. Stop owning it once that list has shipped, since later work then arrives as discrete items rather than as versions.
97
- 2. Plan the next feature. Run `claude-feature` here, with the cross-feature context, to write a plan to `.claude/plans/`. Planning stays in this warm session so the plan front-loads reasoning a cold worker would otherwise re-derive. A constraint supplied from here that names a surface to leave alone states which of two acts it forbids, and the rule governing that is Step 3 of `claude-feature` under Constraints.
97
+ 2. Plan the next feature. Run `claude-feature` here, with the cross-feature context, to write a plan to `.claude/plans/`. Planning stays in this warm session so the plan front-loads reasoning a cold worker would otherwise re-derive. Every plan written from here also carries a constraint per track in flight, which the paragraph below this list states.
98
98
  3. Decide parallelism and merge order. Note which plans touch a shared wiring seam so their PRs merge in sequence, not at once.
99
99
  4. Verify the plan against the tree. Reading it is not enough, since a plan goes stale from whatever merged after it was written. Grep for each construct it names and count the sites against the count it claims. Check that every phase label it cites is still open. Open each file it describes rather than trusting its account of the contents. Correct the plan before handing it over.
100
100
  5. Hand off. The human opens a worker worktree with `claude-worktree` and runs `claude-autoship` against the plan. The orchestrator does not spawn workers.
101
101
  6. Review the PR. When a worker opens a PR, run `claude-pr-review` to post findings to it. This is the deep, independent pass. The worker's autoship self-review was only the green gate. Learning that a PR moved is the mechanical half, so read `${CLAUDE_SKILL_DIR}/references/orchestrator-poll.md` and start the poll it carries on the first dispatch rather than checking the board by hand. It routes a moved or answered pull request straight to a re-review and reports an opened one without acting, which keeps every first pass a batched judgment this session triggers.
102
102
  7. Close the loop. After the worker runs `claude-address-review`, re-review if needed, then the human merges. Tell the trailing worker to rebase when its branch shares a seam with the merged one.
103
103
 
104
+ A plan written here is written against a tree several branches are already changing, so it names the file set of every track in flight as a constraint, one set per track, read from the Touches column of that track's row. State for each set which of the two acts it forbids, per Constraints in `.claude/standards/plan.md`, or `${CLAUDE_SKILL_DIR}/../../standards/plan.md` when the project does not have it. A bare path list leaves the worker guessing, which is how a plan ends up forbidding the repair of a citation the change broke.
105
+
104
106
  ## Boundaries
105
107
 
106
108
  - Run one orchestrator at a time. The board is gitignored, so a second session sees none of this one's writes: two task files land minutes apart under different labels for the same work, one session archives a task mid-sweep in the other, and each archives a plan the other had retargeted. An Owner column does not fix this, since neither session can read the other's rows.
@@ -114,7 +116,13 @@ The tracked-file boundary collides with `CLAUDE.md`, which says to handle a smal
114
116
 
115
117
  ## Refilling the ready queue
116
118
 
117
- Keep enough planned, non-conflicting tasks available that a free worker never waits, and place the findings the last merge produced before promoting anything new. Run this after every merge and whenever the ready list thins. `${CLAUDE_SKILL_DIR}/references/orchestrator-sweep.md` wraps this procedure for a batch of merges and adds the plan re-verification that a merge invalidates.
119
+ Keep enough planned, non-conflicting tasks available that a free worker never waits, and place the findings the last merge produced before promoting anything new. Run this after every merge, whenever the ready list thins, and whenever a wave is in flight with fewer unclaimed plans than there are workers building. `${CLAUDE_SKILL_DIR}/references/orchestrator-sweep.md` wraps this procedure for a batch of merges and adds the plan re-verification that a merge invalidates.
120
+
121
+ The third trigger reaches a window the first two cannot. A session with three workers mid-build has merged nothing and has watched its ready list sit still, so both reactive triggers stay silent across the one stretch where planning costs the session nothing, and the wave finishes into an empty `## Run now`. Nothing starts this pass, in the way nothing starts the review poll, so it holds only while the session applies it.
122
+
123
+ It counts unclaimed plans against workers rather than reading the reserve in step 4, which is sized for one worker finishing and falls short of a wave landing together. A plan a worker has already taken serves nobody who finishes next, so counting it is what lets the queue read full while it is about to empty. How many to write forward past that floor is the parallelism call rather than a count of free slots, and `## Parallelism` below states what binds it.
124
+
125
+ ### Running the refill
118
126
 
119
127
  1. Run `gh pr list --state open` and `git log --oneline -8`. Report any pull request whose review has not been posted and stop for that one first.
120
128
  2. For each pull request merged since the last sweep, place every finding it produced. Route a finding that changes a rule to the standard or rule that states it, one that changes another task to that task's Findings, and one that overturns a groundwork lean to that folder marked answered. Never leave a finding in a pull request thread alone.
@@ -122,7 +130,7 @@ Keep enough planned, non-conflicting tasks available that a free worker never wa
122
130
  4. Read `.claude/tasks/priority.md` and count entries under its `## Run now` heading that carry a written plan. Keep one in reserve beyond what is running.
123
131
  5. Promote by whether a task establishes functionality rather than by age. Prefer a task that adds or proves a mechanism over one that trims, tidies, or audits an existing surface.
124
132
  6. Before promoting a candidate, list the files it touches against every task already running, per Parallelism below. Name the overlap and serialize when the sets are not disjoint.
125
- 7. Write a plan for each newly promoted task with `claude-feature`, then report:
133
+ 7. Write a plan for each newly promoted task with `claude-feature`, carrying the in-flight constraint that The loop above states, then report:
126
134
 
127
135
  ```plaintext
128
136
  Capture: owed since <the last handoff, or session start when none has run>
@@ -11,7 +11,9 @@ Without this skill, using the toolkit means knowing which skill to pick, and the
11
11
 
12
12
  Two failures come from acting on memory. A session that names a stack, rule, or snippet from what it remembers rather than from the live catalog runs against an entry that has been renamed or removed. And a session that edits a managed file by hand produces a change the next sync overwrites, which reads as the toolkit undoing work rather than as the edit having been made in the wrong place.
13
13
 
14
- The last failure is duplication. A front door that answers everything itself reimplements first-time detection and seed diffing inline, badly, beside the skills that already do both. The value of a router is that it stops at the handoff.
14
+ The third failure is duplication. A front door that answers everything itself reimplements first-time detection and seed diffing inline, badly, beside the skills that already do both. The value of a router is that it stops at the handoff.
15
+
16
+ The last failure is writing without re-checking. A domain sync brought a stricter standard into a target and left ten of its eleven context entries non-compliant in the same moment, and the run reported success because reporting the command was where its work ended. A person found the breakage afterwards by running an audit by hand. The audit that would have caught it is built and reachable from nothing the router offers, so one session held both the defect and its detector and connected neither to the other.
15
17
 
16
18
  ## Must
17
19
 
@@ -19,17 +21,23 @@ The last failure is duplication. A front door that answers everything itself rei
19
21
  - Map the stated intent to one lifecycle phase, then either run the simplest command that satisfies it or hand off
20
22
  - Resolve every stack, rule, snippet, and standard name from a catalog at runtime
21
23
  - Run the CLI non-interactively and report the command run, what changed, and the full path of anything written
24
+ - Offer every audit whose surface the target carries, and offer none whose surface it lacks
25
+ - Re-check the target after any operation that wrote, and report what those writes changed against the state read before acting
22
26
 
23
27
  ## Must not
24
28
 
25
29
  - Edit a managed file by hand instead of running the CLI that owns it
26
30
  - Reimplement a flow another skill owns
27
31
  - Hardcode a catalog name
32
+ - Run an audit the user did not pick, which turns a front door into a full sweep
33
+ - Refuse to finish an operation over an audit finding, since every audit reports judgments beside facts and a router that stops on one is a router a target works around
28
34
  - Auto-trigger. It is a door the user opens, and a router that fires on its own routes requests nobody made.
29
35
 
30
36
  ## Guards
31
37
 
32
38
  - An intent matching a deep flow hands off rather than running a shallow version of it, since a partial scaffold is harder to recover from than none
39
+ - An ignore-only fix on an unmeasured tooling report stops, names which cause applies, and names the command answering that cause. Unmeasured has several causes and they do not share a remedy, so one suggested command is wrong for at least one of them. No cause asks the user to supply a stack name, and none reads the zero counts under an unmeasured report as a clean target.
40
+ - A re-check with no earlier report to compare against says the write ran without a baseline, rather than describing the target from scratch
33
41
 
34
42
  ## Out of scope
35
43
 
@@ -22,13 +22,14 @@ Load a domain doc with `aitk docs <topic>` only when the intent touches that dom
22
22
 
23
23
  Run `aitk sync --check . --json` before routing. It reports what a target is behind on across every surface, so the intent comes from the project state rather than from the user having to know it already. Skip only when the user named a single operation to run.
24
24
 
25
- Read five fields off the report and carry each to `## Route`:
25
+ Read six fields off the report and carry each to `## Route`:
26
26
 
27
27
  - `unmigrated`: a domain sitting at the root layout with nothing under `.claude/`. The most urgent finding, because that domain reports no drift of its own while being entirely behind
28
28
  - `superseded`: a file a newer seed folder replaced. Report it and stop. No command moves it, since the content is the project's own
29
29
  - `seeds`: entries are `matching`, `stale`, `drifted`, or `missing`. Anything but `matching` needs the seed handoff
30
30
  - `domains[].entries`: per-file `stale`, `customized`, `stranded`, and `orphaned` as before
31
31
  - `historyUnavailable` on a domain or on `seeds`: attribution failed, so treat every difference as unverified and say so rather than reporting a file as untouched
32
+ - `tooling`: read `measured` first. Every count under it is zero when it is false, which is an absence of measurement rather than a measured zero. Past that, `chain` names the stacks the install resolved, nearest first, and `counts.gitignore` counts the managed ignore entries the target is missing.
32
33
 
33
34
  State what the report found in one line per finding before acting on any of it.
34
35
 
@@ -45,8 +46,30 @@ Map the stated intent, or what `## Diagnose` found, to one lifecycle phase, then
45
46
  - Seed or standards drift in `CLAUDE.md` or `.claude/` preambles: hand off to `claude-seed-sync`
46
47
  - Install one snippet, standard, or rule: run the domain `install` command
47
48
  - Sync one domain or every installed domain: run `aitk <domain> sync` or `aitk sync`
49
+ - Fix only the ignore entries of the installed stack: run `aitk tooling inject --gitignore <stack>`
48
50
  - Browse what is available: run `aitk <domain> list`
49
51
 
52
+ That row runs on a measured tooling report alone, so `measured` decides before `chain` is read at all. A false one splits three ways. Report tooling as unmeasured in each, name the cause, and run nothing, since the zero counts underneath are unmeasured rather than clean:
53
+
54
+ - An empty `chain` at a workspace root, which carries `pnpm-workspace.yaml` or a `workspaces` key in `package.json`: no chain is recorded there by design, since one would guess at what the packages hold. Name `aitk tooling sync <stack> <path>` against a package.
55
+ - An empty `chain` anywhere else: no tooling install is recorded, so name `aitk tooling sync` as the command that records one rather than asking the user for a stack.
56
+ - A `chain` carrying names: this toolkit no longer ships those stacks. Name them, since injecting would write against a retired name.
57
+
58
+ On a measured report, take `<stack>` from the first name in `tooling.chain`, which records the stack nearest the target. Inject re-resolves that leaf's own chain, so a target whose recorded chain is shorter receives the entries from the layer its install skipped. Say so before running it.
59
+
60
+ ### Audits
61
+
62
+ Four audits measure a surface without changing it. Offer the ones whose surface the target carries, list them together, and let the user pick. Run none of them unasked, and never treat a finding as a reason to abandon the operation the user asked for, since each reports judgments beside facts.
63
+
64
+ - `.claude/context/` present: offer `aitk context audit`
65
+ - A record folder present under `.claude/`, one of `plans`, `groundwork`, `intake`, or `memory`: offer `aitk records validate <kind>` for each one found
66
+ - Markdown that git lists: offer `aitk markdown audit`
67
+ - TypeScript or shell source present: offer `aitk comments scan`
68
+
69
+ An audit offered against a surface the target lacks reports an empty run as a finding, which is the same defect as never offering it at all. Check the surface before naming the command.
70
+
71
+ The markdown row is the one every target satisfies, since a project with no markdown is not one this reaches. Its condition is stated so the four rows read alike, and the row needs no gate beyond it.
72
+
50
73
  ## Execute
51
74
 
52
75
  For operations this skill runs directly:
@@ -54,6 +77,13 @@ For operations this skill runs directly:
54
77
  - Read the catalog first with `aitk <domain> list --json`, then match against project context
55
78
  - Run the CLI with `AITK_NON_INTERACTIVE=1` so it skips prompts. The tool permission dialog is the confirmation gate.
56
79
  - Report the command run and what changed. Emit the full relative path for any file written.
80
+ - Re-run `aitk sync --check . --json` after any operation that wrote, and compare it against the report `## Diagnose` read before acting
81
+ - Report the fields that moved and name the write that moved each one. Do not restate the second report, since the value sits in the difference alone.
82
+ - State a field that moved the wrong way and leave it for the user to decide on. The re-check reports and never repairs.
83
+
84
+ A write can leave a target worse in a field the write never named, which stays silent while the operation itself succeeds. The re-check is what makes that visible, so it runs before the operation is reported as done.
85
+
86
+ The comparison needs the earlier report. When `## Diagnose` was skipped because the user named a single operation, say the write ran with no baseline rather than describing the target from scratch, which would attribute differences another session made to this run.
57
87
 
58
88
  ## Boundaries
59
89
 
@@ -142,11 +142,11 @@ edit and a job counting those stays red with no remedy.
142
142
 
143
143
  ### Surfaces reported beside the domains
144
144
 
145
- Three sections sit outside the per-domain scan, because each names something
145
+ Four sections sit outside the per-domain scan, because each names something
146
146
  that walk cannot see. None of them produces a change, and no sync command
147
147
  applies any of them.
148
148
 
149
- All three report only against a toolkit-managed target, which is one carrying a
149
+ All four report only against a toolkit-managed target, which is one carrying a
150
150
  `.claude/` directory, a `CLAUDE.md`, or a domain still at the root layout. The
151
151
  report says so through `managed` in the JSON and routes an unmanaged directory to
152
152
  `aitk init`. Seeds are why the gate exists, since they enumerate from the toolkit
@@ -159,6 +159,8 @@ toolkit installed. When `managed` is false every section comes back empty rather
159
159
  than the render alone going quiet, so a consumer reading `--json` never acts on a
160
160
  finding the rendered half withheld.
161
161
 
162
+ #### Seeds, superseded artifacts, and unmigrated domains
163
+
162
164
  `seeds` classifies every seed the toolkit ships against the target's copy, as
163
165
  `matching`, `stale`, `drifted`, or `missing`. `missing` has no per-domain
164
166
  equivalent, since the domain walk lists what a target installed and cannot see a
@@ -194,11 +196,57 @@ the toolkit ships, so a root folder holding the project's own documents beside
194
196
  the installed ones reports the installed subset, where a listing reports every
195
197
  file and proposes relocating the lot.
196
198
 
199
+ #### The reverse walk
200
+
201
+ `reverse` is the one section built by walking the target rather than the
202
+ catalog. Every other surface enumerates toolkit-owned keys and asks whether the
203
+ target matches, so a folder the toolkit deleted appears in none of them. It
204
+ carries `unclaimed`, `migrations`, and `historyUnavailable`.
205
+
206
+ `unclaimed` names a folder the target holds at a top-level path the toolkit once
207
+ shipped and has since deleted. The candidate roots come from the toolkit's own
208
+ history rather than from a list, so a root dropped later is covered without a
209
+ code change. Scoping to those roots is what keeps the walk useful: walking the
210
+ whole tree reports every project folder as unclaimed, which is true and says
211
+ nothing.
212
+
213
+ Each entry carries `rel`, a file count, and an `attribution` of `dropped`,
214
+ `project`, or `unattributed`. A dropped folder and one the project wrote are the
215
+ same bytes at the same path, so the verdict is traced from history rather than
216
+ guessed from the filesystem.
217
+
218
+ Content matching a version the toolkit published reads as `dropped` and carries
219
+ the `since` commit that published it. Names the toolkit shipped holding content
220
+ it never published read as `unattributed`, which is a state in its own right
221
+ rather than a soft yes. No overlap at all reads as `project`, and the render
222
+ drops those while the JSON keeps them.
223
+
224
+ Only files whose path the toolkit once held are hashed, so a project folder
225
+ colliding on a retired name costs the walk no reads. The cost is that a file the
226
+ toolkit shipped and the target renamed goes unmatched, the same limit the
227
+ `unmigrated` count carries.
228
+
229
+ `migrations` names a proposal-only skill with a live case in this target, which
230
+ is the treatment `unmigrated` already gives `migration-standards`. It fires on a
231
+ `CLAUDE.md` past 250 lines for `migration-claude-md`, and on a `docs/` folder
232
+ holding markdown with no populated `.claude/context/` for `migration-context`.
233
+ Each entry carries the skill name and the measurement behind it, so a consumer
234
+ can check the proposal before running it. Without the field both skills are
235
+ documented and unreachable from any report.
236
+
237
+ #### What counts toward the gate
238
+
197
239
  `unmigrated` counts toward `--exit-code`, since running the relocation closes it.
198
240
  `superseded` and every seed state are excluded, for the reason `orphaned` already
199
241
  is: only the user can move content they wrote, so failing a job on it leaves the
200
242
  job red with no mechanical remedy.
201
243
 
244
+ The whole `reverse` section is excluded on the same grounds, and more strongly.
245
+ Every entry in it is a judgment about a file the project may own, and one of its
246
+ three verdicts is a labelled unknown by design. The unmigrated detection shipped
247
+ that exact false positive once, failing a push with no action that cleared it,
248
+ so this section reports and gates nothing.
249
+
202
250
  ## Bootstrap
203
251
 
204
252
  `aitk init` installs up to six core domains and reports each one independently. A
@@ -49,7 +49,7 @@ Two ban shapes stay unmeasured and the report says so on every run. A phrase ban
49
49
 
50
50
  ### Bullets, paragraphs, and depth
51
51
 
52
- Bullet weight and depth are the checks that moved off `aitk context audit`, unchanged in what they measure. A top-level bullet reports past roughly 400 characters with continuation lines folded in and nested items left out. A run of lines no heading breaks reports past roughly 40 rendered lines, measured at 80 columns, skipping fenced blocks and exempting a flat peer list averaging under 130 characters a bullet and a run that is entirely table rows.
52
+ Bullet weight and depth are the checks that moved off `aitk context audit`, unchanged in what they measure. A top-level bullet reports past roughly 400 characters with continuation lines folded in and nested items left out. A run of lines no heading breaks reports past roughly 40 rendered lines, measured at 80 columns, skipping fenced blocks and exempting a flat peer list averaging under 130 characters a bullet and a run that is entirely table rows. Each file reports its longest run alone, so a second run past the checkpoint in the same file is never named.
53
53
 
54
54
  Every weight and depth measure counts the text a reader is shown. A link reduces to its anchor text and an autolink drops whole, since no reader is shown either destination. A backticked path stays counted, which is where these measures part from the ban scan above: that one blanks a code span so a standard quoting its own banned character does not report itself, and discounting the same span here would under-report a paragraph carrying several. One file holds both span sets and each answers its own question.
55
55
 
@@ -104,11 +104,19 @@ That attribution comes from `.claude/aitk.json`, a stamp every install and sync
104
104
 
105
105
  A project that has never synced under a toolkit new enough to write a stamp falls back to the toolkit's own git history. Installed content matching any version that history published proves the file untouched, so it reports `stale` naming the commit it came from, and content matching no published version stays `drifted`. That fallback needs the toolkit as a git checkout. Installed from the registry it ships source without history, and the report says attribution was unavailable rather than reading every file as a local edit.
106
106
 
107
- Three further causes sit outside the per-domain scan, each naming something that walk cannot see. A seed the project edited is reported under `seeds` and reconciled with `aitk:claude-seed-sync`, since no sync command touches a seed. A file a newer seed folder replaced is reported under `superseded`, such as `.claude/TASKS.md` against the `.claude/tasks/` that now ships, and nothing moves it because the content is the project's own. A domain sitting at the root layout with nothing under `.claude/` is reported under `unmigrated` and handed to `aitk:migration-standards`.
107
+ Four further causes sit outside the per-domain scan, each naming something that walk cannot see. A seed the project edited is reported under `seeds` and reconciled with `aitk:claude-seed-sync`, since no sync command touches a seed. A file a newer seed folder replaced is reported under `superseded`, such as `.claude/TASKS.md` against the `.claude/tasks/` that now ships, and nothing moves it because the content is the project's own. A domain sitting at the root layout with nothing under `.claude/` is reported under `unmigrated` and handed to `aitk:migration-standards`.
108
108
 
109
- That last one matters most on an older project. Before it existed, a target holding `standards/` at its root reported zero entries for that domain, so a project that had never migrated was indistinguishable from one that was fully current.
109
+ That third one matters most on an older project. Before it existed, a target holding `standards/` at its root reported zero entries for that domain, so a project that had never migrated was indistinguishable from one that was fully current.
110
110
 
111
- Add `--json` for the machine-readable report, and `--exit-code` to fail a CI job when a target falls behind. Files the project authored itself never count toward that exit code, and neither do superseded artifacts, seed drift, or tooling, since each reports content the project is expected to edit or place itself. An unmigrated domain does count, because running the relocation closes it.
111
+ #### What the toolkit stopped shipping
112
+
113
+ The fourth cause runs backwards. Every one above starts from what the toolkit ships and asks whether the target matches, so a folder the toolkit dropped appears in none of them. `reverse` walks the target instead and reports a folder sitting at a top-level path the toolkit once shipped and has since deleted.
114
+
115
+ Each entry carries a verdict, since a dropped folder and one the project wrote are the same bytes at the same path. `dropped` names the commit that published the content. `unattributed` means the toolkit shipped that path and the content matches no version it published. `project` means the folder only shares a retired name. Nothing acts on any of them, and the verdict is what makes the list safe to read.
116
+
117
+ The same field names a proposal-only skill with a live case here under `migrations`, which is how `aitk:migration-claude-md` and `aitk:migration-context` become reachable. Each entry carries the measurement behind the proposal rather than the proposal alone.
118
+
119
+ Add `--json` for the machine-readable report, and `--exit-code` to fail a CI job when a target falls behind. Files the project authored itself never count toward that exit code, and neither do superseded artifacts, seed drift, tooling, or anything the reverse walk reports, since each names content the project is expected to edit or place itself. An unmigrated domain does count, because running the relocation closes it.
112
120
 
113
121
  Tooling reports under a section of its own, and `measured` there says whether the target ever recorded a chain. One that never ran a tooling sync reports unmeasured rather than clean, which is what separates tooling nobody has looked at from tooling that is current. A workspace root records nothing either way, since each package resolves its own chain. Reconcile the configs with `aitk tooling sync <stack> <path>`.
114
122
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "0.68.0",
4
+ "version": "0.70.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -18,6 +18,7 @@ import {
18
18
  SYNC_DOMAINS,
19
19
  type SyncDomain,
20
20
  } from '@/sync/target'
21
+ import type { UnclaimedFolder } from '@/sync/reverse'
21
22
  import { runGitWorkflow } from '@/sync/workflow'
22
23
  import { resolveTarget } from '@/target'
23
24
  import {
@@ -162,6 +163,9 @@ function renderCheck(report: CheckReport): void {
162
163
  for (const name of report.newSkills) logInfo(name)
163
164
  }
164
165
 
166
+ renderUnclaimed(report)
167
+ renderMigrations(report)
168
+
165
169
  outro()
166
170
  // Scanned domains only. Tooling renders a section on every managed target, so
167
171
  // naming it here repeats what that section already said under a second
@@ -237,6 +241,71 @@ function renderSeeds(report: CheckReport): void {
237
241
  logInfo('Run /aitk:claude-seed-sync to reconcile these section by section.')
238
242
  }
239
243
 
244
+ /**
245
+ * Names the attribution before the count, the way the tooling section does. A
246
+ * dropped folder and a project-authored one are the same bytes at the same
247
+ * path, so the count alone is the one thing an operator cannot act on.
248
+ *
249
+ * A folder history proved the project owns is dropped from the render and kept
250
+ * in the JSON. Printing it costs a line on every run for a name collision no
251
+ * remedy closes, which is the bar this section had to clear to exist at all.
252
+ */
253
+ function renderUnclaimed(report: CheckReport): void {
254
+ const { unclaimed, historyUnavailable } = report.reverse
255
+ const notable = unclaimed.filter((entry) => entry.attribution !== 'project')
256
+
257
+ if (notable.length === 0 && !historyUnavailable) return
258
+
259
+ logStep('No longer shipped by the toolkit')
260
+
261
+ if (historyUnavailable) {
262
+ logWarn('This toolkit has no git history, so nothing could be walked.')
263
+ return
264
+ }
265
+
266
+ for (const entry of notable) {
267
+ logWarn(`${entry.rel}/ (${describeUnclaimed(entry)})`)
268
+ }
269
+
270
+ logInfo(
271
+ 'Decide what happens to these yourself. No sync command touches them.',
272
+ )
273
+ }
274
+
275
+ /**
276
+ * State first, then the count, then where the content came from. The revision
277
+ * is the commit that published the content the target still holds, never the
278
+ * one that dropped the folder, so it takes a clause of its own. Suffixed onto
279
+ * `dropped upstream` it reads as the date of the drop, and an operator running
280
+ * `git show` on it lands on the commit that added the folder.
281
+ */
282
+ function describeUnclaimed(entry: UnclaimedFolder): string {
283
+ const state =
284
+ entry.attribution === 'dropped' ? 'dropped upstream' : 'unattributed'
285
+ const counted = `${state}, ${entry.files} files`
286
+
287
+ if (entry.since === undefined) return counted
288
+
289
+ return `${counted}, content published at ${entry.since.slice(0, 7)}`
290
+ }
291
+
292
+ /**
293
+ * The two proposal-only skills no other field reaches. `migration-standards`
294
+ * gets the same treatment from the unmigrated section above, which is the
295
+ * precedent this follows rather than a second spelling of it.
296
+ */
297
+ function renderMigrations(report: CheckReport): void {
298
+ const { migrations } = report.reverse
299
+ if (migrations.length === 0) return
300
+
301
+ logStep('Migrations with a case here')
302
+
303
+ for (const candidate of migrations) {
304
+ logWarn(candidate.reason)
305
+ logInfo(`Run /aitk:${candidate.skill} for a proposal.`)
306
+ }
307
+ }
308
+
240
309
  async function runSync(target: string): Promise<number> {
241
310
  intro('aitk sync')
242
311
 
package/src/sync/check.ts CHANGED
@@ -10,6 +10,11 @@ import {
10
10
  type SupersededEntry,
11
11
  type UnmigratedDomain,
12
12
  } from '@/sync/layout'
13
+ import {
14
+ buildReverseReport,
15
+ emptyReverseReport,
16
+ type ReverseReport,
17
+ } from '@/sync/reverse'
13
18
  import { buildSeedsReport, type SeedsReport } from '@/sync/seeds-report'
14
19
  import {
15
20
  readStamp,
@@ -151,6 +156,13 @@ export interface CheckReport {
151
156
  readonly superseded: readonly SupersededEntry[]
152
157
  readonly unmigrated: readonly UnmigratedDomain[]
153
158
  readonly newSkills: readonly string[]
159
+ /**
160
+ * The one section built by walking the target rather than the catalog. It
161
+ * reports beside `superseded`, `unmigrated`, and `newSkills` rather than
162
+ * absorbing them, because each of those already answers a narrower version of
163
+ * the same question correctly. See `@/sync/reverse`.
164
+ */
165
+ readonly reverse: ReverseReport
154
166
  }
155
167
 
156
168
  export function installedStampDomains(target: string): ScannedDomain[] {
@@ -258,6 +270,11 @@ export function countStates(entries: readonly ScanEntry[]): StateCounts {
258
270
  * project is expected to edit, so a job counting it stays red with no remedy.
259
271
  * Being unmeasured is not what excludes it, since an unmeasured report carries
260
272
  * zero changes and would pass a count either way.
273
+ *
274
+ * The reverse report is excluded because every entry in it is a judgment about
275
+ * a file the project may own. `detectUnmigrated` already shipped that exact
276
+ * false positive once, failing a push with no action that cleared it, and a
277
+ * walk that reports `unattributed` by design would repeat it.
261
278
  */
262
279
  export function hasDrift(report: CheckReport): boolean {
263
280
  if (report.unmigrated.length > 0) return true
@@ -306,6 +323,7 @@ export async function buildCheckReport(
306
323
  superseded: [],
307
324
  unmigrated: [],
308
325
  newSkills: [],
326
+ reverse: emptyReverseReport(),
309
327
  }
310
328
  }
311
329
 
@@ -318,6 +336,7 @@ export async function buildCheckReport(
318
336
  superseded: collectSuperseded(target),
319
337
  unmigrated,
320
338
  newSkills: await readNewSkills(toolkitRoot, anchors),
339
+ reverse: buildReverseReport(toolkitRoot, target),
321
340
  }
322
341
  }
323
342
 
@@ -101,7 +101,7 @@ export function readHistoryIndex(
101
101
  * caller's `relative()` output has to be converted before it can match a path
102
102
  * the log emitted.
103
103
  */
104
- function toRepoPath(path: string): string {
104
+ export function toRepoPath(path: string): string {
105
105
  return path.split(sep).join('/')
106
106
  }
107
107
 
@@ -0,0 +1,270 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { execaSync } from 'execa'
4
+ import { gitEnv } from '@/git-env'
5
+ import {
6
+ findInstalledOrigin,
7
+ type HistoryIndex,
8
+ readHistoryIndex,
9
+ toRepoPath,
10
+ } from '@/sync/history'
11
+
12
+ const CLAUDE_DIR = '.claude'
13
+
14
+ /**
15
+ * A `CLAUDE.md` past this many lines has more in it than the always-load tier
16
+ * is for. The figure is the checkpoint `.claude/standards/context.md` sets for
17
+ * a context entry, raised because `CLAUDE.md` legitimately carries more than
18
+ * one domain. It gates a proposal rather than a failure, so being approximate
19
+ * costs a line an operator can ignore.
20
+ */
21
+ const CLAUDE_MD_LINES = 250
22
+
23
+ /**
24
+ * Who put an unclaimed folder in the target. A dropped folder and one the
25
+ * project wrote are the same bytes at the same path, so this is traced from the
26
+ * toolkit's own history and never guessed from the filesystem.
27
+ *
28
+ * `unattributed` is a verdict in its own right rather than a soft `dropped`.
29
+ * The toolkit shipped a path of that name and the content matches no version it
30
+ * ever published, which is exactly what history proving nothing looks like. An
31
+ * operator can act on a labelled unknown and cannot act on a guess.
32
+ */
33
+ export type Attribution = 'dropped' | 'project' | 'unattributed'
34
+
35
+ /**
36
+ * A folder the target holds at a root the toolkit has stopped shipping.
37
+ * Carries no source and queues no change, because only the user can decide what
38
+ * happens to content the toolkit no longer claims.
39
+ */
40
+ export interface UnclaimedFolder {
41
+ readonly rel: string
42
+ readonly files: number
43
+ readonly attribution: Attribution
44
+ /** Toolkit revision whose version of a file here the target still holds. */
45
+ readonly since?: string
46
+ }
47
+
48
+ /**
49
+ * A proposal-only skill with a live case in this target. The skills propose and
50
+ * never execute, so the report names one rather than queueing a change, which
51
+ * is the treatment `unmigrated` already gives `migration-standards`.
52
+ */
53
+ export interface MigrationCandidate {
54
+ readonly skill: string
55
+ /** What was measured, so the proposal is checkable before it is run. */
56
+ readonly reason: string
57
+ }
58
+
59
+ export interface ReverseReport {
60
+ readonly unclaimed: readonly UnclaimedFolder[]
61
+ readonly migrations: readonly MigrationCandidate[]
62
+ /** Set when the walk needed history to run and this toolkit has none. */
63
+ readonly historyUnavailable: boolean
64
+ }
65
+
66
+ const EMPTY_REVERSE: ReverseReport = {
67
+ unclaimed: [],
68
+ migrations: [],
69
+ historyUnavailable: false,
70
+ }
71
+
72
+ /**
73
+ * Asks the question every other detection surface asks backwards: what does the
74
+ * target hold that no live catalog claims. The forward direction enumerates
75
+ * toolkit-owned keys and tests the target against them, so a folder the toolkit
76
+ * deleted appears in no section at all.
77
+ *
78
+ * Scoped to roots the toolkit itself has dropped rather than to the whole tree.
79
+ * Walking the tree reports every project folder as unclaimed, which is true and
80
+ * useless, and the question here is only what the toolkit put there and then
81
+ * stopped claiming.
82
+ *
83
+ * Reports beside `superseded`, `unmigrated`, and `newSkills` rather than
84
+ * absorbing them. Each of those answers a narrower version of the same question
85
+ * and none is wrong today, so folding them in would change what two shipped
86
+ * sections print in the same change that introduces a third.
87
+ */
88
+ export function buildReverseReport(
89
+ toolkitRoot: string,
90
+ target: string,
91
+ ): ReverseReport {
92
+ const roots = readDroppedRoots(toolkitRoot)
93
+ const migrations = detectMigrations(target)
94
+
95
+ if (roots === undefined) {
96
+ return { unclaimed: [], migrations, historyUnavailable: true }
97
+ }
98
+
99
+ const unclaimed: UnclaimedFolder[] = []
100
+
101
+ for (const root of roots) {
102
+ for (const rel of [root, join(CLAUDE_DIR, root)]) {
103
+ const files = listFiles(join(target, rel))
104
+ if (files.length === 0) continue
105
+
106
+ unclaimed.push(attributeFolder(toolkitRoot, target, rel, root, files))
107
+ }
108
+ }
109
+
110
+ return { unclaimed, migrations, historyUnavailable: false }
111
+ }
112
+
113
+ export function emptyReverseReport(): ReverseReport {
114
+ return EMPTY_REVERSE
115
+ }
116
+
117
+ /**
118
+ * Top-level toolkit paths that history records a deletion under and that no
119
+ * longer exist in the working tree. Both halves are load-bearing: a path with
120
+ * deletions that still exists is a live root the forward direction already
121
+ * covers, and a path that never lost a file was never dropped.
122
+ *
123
+ * Read as one log over the whole repository rather than per candidate, since
124
+ * the candidate set is what the call produces. A root-level file is skipped
125
+ * because this walk matches target folders, and a deleted `README.md` names no
126
+ * folder to look for.
127
+ */
128
+ function readDroppedRoots(toolkitRoot: string): readonly string[] | undefined {
129
+ const result = execaSync(
130
+ 'git',
131
+ [
132
+ '-C',
133
+ toolkitRoot,
134
+ 'log',
135
+ '--all',
136
+ '--diff-filter=D',
137
+ '--name-only',
138
+ '--format=',
139
+ ],
140
+ { reject: false, env: gitEnv(), extendEnv: false },
141
+ )
142
+
143
+ if (result.exitCode !== 0) return undefined
144
+
145
+ const roots = new Set<string>()
146
+
147
+ for (const line of result.stdout.split('\n')) {
148
+ const trimmed = line.trim()
149
+ const boundary = trimmed.indexOf('/')
150
+ if (boundary <= 0) continue
151
+
152
+ roots.add(trimmed.slice(0, boundary))
153
+ }
154
+
155
+ return [...roots]
156
+ .filter((root) => !existsSync(join(toolkitRoot, root)))
157
+ .sort()
158
+ }
159
+
160
+ /**
161
+ * Splits a found folder three ways against the toolkit's history of the root it
162
+ * sits at. Content matching a published version proves the toolkit put the file
163
+ * there. Names the toolkit shipped with content it never published proves only
164
+ * that the two collided, which is the unattributed case. No name overlap at all
165
+ * is a folder the project owns that happens to share a retired name.
166
+ *
167
+ * Only files whose relative path the toolkit once held are hashed, so a project
168
+ * folder colliding on name costs the walk no reads. The cost is that a file the
169
+ * toolkit shipped and the target renamed goes unmatched, the same limit
170
+ * `countToolkitOwned` carries for the forward direction.
171
+ */
172
+ function attributeFolder(
173
+ toolkitRoot: string,
174
+ target: string,
175
+ rel: string,
176
+ root: string,
177
+ files: readonly string[],
178
+ ): UnclaimedFolder {
179
+ const index = readHistoryIndex(toolkitRoot, [root])
180
+
181
+ if (index === undefined) {
182
+ return { rel, files: files.length, attribution: 'unattributed' }
183
+ }
184
+
185
+ let covered = false
186
+
187
+ for (const file of files) {
188
+ const sourceRel = toRepoPath(join(root, file))
189
+ if (!covers(index, sourceRel)) continue
190
+
191
+ covered = true
192
+ const since = findInstalledOrigin(index, sourceRel, join(target, rel, file))
193
+
194
+ if (since !== undefined) {
195
+ return { rel, files: files.length, attribution: 'dropped', since }
196
+ }
197
+ }
198
+
199
+ return {
200
+ rel,
201
+ files: files.length,
202
+ attribution: covered ? 'unattributed' : 'project',
203
+ }
204
+ }
205
+
206
+ function covers(index: HistoryIndex, sourceRel: string): boolean {
207
+ return index.get(sourceRel) !== undefined
208
+ }
209
+
210
+ /**
211
+ * Live cases for the two proposal-only skills no report field has ever named.
212
+ * `migration-standards` reaches an operator through `unmigrated`, and without
213
+ * this its two siblings are documented and unreachable.
214
+ *
215
+ * Both tests read what the skill itself acts on, so a proposal the report makes
216
+ * is one the skill has work to do for. A context tier that already holds files
217
+ * settles the `docs/` split, whether or not the toolkit is what made it.
218
+ */
219
+ export function detectMigrations(target: string): MigrationCandidate[] {
220
+ const found: MigrationCandidate[] = []
221
+ const claudeMd = join(target, 'CLAUDE.md')
222
+
223
+ if (existsSync(claudeMd)) {
224
+ const lines = countLines(readFileSync(claudeMd, 'utf8'))
225
+ if (lines > CLAUDE_MD_LINES) {
226
+ found.push({
227
+ skill: 'migration-claude-md',
228
+ reason: `CLAUDE.md carries ${lines} lines, past the ${CLAUDE_MD_LINES} the three-tier split is for`,
229
+ })
230
+ }
231
+ }
232
+
233
+ const docs = listFiles(join(target, 'docs')).filter(isMarkdown).length
234
+ const context = listFiles(join(target, CLAUDE_DIR, 'context')).length
235
+
236
+ if (docs > 0 && context === 0) {
237
+ found.push({
238
+ skill: 'migration-context',
239
+ reason: `${docs} markdown files under docs/ with no .claude/context/ tier`,
240
+ })
241
+ }
242
+
243
+ return found
244
+ }
245
+
246
+ function isMarkdown(rel: string): boolean {
247
+ return rel.endsWith('.md')
248
+ }
249
+
250
+ /**
251
+ * Lines as `wc -l` counts them, so the figure in the reason matches what an
252
+ * operator gets from the shell. Splitting alone overstates a file ending in a
253
+ * newline by one, and the reason states a measurement rather than a rank.
254
+ */
255
+ function countLines(content: string): number {
256
+ const parts = content.split('\n')
257
+ return parts.at(-1) === '' ? parts.length - 1 : parts.length
258
+ }
259
+
260
+ /**
261
+ * Every file under a directory, dotfiles included. A dropped root can hold any
262
+ * extension, so this does not filter to markdown the way the domain walk does.
263
+ */
264
+ function listFiles(dir: string): string[] {
265
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) return []
266
+
267
+ return [
268
+ ...new Bun.Glob('**/*').scanSync({ cwd: dir, onlyFiles: true, dot: true }),
269
+ ].sort()
270
+ }