@erclx/aitk 0.69.0 → 0.71.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.69.0",
4
+ "version": "0.71.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -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
@@ -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
 
@@ -8,6 +8,8 @@ description: Enforce planning standards before implementation
8
8
 
9
9
  - Analyze requests and output a numbered implementation plan before execution.
10
10
  - Challenge ambiguous or over-engineered requests before implementation.
11
+ - Search the project, its dependencies, and the standard library for an existing implementation before writing new code.
12
+ - State where the search ran and why each candidate was rejected. Do not assert a search without naming its results.
11
13
  - Propose the simplest solution that satisfies the requirement before implementing complex patterns.
12
14
  - Write or update tests as part of every implementation plan.
13
15
  - Do not modify code without a confirmed plan.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "0.69.0",
4
+ "version": "0.71.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
+ }
@@ -11,7 +11,7 @@ Skills give Claude Code domain-specific constraints and rules inline, so it can
11
11
 
12
12
  ## Scope
13
13
 
14
- Governs a skill folder under `skills/` as one artifact: `SKILL.md`, its sibling `REQUIREMENT.md`, and the bundled reference, script, and asset folders beside them.
14
+ Governs a skill folder under `skills/` as one artifact: `SKILL.md`, its siblings `REQUIREMENT.md` and `EVAL.md`, and the bundled reference, script, and asset folders beside them.
15
15
 
16
16
  Does not govern:
17
17
 
@@ -88,7 +88,9 @@ allowed-tools: <tools required>
88
88
 
89
89
  ## Structure
90
90
 
91
- - Skill is a folder named in kebab-case containing `SKILL.md` (required), `REQUIREMENT.md` (required), `scripts/` (optional), `references/` (optional), `assets/` (optional)
91
+ - Skill is a folder named in kebab-case containing `SKILL.md` (required), `REQUIREMENT.md` (required), `EVAL.md` (optional), `scripts/` (optional), `references/` (optional), `assets/` (optional)
92
+ - Name a file sitting directly in the skill folder in capitals and a bundled folder in lowercase, so the parts a reader opens are distinct from the ones a skill loads
93
+ - `EVAL.md` holds prompts and a judging rubric a person runs by hand, so a skill carrying one needs no runner beside it
92
94
  - `SKILL.md` must start with YAML frontmatter between `---` delimiters
93
95
  - No `README.md` inside the skill folder
94
96
  - No spaces, capitals, or underscores in folder or skill name