@erclx/aitk 0.69.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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/toolkit-operator/REQUIREMENT.md +9 -1
- package/claude/skills/toolkit-operator/SKILL.md +31 -1
- package/docs/agents/install-and-sync.md +50 -2
- package/docs/target-projects.md +11 -3
- package/package.json +1 -1
- package/src/commands/sync.ts +69 -0
- package/src/sync/check.ts +19 -0
- package/src/sync/history.ts +1 -1
- package/src/sync/reverse.ts +270 -0
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
package/docs/target-projects.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
package/src/commands/sync.ts
CHANGED
|
@@ -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
|
|
package/src/sync/history.ts
CHANGED
|
@@ -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
|
+
}
|