@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 CHANGED
@@ -21,7 +21,7 @@ claude plugin install aitk@aitk
21
21
 
22
22
  ![Adding the aitk marketplace and installing the plugin in Claude Code](assets/install.png)
23
23
 
24
- The skills land as `/aitk:<name>`. If your session was already open, run `/reload-plugins` to pick them up. Updates arrive on release, so a push to this repo won't reach your installed copy.
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.
@@ -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.45.0",
4
+ "version": "0.46.1",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -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 both in parallel from the project root:
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
- - If the target version looks customized (extra bullets, project-specific paths, filled-in placeholders), call it out as **Customized**. Default action: skip, record in the scope table only, never numbered.
45
- - If the target version looks like the original toolkit version with the toolkit having moved on, call it out as **Stale**. Default action: propose update.
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
@@ -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 updates arrive on release, so an upstream push does not reach an installed copy.
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
- 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.
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "0.45.0",
4
+ "version": "0.46.1",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -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
- const SUBDIRS: readonly string[] = [
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('0.1.0')
112
+ .version(readVersion())
94
113
  .enablePositionalOptions()
95
114
  .helpOption(false)
96
115
  program.action(() => showHelp())
@@ -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
+ }