@erclx/aitk 0.45.0 → 0.46.1
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/README.md +15 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-seed-sync/REQUIREMENT.md +3 -1
- package/claude/skills/claude-seed-sync/SKILL.md +16 -4
- package/claude/skills/toolkit-operator/SKILL.md +18 -1
- package/docs/agents/install-and-sync.md +46 -0
- package/docs/target-projects.md +6 -2
- package/package.json +1 -1
- package/src/claude/seeds.ts +6 -1
- package/src/cli.ts +20 -1
- package/src/commands/sync.ts +54 -1
- package/src/sync/check.ts +71 -0
- package/src/sync/layout.ts +139 -0
- package/src/sync/seeds-report.ts +111 -0
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ claude plugin install aitk@aitk
|
|
|
21
21
|
|
|
22
22
|

|
|
23
23
|
|
|
24
|
-
The skills land as `/aitk:<name>`. If your session was already open, run `/reload-plugins` to pick them up.
|
|
24
|
+
The skills land as `/aitk:<name>`. If your session was already open, run `/reload-plugins` to pick them up.
|
|
25
25
|
|
|
26
26
|
Several skills call the `aitk` CLI to read catalogs and run installs, and the plugin doesn't put it on your path. Install it from the registry.
|
|
27
27
|
|
|
@@ -31,6 +31,20 @@ bun install --global @erclx/aitk
|
|
|
31
31
|
|
|
32
32
|
[Bun](https://bun.sh) is the CLI runtime and has to be on your path first. Confirm the install by resolving `aitk --help`.
|
|
33
33
|
|
|
34
|
+
## Update
|
|
35
|
+
|
|
36
|
+
Nothing refreshes on its own. Claude Code ships auto-update off for third-party marketplaces, so an installed copy serves whatever version it was installed at until you refresh it.
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
claude plugin marketplace update aitk
|
|
40
|
+
claude plugin update aitk@aitk
|
|
41
|
+
bun install --global @erclx/aitk
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The first two update the skills, the third updates the CLI, and they move independently. Restart Claude Code, or run `/reload-plugins`, to pick the skills up.
|
|
45
|
+
|
|
46
|
+
To stop doing this by hand, turn auto-update on once under `/plugin` in the Marketplaces tab. Confirm what you are running with `aitk --version` and `claude plugin list`.
|
|
47
|
+
|
|
34
48
|
## Why
|
|
35
49
|
|
|
36
50
|
Every AI coding setup accumulates the same assets. Prompts to reuse, rules agents should follow, slash commands, skills, seed docs, sync scripts. Once you have enough projects, your copies drift and your agents stop getting consistent signals.
|
|
@@ -17,7 +17,9 @@ Two failures belong to the audit rather than to the diff. A decision taken in ch
|
|
|
17
17
|
|
|
18
18
|
- Read seed and standard content from the CLI rather than holding a copy, so the audit and the install cannot disagree
|
|
19
19
|
- Diff per section, treating the preamble as a section of its own
|
|
20
|
-
- Separate a customized section from a stale one, and default the customized one to no action
|
|
20
|
+
- Separate a customized section from a stale one by reading the attribution the drift report records, not by how the section looks, and default the customized one to no action
|
|
21
|
+
- Fall back to appearance only when attribution is unavailable, and mark every verdict reached that way as unverified
|
|
22
|
+
- Leave a file the report proves untouched to the bulk sync that owns it, rather than auditing what carries no edits
|
|
21
23
|
- Persist the proposal and every decision to a review file that stays the source of truth across re-pings
|
|
22
24
|
- Apply one section at a time, never by rewriting a file
|
|
23
25
|
|
|
@@ -12,19 +12,30 @@ Surfaces drift between the toolkit's current seed docs and what was installed in
|
|
|
12
12
|
- If the `aitk` CLI is not on PATH, stop: `❌ aitk CLI not found. Install the toolkit first.`
|
|
13
13
|
- If no `.claude/` directory exists at the project root, stop: `❌ No .claude/ directory found. Run aitk claude init first.`
|
|
14
14
|
|
|
15
|
-
## Step 1: read toolkit sources
|
|
15
|
+
## Step 1: read toolkit sources and the drift report
|
|
16
16
|
|
|
17
|
-
Run
|
|
17
|
+
Run all three in parallel from the project root:
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
20
|
aitk claude seeds list --json 2>/dev/null
|
|
21
21
|
aitk standards list --json 2>/dev/null
|
|
22
|
+
aitk sync --check . --json 2>/dev/null
|
|
22
23
|
```
|
|
23
24
|
|
|
24
25
|
Seeds emit an array of `{name, source, target, content}`. Standards emit `{standards: [{name, description, target, content}]}`. In both cases `target` is the path relative to the project root where the file installs. Merge the two into one list of entries tagged by source (`seed` or `standard`).
|
|
25
26
|
|
|
26
27
|
If the target project has no `.claude/standards/` directory, skip the standards stage silently.
|
|
27
28
|
|
|
29
|
+
### Narrow the set by attribution
|
|
30
|
+
|
|
31
|
+
The report is what separates a file the project edited from one the toolkit moved on without it. Read `seeds.entries` for seed paths and `domains[].entries` for standards, then drop from the merged list every entry the report attributes as `stale`. A stale file matches a version the toolkit published, so it carries no edits to lose and `aitk standards sync` takes it whole. Section-merging it is work with no decision behind it.
|
|
32
|
+
|
|
33
|
+
Keep every seed regardless of state. `CLAUDE.md` is the file a project edits most, and its `drifted` verdict is the case this skill exists for.
|
|
34
|
+
|
|
35
|
+
Record the dropped standards as one line in the scope table rather than as rows: `<n> standards stale, taken by aitk standards sync`.
|
|
36
|
+
|
|
37
|
+
Fall back to the appearance heuristic in step 3 when the report cannot attribute, which is `historyUnavailable` set on the relevant section or the command failing outright. Say so in the summary block, because a fallback audit reports guesses rather than facts.
|
|
38
|
+
|
|
28
39
|
## Step 2: read installed copies
|
|
29
40
|
|
|
30
41
|
For each entry in the merged list, read the file at its `target` path from the project root. Run reads in parallel. Mark missing files for **Add** treatment. Skip non-text seeds (`.json`) for section diffing. Record a one-line note in the scope table that the user can compare manually.
|
|
@@ -41,8 +52,9 @@ For each seed file present in both sides, parse the body into a preamble (everyt
|
|
|
41
52
|
- **Toolkit-only section** (present in source, absent in target): candidate to **Add**.
|
|
42
53
|
- **Target-only section** (present in target, absent in source): preserve, never propose removal. These are user customizations.
|
|
43
54
|
- **Drifted section** (present in both, content differs): candidate to **Update**.
|
|
44
|
-
-
|
|
45
|
-
-
|
|
55
|
+
- Read the file's verdict from the report rather than judging it by eye. `drifted` means the content matches no version the toolkit ever published, so the project wrote it: call it **Customized**, default to skip, record in the scope table only, never numbered.
|
|
56
|
+
- `stale` at the file level means the toolkit moved and the project did not, so any section differing inside it is **Stale**. Default action: propose update.
|
|
57
|
+
- Only when the report could not attribute the file, judge by appearance: a version carrying extra bullets, project-specific paths, or filled-in placeholders reads as **Customized**, and one reading like the original toolkit text reads as **Stale**. Mark every verdict reached this way as unverified in the proposal.
|
|
46
58
|
|
|
47
59
|
The user judges intent. The skill makes the judgment legible.
|
|
48
60
|
|
|
@@ -18,9 +18,26 @@ Run these from the target project root, in parallel, before acting:
|
|
|
18
18
|
|
|
19
19
|
Load a domain doc with `aitk docs <topic>` only when the intent touches that domain. Read the live catalog for any domain you act on with `aitk <domain> list --json`. Never hardcode stack, rule, snippet, or standards names.
|
|
20
20
|
|
|
21
|
+
## Diagnose
|
|
22
|
+
|
|
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
|
+
|
|
25
|
+
Read five fields off the report and carry each to `## Route`:
|
|
26
|
+
|
|
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
|
+
- `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
|
+
- `seeds`: entries are `matching`, `stale`, `drifted`, or `missing`. Anything but `matching` needs the seed handoff
|
|
30
|
+
- `domains[].entries`: per-file `stale`, `customized`, `stranded`, and `orphaned` as before
|
|
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
|
+
|
|
33
|
+
State what the report found in one line per finding before acting on any of it.
|
|
34
|
+
|
|
21
35
|
## Route
|
|
22
36
|
|
|
23
|
-
Map the stated intent to one lifecycle phase, then act:
|
|
37
|
+
Map the stated intent, or what `## Diagnose` found, to one lifecycle phase, then act:
|
|
38
|
+
|
|
39
|
+
- A domain in `unmigrated`: hand off to `migration-standards`
|
|
40
|
+
- Anything in `superseded`: tell the user which files and what replaced them. Do not move or delete them
|
|
24
41
|
|
|
25
42
|
- First-time scaffold of a fresh project: hand off to `setup-init`
|
|
26
43
|
- Governance rules for the project stack: hand off to `setup-gov`
|
|
@@ -117,6 +117,52 @@ commits touching its own source path. The `covers` field names the domains a
|
|
|
117
117
|
target has actually stamped, so a domain that was never stamped is legible
|
|
118
118
|
rather than reading as a clean one.
|
|
119
119
|
|
|
120
|
+
### Surfaces reported beside the domains
|
|
121
|
+
|
|
122
|
+
Three sections sit outside the per-domain scan, because each names something
|
|
123
|
+
that walk cannot see. None of them produces a change, and no sync command
|
|
124
|
+
applies any of them.
|
|
125
|
+
|
|
126
|
+
All three report only against a toolkit-managed target, which is one carrying a
|
|
127
|
+
`.claude/` directory, a `CLAUDE.md`, or a domain still at the root layout. The
|
|
128
|
+
report says so through `managed` in the JSON and routes an unmanaged directory to
|
|
129
|
+
`aitk init`. Seeds are why the gate exists, since they enumerate from the toolkit
|
|
130
|
+
source rather than from what a target installed, so an unmanaged directory would
|
|
131
|
+
otherwise report every seed as `missing`.
|
|
132
|
+
|
|
133
|
+
A root-layout domain counts as a marker on its own, because the detection fires
|
|
134
|
+
only on root files the toolkit ships and a project in the old layout is one the
|
|
135
|
+
toolkit installed. When `managed` is false every section comes back empty rather
|
|
136
|
+
than the render alone going quiet, so a consumer reading `--json` never acts on a
|
|
137
|
+
finding the rendered half withheld.
|
|
138
|
+
|
|
139
|
+
`seeds` classifies every seed the toolkit ships against the target's copy, as
|
|
140
|
+
`matching`, `stale`, `drifted`, or `missing`. `missing` has no per-domain
|
|
141
|
+
equivalent, since the domain walk lists what a target installed and cannot see a
|
|
142
|
+
file that never arrived. There is no `customized` verdict here, because that one
|
|
143
|
+
needs a stamp and seeds carry none, so a file history cannot attribute stays
|
|
144
|
+
`drifted`. Reconcile the section with `claude-seed-sync`, which merges one
|
|
145
|
+
section at a time rather than replacing a file the project edits.
|
|
146
|
+
|
|
147
|
+
`superseded` names a file a newer seed folder replaced, such as `.claude/TASKS.md`
|
|
148
|
+
against the `.claude/tasks/` that now ships. The entry carries `replacedBy` and
|
|
149
|
+
nothing else, and the file is never deleted, since the content belongs to the
|
|
150
|
+
project and only its author can decide where it moves. The list derives from the
|
|
151
|
+
seed tree rather than from a fixed set of filenames, so a folder added later is
|
|
152
|
+
covered without a code change. Only an exact stem matches, which leaves a
|
|
153
|
+
suffixed variant such as `TASKS-ARCHIVE.md` unreported.
|
|
154
|
+
|
|
155
|
+
`unmigrated` names a domain sitting at the root layout an older toolkit installed
|
|
156
|
+
to, with nothing at the path the current one reads. It carries `rootPath`,
|
|
157
|
+
`installPath`, and a file count. Without it a project holding `standards/` at its
|
|
158
|
+
root reports zero entries for that domain and reads as clean, which is the most
|
|
159
|
+
misleading state the report can produce. Route it to `migration-standards`.
|
|
160
|
+
|
|
161
|
+
`unmigrated` counts toward `--exit-code`, since running the relocation closes it.
|
|
162
|
+
`superseded` and every seed state are excluded, for the reason `orphaned` already
|
|
163
|
+
is: only the user can move content they wrote, so failing a job on it leaves the
|
|
164
|
+
job red with no mechanical remedy.
|
|
165
|
+
|
|
120
166
|
## Bootstrap
|
|
121
167
|
|
|
122
168
|
`aitk init` installs up to six core domains and reports each one independently. A
|
package/docs/target-projects.md
CHANGED
|
@@ -12,7 +12,7 @@ This doc stays at the narrative layer. For command flags and JSON shapes, see [a
|
|
|
12
12
|
|
|
13
13
|
## Getting the skills
|
|
14
14
|
|
|
15
|
-
The skills reach a session through a marketplace install, once per machine. Every session on that machine carries them afterward, and
|
|
15
|
+
The skills reach a session through a marketplace install, once per machine. Every session on that machine carries them afterward, and the installed copy stays at the version it was installed at. Claude Code ships auto-update off for third-party marketplaces, so neither a push nor a release reaches that copy until someone refreshes it with `claude plugin marketplace update aitk` followed by `claude plugin update aitk@aitk`, or turns auto-update on once under `/plugin`. The `aitk` CLI moves on its own schedule through the registry, so a machine can hold current skills against a stale CLI or the reverse.
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
18
|
claude plugin marketplace add https://github.com/erclx/aitk
|
|
@@ -104,7 +104,11 @@ 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
|
+
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`.
|
|
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.
|
|
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 or seed drift, since both need the user to move content only they can place. An unmigrated domain does count, because running the relocation closes it.
|
|
108
112
|
|
|
109
113
|
Tooling is not covered by the stamp. Reconcile those configs with `aitk tooling <stack> <path>`.
|
|
110
114
|
|
package/package.json
CHANGED
package/src/claude/seeds.ts
CHANGED
|
@@ -7,7 +7,12 @@ const SEEDS_DIR = join('tooling', 'claude', 'seeds')
|
|
|
7
7
|
const CLAUDE_DIR = '.claude'
|
|
8
8
|
const CLAUDE_MD = 'CLAUDE.md'
|
|
9
9
|
const HOOKS = 'hooks'
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Seed subdirectories under `.claude/`. Exported because each one replaced a
|
|
12
|
+
* single file of the same stem in an older layout, which is what
|
|
13
|
+
* `@/sync/layout` pairs a target against to find a superseded artifact.
|
|
14
|
+
*/
|
|
15
|
+
export const SUBDIRS: readonly string[] = [
|
|
11
16
|
HOOKS,
|
|
12
17
|
'context',
|
|
13
18
|
'diagrams',
|
package/src/cli.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
3
5
|
import { Command } from 'commander'
|
|
4
6
|
import { register as init } from '@/commands/init'
|
|
5
7
|
import { register as sandbox } from '@/commands/sandbox'
|
|
@@ -20,6 +22,7 @@ import { register as transcripts } from '@/commands/transcripts'
|
|
|
20
22
|
import { register as tasks } from '@/commands/tasks'
|
|
21
23
|
import { register as comments } from '@/commands/comments'
|
|
22
24
|
import { register as context } from '@/commands/context'
|
|
25
|
+
import { PROJECT_ROOT } from '@/exec'
|
|
23
26
|
|
|
24
27
|
const GREY = '\x1b[0;90m'
|
|
25
28
|
const WHITE = '\x1b[1;37m'
|
|
@@ -87,10 +90,26 @@ function showHelp(): void {
|
|
|
87
90
|
console.log(lines.join('\n'))
|
|
88
91
|
}
|
|
89
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Read at runtime rather than inlined, because a literal here is a second place
|
|
95
|
+
* the version lives and it stopped tracking `package.json` at `0.1.0`. The
|
|
96
|
+
* release tool writes one file and this follows it. `package.json` ships in
|
|
97
|
+
* every npm tarball regardless of the `files` list, so the read resolves from a
|
|
98
|
+
* registry install as well as from a clone.
|
|
99
|
+
*/
|
|
100
|
+
function readVersion(): string {
|
|
101
|
+
try {
|
|
102
|
+
const raw = readFileSync(join(PROJECT_ROOT, 'package.json'), 'utf8')
|
|
103
|
+
return (JSON.parse(raw) as { version?: string }).version ?? 'unknown'
|
|
104
|
+
} catch {
|
|
105
|
+
return 'unknown'
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
90
109
|
const program = new Command()
|
|
91
110
|
program
|
|
92
111
|
.name('aitk')
|
|
93
|
-
.version(
|
|
112
|
+
.version(readVersion())
|
|
94
113
|
.enablePositionalOptions()
|
|
95
114
|
.helpOption(false)
|
|
96
115
|
program.action(() => showHelp())
|
package/src/commands/sync.ts
CHANGED
|
@@ -95,6 +95,16 @@ async function runCheck(target: string, options: SyncOptions): Promise<number> {
|
|
|
95
95
|
function renderCheck(report: CheckReport): void {
|
|
96
96
|
intro('aitk sync --check')
|
|
97
97
|
|
|
98
|
+
if (!report.managed) {
|
|
99
|
+
logStep('Not a toolkit project')
|
|
100
|
+
logWarn('No .claude/ directory and no CLAUDE.md at the target.')
|
|
101
|
+
logInfo(
|
|
102
|
+
'Run `aitk init` to install, or /aitk:setup-init to resolve a stack.',
|
|
103
|
+
)
|
|
104
|
+
outro()
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
98
108
|
for (const domain of report.domains) {
|
|
99
109
|
logStep(domain.domain)
|
|
100
110
|
|
|
@@ -123,14 +133,33 @@ function renderCheck(report: CheckReport): void {
|
|
|
123
133
|
}
|
|
124
134
|
}
|
|
125
135
|
|
|
136
|
+
for (const entry of report.unmigrated) {
|
|
137
|
+
logStep(`${entry.domain} (not migrated)`)
|
|
138
|
+
logWarn(
|
|
139
|
+
`${entry.files} files at ${entry.rootPath}/, nothing at ${entry.installPath}/`,
|
|
140
|
+
)
|
|
141
|
+
logInfo('Run /aitk:migration-standards to relocate them.')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
renderSeeds(report)
|
|
145
|
+
|
|
146
|
+
if (report.superseded.length > 0) {
|
|
147
|
+
logStep('Superseded by a newer layout')
|
|
148
|
+
for (const entry of report.superseded) {
|
|
149
|
+
logWarn(`${entry.rel} (replaced by ${entry.replacedBy}/)`)
|
|
150
|
+
}
|
|
151
|
+
logInfo('Move the content yourself. No sync command touches these.')
|
|
152
|
+
}
|
|
153
|
+
|
|
126
154
|
if (report.newSkills.length > 0) {
|
|
127
155
|
logStep('New skills, no sync needed')
|
|
128
156
|
for (const name of report.newSkills) logInfo(name)
|
|
129
157
|
}
|
|
130
158
|
|
|
131
159
|
outro()
|
|
160
|
+
const unmigrated = report.unmigrated.map((entry) => entry.domain)
|
|
132
161
|
const uncovered = STAMP_DOMAINS.filter(
|
|
133
|
-
(domain) => !report.covers.includes(domain),
|
|
162
|
+
(domain) => !report.covers.includes(domain) && !unmigrated.includes(domain),
|
|
134
163
|
)
|
|
135
164
|
const unstamped =
|
|
136
165
|
uncovered.length === 0 ? '' : `Unstamped: ${uncovered.join(', ')}. `
|
|
@@ -139,6 +168,30 @@ function renderCheck(report: CheckReport): void {
|
|
|
139
168
|
)
|
|
140
169
|
}
|
|
141
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Seeds print their own section because no sync command applies them. A `stale`
|
|
173
|
+
* seed is safe to take whole and a `drifted` one holds edits, which is the split
|
|
174
|
+
* `claude-seed-sync` reads to decide what needs a section-level merge.
|
|
175
|
+
*/
|
|
176
|
+
function renderSeeds(report: CheckReport): void {
|
|
177
|
+
const notable = report.seeds.entries.filter(
|
|
178
|
+
(entry) => entry.state !== 'matching',
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if (notable.length === 0) return
|
|
182
|
+
|
|
183
|
+
logStep('seeds')
|
|
184
|
+
if (report.seeds.historyUnavailable) {
|
|
185
|
+
logWarn('This toolkit has no git history. Drift below is unattributed.')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const entry of notable) {
|
|
189
|
+
logWarn(`${entry.rel} (${entry.state})`)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
logInfo('Run /aitk:claude-seed-sync to reconcile these section by section.')
|
|
193
|
+
}
|
|
194
|
+
|
|
142
195
|
async function runSync(target: string): Promise<number> {
|
|
143
196
|
intro('aitk sync')
|
|
144
197
|
|
package/src/sync/check.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
1
2
|
import { join } from 'node:path'
|
|
2
3
|
import { execa } from 'execa'
|
|
3
4
|
import { createGovAdapter } from '@/gov/adapter'
|
|
4
5
|
import { createSnippetsAdapter } from '@/snippets/adapter'
|
|
5
6
|
import { planSync, type ScanEntry, type SyncAdapter } from '@/sync/engine'
|
|
7
|
+
import {
|
|
8
|
+
collectSuperseded,
|
|
9
|
+
detectUnmigrated,
|
|
10
|
+
type SupersededEntry,
|
|
11
|
+
type UnmigratedDomain,
|
|
12
|
+
} from '@/sync/layout'
|
|
13
|
+
import { buildSeedsReport, type SeedsReport } from '@/sync/seeds-report'
|
|
6
14
|
import {
|
|
7
15
|
readStamp,
|
|
8
16
|
STAMP_DOMAINS,
|
|
@@ -68,7 +76,16 @@ export interface UpstreamCommit {
|
|
|
68
76
|
|
|
69
77
|
export interface CheckReport {
|
|
70
78
|
readonly covers: readonly StampDomain[]
|
|
79
|
+
/** False when the target is not a toolkit project, so every section stays empty. */
|
|
80
|
+
readonly managed: boolean
|
|
71
81
|
readonly domains: readonly DomainReport[]
|
|
82
|
+
/**
|
|
83
|
+
* Reported beside the domains rather than as one of them, because seeds carry
|
|
84
|
+
* no stamp and produce no change. See `@/sync/seeds-report`.
|
|
85
|
+
*/
|
|
86
|
+
readonly seeds: SeedsReport
|
|
87
|
+
readonly superseded: readonly SupersededEntry[]
|
|
88
|
+
readonly unmigrated: readonly UnmigratedDomain[]
|
|
72
89
|
readonly newSkills: readonly string[]
|
|
73
90
|
}
|
|
74
91
|
|
|
@@ -78,6 +95,33 @@ export function installedStampDomains(target: string): StampDomain[] {
|
|
|
78
95
|
)
|
|
79
96
|
}
|
|
80
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Whether the target is a toolkit-managed project at all. Seeds are enumerated
|
|
100
|
+
* from the source rather than from what a target installed, so without this gate
|
|
101
|
+
* a directory the toolkit has never touched reports every seed as `missing` and
|
|
102
|
+
* routes to a skill that reconciles section by section. `installedStampDomains`
|
|
103
|
+
* gates the three scanned domains the same way, which is why they stay quiet on
|
|
104
|
+
* the same directory.
|
|
105
|
+
*
|
|
106
|
+
* An unmigrated domain counts as a marker in its own right. `detectUnmigrated`
|
|
107
|
+
* fires only on root files whose basename the toolkit ships, so it firing proves
|
|
108
|
+
* the toolkit installed here before the layout moved under `.claude/`. Reading
|
|
109
|
+
* only the markers would report such a target as unmanaged while the same report
|
|
110
|
+
* carried its unmigrated domain, and a consumer reading the JSON would route to
|
|
111
|
+
* the relocation while the rendered half routed to install.
|
|
112
|
+
*/
|
|
113
|
+
export function isManagedTarget(
|
|
114
|
+
target: string,
|
|
115
|
+
unmigrated: readonly UnmigratedDomain[],
|
|
116
|
+
): boolean {
|
|
117
|
+
if (unmigrated.length > 0) return true
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
isDirectory(join(target, '.claude')) ||
|
|
121
|
+
existsSync(join(target, 'CLAUDE.md'))
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
81
125
|
export function countStates(entries: readonly ScanEntry[]): StateCounts {
|
|
82
126
|
return {
|
|
83
127
|
matching: count(entries, 'matching'),
|
|
@@ -93,8 +137,16 @@ export function countStates(entries: readonly ScanEntry[]): StateCounts {
|
|
|
93
137
|
* Whether the target has diverged from the toolkit in a way a sync could close.
|
|
94
138
|
* Orphaned files are excluded: a project-authored rule never converges, and
|
|
95
139
|
* counting it would leave `--exit-code` failing forever with no remedy.
|
|
140
|
+
*
|
|
141
|
+
* An unmigrated domain counts, because running the relocation closes it. A
|
|
142
|
+
* superseded artifact does not, for the same reason orphaned files do not: only
|
|
143
|
+
* the user can move content they wrote, so failing a job on it leaves the job
|
|
144
|
+
* red with no mechanical remedy. Seeds are excluded on the same grounds, since
|
|
145
|
+
* every seed a project edits would otherwise fail the check forever.
|
|
96
146
|
*/
|
|
97
147
|
export function hasDrift(report: CheckReport): boolean {
|
|
148
|
+
if (report.unmigrated.length > 0) return true
|
|
149
|
+
|
|
98
150
|
return report.domains.some(
|
|
99
151
|
(domain) =>
|
|
100
152
|
domain.counts.stale +
|
|
@@ -126,9 +178,28 @@ export async function buildCheckReport(
|
|
|
126
178
|
.map((domain) => domain.commit)
|
|
127
179
|
.filter((commit): commit is string => commit !== undefined)
|
|
128
180
|
|
|
181
|
+
const unmigrated = detectUnmigrated(toolkitRoot, target)
|
|
182
|
+
const managed = isManagedTarget(target, unmigrated)
|
|
183
|
+
|
|
184
|
+
if (!managed) {
|
|
185
|
+
return {
|
|
186
|
+
covers: [],
|
|
187
|
+
managed,
|
|
188
|
+
domains: [],
|
|
189
|
+
seeds: { entries: [], historyUnavailable: false },
|
|
190
|
+
superseded: [],
|
|
191
|
+
unmigrated: [],
|
|
192
|
+
newSkills: [],
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
129
196
|
return {
|
|
130
197
|
covers: stamp?.covers ?? [],
|
|
198
|
+
managed,
|
|
131
199
|
domains,
|
|
200
|
+
seeds: buildSeedsReport(toolkitRoot, target),
|
|
201
|
+
superseded: collectSuperseded(target),
|
|
202
|
+
unmigrated,
|
|
132
203
|
newSkills: await readNewSkills(toolkitRoot, anchors),
|
|
133
204
|
}
|
|
134
205
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs'
|
|
2
|
+
import { basename, join } from 'node:path'
|
|
3
|
+
import { SUBDIRS } from '@/claude/seeds'
|
|
4
|
+
import { snippetsSourceDir } from '@/snippets/categories'
|
|
5
|
+
import { standardsSourceDir } from '@/standards/adapter'
|
|
6
|
+
import type { StampDomain } from '@/sync/stamp'
|
|
7
|
+
|
|
8
|
+
const CLAUDE_DIR = '.claude'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Domains an older toolkit installed at the project root, each with the source
|
|
12
|
+
* folder naming what it owns. Governance is absent because its rules have always
|
|
13
|
+
* landed under `.claude/rules/`, so there is no earlier location to be stranded
|
|
14
|
+
* at.
|
|
15
|
+
*
|
|
16
|
+
* A tuple array rather than a partial record, so the domain key stays typed
|
|
17
|
+
* without asserting an `Object.entries` result back into the union.
|
|
18
|
+
*/
|
|
19
|
+
const ROOT_LAYOUTS: readonly (readonly [
|
|
20
|
+
StampDomain,
|
|
21
|
+
string,
|
|
22
|
+
(root: string) => string,
|
|
23
|
+
])[] = [
|
|
24
|
+
['standards', 'standards', standardsSourceDir],
|
|
25
|
+
['snippets', 'snippets', snippetsSourceDir],
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A target file that a shipped seed folder replaced. Carries no source and
|
|
30
|
+
* queues no change, because the file holds content the project wrote and only
|
|
31
|
+
* the user can decide where it moves.
|
|
32
|
+
*/
|
|
33
|
+
export interface SupersededEntry {
|
|
34
|
+
readonly rel: string
|
|
35
|
+
readonly replacedBy: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A domain whose files sit at the root layout an older toolkit installed to,
|
|
40
|
+
* with nothing at the path the current one reads. Distinct from a domain that
|
|
41
|
+
* was never installed, which has neither.
|
|
42
|
+
*/
|
|
43
|
+
export interface UnmigratedDomain {
|
|
44
|
+
readonly domain: StampDomain
|
|
45
|
+
readonly rootPath: string
|
|
46
|
+
readonly installPath: string
|
|
47
|
+
/** Root files the toolkit ships under this domain, not every file present. */
|
|
48
|
+
readonly files: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Pairs each seed subdirectory against an uppercase-stem sibling in the target,
|
|
53
|
+
* so a project still holding `.claude/TASKS.md` is reported against the
|
|
54
|
+
* `.claude/tasks/` folder that replaced it.
|
|
55
|
+
*
|
|
56
|
+
* Deriving from the seed tree rather than from a fixed list means a folder
|
|
57
|
+
* added later is covered without editing this file. The cost is that only an
|
|
58
|
+
* exact stem matches, so a suffixed variant such as `TASKS-ARCHIVE.md` is not
|
|
59
|
+
* reported.
|
|
60
|
+
*/
|
|
61
|
+
export function collectSuperseded(target: string): SupersededEntry[] {
|
|
62
|
+
const entries: SupersededEntry[] = []
|
|
63
|
+
|
|
64
|
+
for (const subdir of SUBDIRS) {
|
|
65
|
+
const rel = join(CLAUDE_DIR, `${subdir.toUpperCase()}.md`)
|
|
66
|
+
if (!isFile(join(target, rel))) continue
|
|
67
|
+
|
|
68
|
+
entries.push({ rel, replacedBy: join(CLAUDE_DIR, subdir) })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return entries
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Domains the target holds at the root rather than under `.claude/`. Reported
|
|
76
|
+
* separately from the per-domain scan because that scan lists only domains it
|
|
77
|
+
* finds installed, so an unmigrated project would otherwise read as one that
|
|
78
|
+
* never installed the domain at all.
|
|
79
|
+
*
|
|
80
|
+
* A root folder is claimed only when it holds a file the toolkit ships under
|
|
81
|
+
* that domain. Presence of the folder alone is not evidence: a project can
|
|
82
|
+
* carry its own `standards/` of project docs and never have installed the
|
|
83
|
+
* domain, and calling that unmigrated would fail `--exit-code` with no action
|
|
84
|
+
* that clears it.
|
|
85
|
+
*/
|
|
86
|
+
export function detectUnmigrated(
|
|
87
|
+
toolkitRoot: string,
|
|
88
|
+
target: string,
|
|
89
|
+
): UnmigratedDomain[] {
|
|
90
|
+
const found: UnmigratedDomain[] = []
|
|
91
|
+
|
|
92
|
+
for (const [domain, rootPath, sourceDir] of ROOT_LAYOUTS) {
|
|
93
|
+
const installPath = join(CLAUDE_DIR, rootPath)
|
|
94
|
+
if (isDirectoryWithFiles(join(target, installPath))) continue
|
|
95
|
+
|
|
96
|
+
const files = countToolkitOwned(
|
|
97
|
+
join(target, rootPath),
|
|
98
|
+
sourceDir(toolkitRoot),
|
|
99
|
+
)
|
|
100
|
+
if (files === 0) continue
|
|
101
|
+
|
|
102
|
+
found.push({ domain, rootPath, installPath, files })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return found
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Root files whose basename matches something the toolkit ships for this domain.
|
|
110
|
+
* Basenames rather than relative paths, because standards install flat while
|
|
111
|
+
* snippets nest by category, and the question here is only whether any file is
|
|
112
|
+
* toolkit-owned rather than which source each one came from.
|
|
113
|
+
*/
|
|
114
|
+
function countToolkitOwned(dir: string, sourceDir: string): number {
|
|
115
|
+
const owned = new Set(listMarkdown(sourceDir).map((rel) => basename(rel)))
|
|
116
|
+
if (owned.size === 0) return 0
|
|
117
|
+
|
|
118
|
+
return listMarkdown(dir).filter((rel) => owned.has(basename(rel))).length
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isFile(path: string): boolean {
|
|
122
|
+
return existsSync(path) && statSync(path).isFile()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isDirectoryWithFiles(path: string): boolean {
|
|
126
|
+
return listMarkdown(path).length > 0
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function listMarkdown(dir: string): string[] {
|
|
130
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) return []
|
|
131
|
+
|
|
132
|
+
return [
|
|
133
|
+
...new Bun.Glob('**/*.md').scanSync({
|
|
134
|
+
cwd: dir,
|
|
135
|
+
onlyFiles: true,
|
|
136
|
+
dot: true,
|
|
137
|
+
}),
|
|
138
|
+
]
|
|
139
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { relative } from 'node:path'
|
|
3
|
+
import { planSeeds, type Seed } from '@/claude/seeds'
|
|
4
|
+
import { findInstalledOrigin, readHistoryIndex } from '@/sync/history'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* How an installed seed compares to the seed the toolkit currently ships.
|
|
8
|
+
*
|
|
9
|
+
* `missing` has no counterpart in the domain scan, which walks what a target
|
|
10
|
+
* installed and so cannot see a file that never arrived. Seeds are enumerated
|
|
11
|
+
* from the source instead, which is what makes the absence legible.
|
|
12
|
+
*
|
|
13
|
+
* There is no `customized` here. That verdict needs a stamp, and seeds carry
|
|
14
|
+
* none, so a file history cannot attribute stays `drifted` and a consumer reads
|
|
15
|
+
* it as the local edit it almost always is.
|
|
16
|
+
*/
|
|
17
|
+
export type SeedState = 'matching' | 'stale' | 'drifted' | 'missing'
|
|
18
|
+
|
|
19
|
+
export interface SeedReportEntry {
|
|
20
|
+
readonly state: SeedState
|
|
21
|
+
readonly rel: string
|
|
22
|
+
/** Toolkit revision this file's content came from, when history proved it. */
|
|
23
|
+
readonly since?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SeedsReport {
|
|
27
|
+
readonly entries: readonly SeedReportEntry[]
|
|
28
|
+
/** Set when a file needed history to attribute it and this toolkit has none. */
|
|
29
|
+
readonly historyUnavailable: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Classifies every seed the toolkit ships against the target's copy, and never
|
|
34
|
+
* returns a change. Seeds are copy-once files a project is expected to edit, so
|
|
35
|
+
* the engine's copy path would overwrite `CLAUDE.md` wholesale. Reporting alone
|
|
36
|
+
* is what lets `claude-seed-sync` merge one section at a time instead.
|
|
37
|
+
*
|
|
38
|
+
* Attribution reuses the history reader rather than the engine's own recovery
|
|
39
|
+
* pass, which is private and takes a `SyncAdapter` seeds have no way to supply.
|
|
40
|
+
*/
|
|
41
|
+
export function buildSeedsReport(
|
|
42
|
+
toolkitRoot: string,
|
|
43
|
+
target: string,
|
|
44
|
+
): SeedsReport {
|
|
45
|
+
const differing: DifferingSeed[] = []
|
|
46
|
+
const entries: SeedReportEntry[] = []
|
|
47
|
+
|
|
48
|
+
for (const { seed, present } of planSeeds(toolkitRoot, target)) {
|
|
49
|
+
const rel = relative(target, seed.dest)
|
|
50
|
+
|
|
51
|
+
if (!present) {
|
|
52
|
+
entries.push({ state: 'missing', rel })
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (sameContent(seed.src, seed.dest)) {
|
|
57
|
+
entries.push({ state: 'matching', rel })
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
differing.push({ index: entries.length, seed })
|
|
62
|
+
entries.push({ state: 'drifted', rel })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const historyUnavailable = attribute(toolkitRoot, entries, differing)
|
|
66
|
+
|
|
67
|
+
return { entries, historyUnavailable }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface DifferingSeed {
|
|
71
|
+
readonly index: number
|
|
72
|
+
readonly seed: Seed
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Second pass over the seeds that differ, matching installed content against
|
|
77
|
+
* every version the toolkit ever published. A match proves the file is
|
|
78
|
+
* untouched since it landed, so the toolkit is what moved and the entry becomes
|
|
79
|
+
* `stale`. Runs as one git call for the whole set rather than one per file.
|
|
80
|
+
*/
|
|
81
|
+
function attribute(
|
|
82
|
+
toolkitRoot: string,
|
|
83
|
+
entries: SeedReportEntry[],
|
|
84
|
+
differing: readonly DifferingSeed[],
|
|
85
|
+
): boolean {
|
|
86
|
+
if (differing.length === 0) return false
|
|
87
|
+
|
|
88
|
+
const index = readHistoryIndex(
|
|
89
|
+
toolkitRoot,
|
|
90
|
+
differing.map((file) => relative(toolkitRoot, file.seed.src)),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if (index === undefined) return true
|
|
94
|
+
|
|
95
|
+
for (const file of differing) {
|
|
96
|
+
const since = findInstalledOrigin(
|
|
97
|
+
index,
|
|
98
|
+
relative(toolkitRoot, file.seed.src),
|
|
99
|
+
file.seed.dest,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if (since === undefined) continue
|
|
103
|
+
entries[file.index] = { ...entries[file.index], state: 'stale', since }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function sameContent(source: string, dest: string): boolean {
|
|
110
|
+
return readFileSync(source).equals(readFileSync(dest))
|
|
111
|
+
}
|