@erclx/aitk 3.19.0 → 3.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "3.19.0",
4
+ "version": "3.21.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -1,39 +1,41 @@
1
1
  ---
2
2
  name: create-rule
3
- description: Why a project-local rule needs a band, a number checked against two catalogs, and a scope key that decides when it fires
3
+ description: Why a project-local rule needs a subdir, a number from the band reserved for one, and a scope key that decides when it fires
4
4
  ---
5
5
 
6
6
  # Create rule requirement
7
7
 
8
8
  ## Gap
9
9
 
10
- Without this skill, a project rule is written straight into the rules folder on a number already taken, so two rules collide and one loses. The subtler collision was with the toolkit itself: a number free in the target today could be the number a shipped rule landed on tomorrow, and the next install double-booked it, so a rule the project wrote got overwritten by one it never chose. Writing under `.claude/rules/project/` closes that half regardless of the number, since the sync engine orphans the file by location before it ever compares names, but the band still needs a free number to stay readable against the toolkit's own numbering.
10
+ Without this skill, a project rule is written straight into the rules folder on a number already taken, so two rules collide and one loses. The subtler collision was with the toolkit itself: a number free in the target today could be the number a shipped rule landed on tomorrow, and the next install double-booked it, so a rule the project wrote got overwritten by one it never chose. Writing under `.claude/rules/project/` closes that half regardless of the number, since the sync engine orphans the file by location before it ever compares names.
11
11
 
12
- The band gets picked by feel, so a UI copy rule lands in the always-on range and loads on every session for the rest of the project's life. The scope key fails in both directions. An always-on rule carrying a path scope fires only on files it was never about, and a path-scoped rule missing it loads constantly. Written from memory rather than from the rule standard, the body comes out in a shape the rest of the catalog does not share.
12
+ Numbering in `900-999` closes the other half. A number scanned against today's toolkit catalog goes stale on the next release, and a reserved band does not.
13
+
14
+ The subdir gets picked by feel, so a UI copy rule lands in the always-on folder and loads on every session for the rest of the project's life. The scope key fails in both directions. An always-on rule carrying a path scope fires only on files it was never about, and a path-scoped rule missing it loads constantly. Written from memory rather than from the rule standard, the body comes out in a shape the rest of the catalog does not share.
13
15
 
14
16
  ## Must
15
17
 
16
18
  - Resolve what the rule enforces and where it applies, asking only for what the request leaves missing
17
- - Pick the band from the topic and take its number range and folder from that choice
19
+ - Pick the subdir from the topic and take the folder from that choice
18
20
  - Write under `.claude/rules/project/`, the subfolder the sync engine reads as project-authored by location
19
- - Check both the target's used prefixes and the toolkit catalog before taking a number
21
+ - Take the lowest free number at or above `900`, scanning every project subdir the target holds rather than one
20
22
  - Read the rule standard before writing the body
21
23
  - Emit the path scope for a path-scoped rule and omit the key entirely for an always-on one
22
- - Preview the resolved path, band, number, and frontmatter, then write without pausing
24
+ - Preview the resolved path, subdir, number, and frontmatter, then write without pausing
23
25
  - Say when the rule loads, since path-scoped and always-on rules behave differently
24
26
 
25
27
  ## Must not
26
28
 
27
29
  - Edit a toolkit source rule, which is authored in the toolkit and would be overwritten here
28
30
  - Work the body shape or the frontmatter from memory
29
- - Take a number without checking the toolkit catalog, which is the collision that surfaces later
31
+ - Take a number below `900`, which a later toolkit release can ship into
30
32
  - Write more than one topic into a single rule
31
33
 
32
34
  ## Guards
33
35
 
34
36
  - No project Claude directory: stop, since there is nowhere for the rule to live
35
37
  - The request names no behavior to enforce: stop rather than inventing one
36
- - The toolkit CLI is not on PATH: scan the target alone and warn that a later install could collide
38
+ - The reserved band holds no free number: stop rather than reaching below `900`
37
39
 
38
40
  ## Out of scope
39
41
 
@@ -5,7 +5,7 @@ description: Scaffolds a project-specific governance rule into `.claude/rules/pr
5
5
 
6
6
  # Create rule
7
7
 
8
- Author a project-local governance rule. The rule lives in the target project, not the toolkit, so `aitk gov sync` never overwrites it: the sync engine orphans anything under `.claude/rules/project/` by location, before it ever checks the rule's name against the toolkit catalog.
8
+ Author a project-local governance rule. The rule lives in the target project, not the toolkit, so `aitk gov sync` never overwrites it: the sync engine orphans anything under `.claude/rules/project/` by location, before it ever checks the rule's name against the toolkit catalog. A rule written anywhere else under `.claude/rules/` survives the sync too, and the report offers it the path under the project subfolder it would take.
9
9
 
10
10
  ## Guards
11
11
 
@@ -19,30 +19,30 @@ Resolve both from the request, and ask only for what is missing. Attach a propos
19
19
  - What the rule enforces: one topic, phrased as a standard (`<topic> conventions`).
20
20
  - Scope: a path glob relative to the project root (`<dir>/**/*.<ext>`) for a path-scoped rule, or always-on when the rule states a global principle with no file scope.
21
21
 
22
- ## Step 2: resolve band and subdir
22
+ ## Step 2: resolve the subdir
23
23
 
24
- Pick the band from the topic. Each band owns a number range and a subdir under `.claude/rules/project/`:
24
+ Pick the subdir from the topic. It sits under `.claude/rules/project/` and names the rule's domain:
25
25
 
26
- - `core/` 000-099: global persona, testing, error handling, planning. Always-on, no `paths:`.
27
- - `lang/` 100-199: one programming language.
28
- - `framework/` 200-299: one framework.
29
- - `lib/` 300-399: one library or tool.
30
- - `ui/` 400-499: UI copy, accessibility, forms.
31
- - `claude/` 500-599: `.claude/` authoring surfaces.
26
+ - `core/`: global persona, testing, error handling, planning. Always-on, no `paths:`.
27
+ - `lang/`: one programming language.
28
+ - `framework/`: one framework.
29
+ - `lib/`: one library or tool.
30
+ - `ui/`: UI copy, accessibility, forms.
31
+ - `claude/`: `.claude/` authoring surfaces.
32
32
 
33
33
  ## Step 3: pick a free number
34
34
 
35
- Pick the lowest unused number in the band that collides with neither the project nor the toolkit catalog:
35
+ A project-authored rule numbers in `900-999`, the band reserved for one. `000-899` belongs to the toolkit, so a number free in the target today is one a later release can ship into.
36
36
 
37
- - Scan the target's `.claude/rules/project/<subdir>/` for used prefixes.
38
- - Run `aitk gov list --json 2>/dev/null` and read the shipped rule numbers in the same range, so the band stays readable against the toolkit's own numbering even though location already keeps a later `aitk gov install` from touching this file.
39
- - If `aitk` is not on PATH, scan the target only and warn that the band could read confusingly against a later toolkit install.
37
+ - Scan every `.claude/rules/project/<subdir>/` in the target for used prefixes, not only the subdir this rule lands in. The band runs as one sequence across all of them.
38
+ - Take the lowest unused number at or above `900`.
39
+ - Stop and say so if the band is full, rather than reaching below `900`.
40
40
 
41
41
  ## Step 4: write the rule
42
42
 
43
43
  Read `${CLAUDE_SKILL_DIR}/../../standards/rule.md` for frontmatter, body shape, and voice before writing the body. Do not work the shape from memory.
44
44
 
45
- Write `.claude/rules/project/<subdir>/<n>-<slug>.md` where `<slug>` is a 1-to-3-word kebab topic. Preview the resolved path, band, number, and frontmatter, then write immediately. The tool permission dialog is the confirmation gate.
45
+ Write `.claude/rules/project/<subdir>/<n>-<slug>.md` where `<slug>` is a 1-to-3-word kebab topic. Preview the resolved path, subdir, number, and frontmatter, then write immediately. The tool permission dialog is the confirmation gate.
46
46
 
47
47
  Frontmatter carries the Claude shape. Path-scoped rules emit one `paths:` entry per glob. Always-on rules omit `paths:` entirely.
48
48
 
@@ -107,13 +107,13 @@ AITK_NON_INTERACTIVE=1 aitk tooling sync vite-react ./frontend --skip base --wri
107
107
  AITK_NON_INTERACTIVE=1 aitk tooling sync python ./backend --skip base --write
108
108
  ```
109
109
 
110
- Without `--skip base`, each subtree re-drops husky, and git honors only one `core.hooksPath`, so the extra hook dirs silently break. Each subtree keeps its own framework configs and its own `.claude/tooling/<stack>.md` audit docs.
110
+ Without `--skip base`, each subtree re-drops husky, and git honors only one `core.hooksPath`, so the extra hook dirs silently break. Each subtree keeps its own framework configs, and its own stack reference reads through `aitk tooling reference <stack>`.
111
111
 
112
112
  Step 3: post-sync fixups. Golden configs arrive from sync, so no config generation is required. But a few items may need a one-time touch:
113
113
 
114
114
  - **ESLint version pin.** If `bun create vite` installed `eslint@^10` and the manifest pins `eslint@^9`, sync does not override a present dep. Run `bun add -d eslint@^9` if `bun run lint:fix` fails with `Class extends value undefined`.
115
115
  - **File naming.** `bun create vite`'s `App.tsx` violates the `KEBAB_CASE` rule. Rename to `app.tsx` and update the import in `main.tsx`.
116
- - **Docs.** Open `<target>/.claude/tooling/<tooling-stack>.md` and `<target>/.claude/tooling/web.md` for any stack-specific follow-ups (Chrome extension overrides, setup script details).
116
+ - **Docs.** Run `aitk tooling reference <tooling-stack>` and `aitk tooling reference web` for any stack-specific follow-ups (Chrome extension overrides, setup script details).
117
117
 
118
118
  Do not generate ESLint, Vitest, or Playwright configs. They ship as golden files. Generating from prose duplicates what sync already installed.
119
119
 
@@ -9,17 +9,17 @@ What each `aitk` sync or install command does to existing files in a target proj
9
9
 
10
10
  ## Overwrite contract
11
11
 
12
- | Surface | Command | Effect on existing files |
13
- | -------------------------------------------------------- | ------------------- | ----------------------------------------------------------------- |
14
- | Golden configs, listed in full below | `aitk tooling sync` | Overwritten once `--write` is passed. Local edits are lost. |
15
- | Dictionary seeds (`.cspell/*.txt`) | `aitk tooling sync` | Merged and sorted. Existing terms preserved. |
16
- | Other seeds (`cspell.json`, `.lintstagedrc`, state docs) | `aitk tooling sync` | Copy-once. Dropped on first install, untouched after. |
17
- | Standards | none | Nothing installs. `aitk standards <name>` reads and never writes. |
18
- | Seed docs and `CLAUDE.md` | `aitk claude init` | Skipped when present. Never overwritten. |
19
- | Seed docs | `aitk claude sync` | Never touched. Only `.gitignore` is written. |
20
- | References (`.claude/tooling/<stack>.md`) | `aitk tooling ref` | Overwritten. |
21
- | `.gitignore`, deps, scripts | any sync | Additive. Existing entries preserved. Deps re-pin on major skew. |
22
- | Generated `index.md` | any sync or regen | Rewritten from target state. Hand edits are lost. |
12
+ | Surface | Command | Effect on existing files |
13
+ | -------------------------------------------------------- | ------------------- | -------------------------------------------------------------------------- |
14
+ | Golden configs, listed in full below | `aitk tooling sync` | Overwritten once `--write` is passed. Local edits are lost. |
15
+ | Dictionary seeds (`.cspell/*.txt`) | `aitk tooling sync` | Merged and sorted. Existing terms preserved. |
16
+ | Other seeds (`cspell.json`, `.lintstagedrc`, state docs) | `aitk tooling sync` | Copy-once. Dropped on first install, untouched after. |
17
+ | Standards | none | Nothing installs. `aitk standards <name>` reads and never writes. |
18
+ | Seed docs and `CLAUDE.md` | `aitk claude init` | Skipped when present. Never overwritten. |
19
+ | Seed docs | `aitk claude sync` | Never touched. Only `.gitignore` is written. |
20
+ | Stack references | none | Nothing installs. `aitk tooling reference <stack>` reads and never writes. |
21
+ | `.gitignore`, deps, scripts | any sync | Additive. Existing entries preserved. Deps re-pin on major skew. |
22
+ | Generated `index.md` | any sync or regen | Rewritten from target state. Hand edits are lost. |
23
23
 
24
24
  ## What a tooling sync can overwrite
25
25
 
@@ -16,6 +16,21 @@ is left alone regardless of its name, since that subfolder is project-authored
16
16
  by location. It also removes a stale `.claude/GOV.md`
17
17
  from the retired build. Use `aitk gov install` to add rules.
18
18
 
19
+ A rule the toolkit finds no source for, sitting in a shared subdirectory rather
20
+ than under `.claude/rules/project/`, is reported with the path under that
21
+ subfolder it would take. The offer is conditional on the project having written
22
+ the rule, since a rule the toolkit shipped and later renamed reaches the same
23
+ line, and moving one there would mark it the project's for good. Nothing is
24
+ moved either way, because a rule's installed path is one the project's own
25
+ rules, skills, and docs may cite. `aitk standards rule` carries the reserved
26
+ number bands behind that placement, where `900-999` is the range a
27
+ project-authored rule takes and everything below it belongs to the toolkit.
28
+
29
+ `aitk snippets sync` reports the same way for a snippet outside
30
+ `.claude/snippets/project/`, since both adapters declare the subfolder.
31
+ `aitk sync --check` does not. It skips every orphaned entry, so the destination
32
+ reaches the two domain sync verbs alone.
33
+
19
34
  When the target's install recorded a stack, `aitk gov sync` also reports a
20
35
  rule that stack lists and `.claude/rules/` does not hold, as a `missing` entry
21
36
  carrying no change. This is what makes a target whose recorded sync point
@@ -45,8 +45,8 @@ Two steps, in order:
45
45
  The chain is:
46
46
 
47
47
  - `aitk init` installs base tooling, Claude seeds, and governance rules into `.claude/rules/` in the same pass
48
- - `aitk tooling sync <stack> --write` adds stack-specific deps, scripts, gitignore entries, and drops `.claude/tooling/<stack>.md` (plus parents) as the agent's audit context
49
- - The agent follows the reference to generate eslint, vitest, playwright configs and the stack's setup script, and extends `.claude/context/ci.md` and `.claude/context/development.md` per the reference's extend sections <!-- audit-ignore-citations -->
48
+ - `aitk tooling sync <stack> --write` adds stack-specific deps, scripts, and gitignore entries
49
+ - The agent reads `aitk tooling reference <stack>` (plus parents) as its audit context, follows it to generate eslint, vitest, playwright configs and the stack's setup script, and extends `.claude/context/ci.md` and `.claude/context/development.md` per the reference's extend sections <!-- audit-ignore-citations -->
50
50
  - `setup-verify` runs the installed `package.json` scripts (lint, typecheck, check, test, build) and reports pass or fail
51
51
 
52
52
  The chain serves a fresh scaffold and names a destination for the three states it does not. An existing project goes to `aitk:toolkit-operator`, which reads what the project already carries before naming a per-domain command. An install wanting the Claude layer without the tooling chain runs `aitk claude init` for the seed docs and then `aitk:setup-indexes` for the index system. A language the toolkit ships no stack for is the one of the three the chain still runs for, on `base`, with the fallback marked in the preview so it can be declined there.
@@ -175,7 +175,7 @@ Reconcile the configs with `aitk tooling sync <stack> <path> --check` to read wh
175
175
 
176
176
  `aitk sync <path>` runs every installed domain's sync in sequence. Safe to run on a cadence.
177
177
 
178
- It never touches user-owned seed files. Governance rules in `.claude/rules/`, tooling configs, and reference docs refresh in place. Stale `.claude/GOV.md` from earlier installs is removed.
178
+ It never touches user-owned seed files. Governance rules in `.claude/rules/` and tooling configs refresh in place. Stale `.claude/GOV.md` from earlier installs is removed.
179
179
 
180
180
  Standards take no part in that run. Nothing installed them, so there is no copy to reconcile and no `aitk standards sync` to reach for.
181
181
 
@@ -184,7 +184,7 @@ Standards take no part in that run. Nothing installed them, so there is no copy
184
184
  - Claude seed docs such as `CLAUDE.md` and `.claude/REQUIREMENTS.md`: invoke `aitk:claude-seed-sync`. The skill splits each file into a preamble (between the H1 and the first H2) plus one part per `##` section, then diffs part by part and proposes per-part edits. User customizations are preserved.
185
185
  - Governance rules already installed: `aitk gov sync <path>` diffs and applies, and never adds new rules. A rule your recorded stack lists reports as `missing` instead.
186
186
  - Tooling configs and seeds: `aitk tooling <stack> <path>` overwrites golden configs and merges seeds
187
- - Reference docs for a stack: `aitk tooling ref <stack> <path>`
187
+ - Reference docs for a stack: `aitk tooling reference <stack>` reads and never writes, so there is nothing to sync
188
188
  - Index regeneration after markdown edits: `aitk indexes regen`
189
189
 
190
190
  Use a targeted entry point when only one surface moved upstream. Use the catch-all when the toolkit lands a bundled release.
@@ -233,7 +233,7 @@ aitk tooling sync vite-react ./frontend --skip base --write
233
233
  aitk tooling sync python ./backend --skip base --write
234
234
  ```
235
235
 
236
- `--skip base` drops the `base` layer from each subtree sync, so husky, prettier, cspell, commitlint, and CI stay single at the repo root. Without it, every subtree re-drops husky, and since git honors only one `core.hooksPath` the extra hook dirs silently break. Each subtree still gets its own framework configs (eslint, vitest, tsconfig, vite) and its own `.claude/tooling/<stack>.md` audit docs.
236
+ `--skip base` drops the `base` layer from each subtree sync, so husky, prettier, cspell, commitlint, and CI stay single at the repo root. Without it, every subtree re-drops husky, and since git honors only one `core.hooksPath` the extra hook dirs silently break. Each subtree still gets its own framework configs (eslint, vitest, tsconfig, vite), and its own stack reference reads through `aitk tooling reference <stack>`.
237
237
 
238
238
  ## Running sync from an agent session
239
239
 
@@ -8,8 +8,10 @@ paths:
8
8
 
9
9
  ## Numbering
10
10
 
11
- - Name a rule `.claude/rules/<subdirectory>/<n>-<slug>.md`, taking a number that collides with neither the project's rules nor any installed shared set.
12
- - Give every rule a numeric prefix and keep the slug to one to three kebab words.
11
+ - Name a rule `.claude/rules/<subdirectory>/<n>-<slug>.md`. Give every rule a numeric prefix and keep the slug to one to three kebab words.
12
+ - Treat `000-899` as reserved for a rule set that ships to targets, `000-599` across the shared subdirectories and `600-899` held for ones it has not added.
13
+ - Number a rule the project authored itself in `900-999` and write it at `.claude/rules/project/<subdirectory>/<n>-<slug>.md`.
14
+ - Do not take a reserved number for a project-authored rule because nothing installed holds it today. A later release can ship into it.
13
15
 
14
16
  ## Body
15
17
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "3.19.0",
4
+ "version": "3.21.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -3,7 +3,6 @@ import type { Command } from 'commander'
3
3
  import { execScript } from '@/exec'
4
4
  import { PROJECT_ROOT } from '@/project-root'
5
5
  import {
6
- applyReferences,
7
6
  injectConfigs,
8
7
  injectGitignore,
9
8
  injectManifest,
@@ -18,12 +17,14 @@ import {
18
17
  resolveChain,
19
18
  stackExists,
20
19
  } from '@/tooling/manifest'
20
+ import { readReference, resolveReference } from '@/tooling/read'
21
21
  import { scan, type ScanResult } from '@/tooling/scan'
22
22
  import { recordToolingChain } from '@/tooling/stamp'
23
23
  import {
24
24
  intro,
25
25
  isNonInteractive,
26
26
  logAdd,
27
+ logError,
27
28
  logInfo,
28
29
  logStep,
29
30
  logWarn,
@@ -32,10 +33,9 @@ import {
32
33
  select,
33
34
  } from '@/ui'
34
35
 
35
- const PASS_THROUGH_VERBS = ['ref', 'create', 'verify'] as const
36
+ const PASS_THROUGH_VERBS = ['create', 'verify'] as const
36
37
 
37
38
  interface SyncOptions {
38
- readonly ref?: boolean
39
39
  readonly skip?: string
40
40
  readonly check?: boolean
41
41
  readonly write?: boolean
@@ -64,7 +64,7 @@ type Prepared =
64
64
  export function register(program: Command): void {
65
65
  const tooling = program
66
66
  .command('tooling')
67
- .description('Manage tooling stacks (sync, ref, create)')
67
+ .description('Manage tooling stacks (sync, reference, create)')
68
68
  .helpOption('-h, --help', 'Show this help message')
69
69
 
70
70
  tooling
@@ -73,7 +73,6 @@ export function register(program: Command): void {
73
73
  .argument('[stack]', 'Tooling stack name (e.g. base, vite-react)')
74
74
  .argument('[target]', 'Target directory', '.')
75
75
  .helpOption('-h, --help', 'Show this help message')
76
- .option('--no-ref', 'Skip dropping reference docs')
77
76
  .option('--skip <stack>', 'Drop a layer from the extends chain')
78
77
  .option('--check', 'Report what would change and write nothing')
79
78
  .option('--write', 'Apply every change without prompting')
@@ -134,6 +133,29 @@ export function register(program: Command): void {
134
133
  process.exitCode = runList(opts)
135
134
  })
136
135
 
136
+ tooling
137
+ .command('reference')
138
+ .description("Print a stack's reference doc")
139
+ .argument('<stack>', 'Tooling stack name (e.g. base, vite-react)')
140
+ .helpOption('-h, --help', 'Show this help message')
141
+ .addHelpText(
142
+ 'after',
143
+ [
144
+ '',
145
+ 'A stack resolves under tooling/ at the working root, then the corpus',
146
+ 'inside the aitk package. No reference installs into a project, so the',
147
+ 'package corpus is what answers there. The frame names the copy it read.',
148
+ '',
149
+ 'Examples:',
150
+ ' aitk tooling reference base',
151
+ ' aitk tooling reference vite-react',
152
+ '',
153
+ ].join('\n'),
154
+ )
155
+ .action((stack: string) => {
156
+ process.exitCode = printReference(stack)
157
+ })
158
+
137
159
  for (const verb of PASS_THROUGH_VERBS) {
138
160
  tooling
139
161
  .command(verb)
@@ -172,6 +194,32 @@ function runList(opts: ListOptions): number {
172
194
  return 0
173
195
  }
174
196
 
197
+ /**
198
+ * Writes the reference to stdout and every frame line to stderr, so a caller
199
+ * capturing the output with `$(...)` receives the document alone. Mirrors
200
+ * `print` in `src/commands/standards.ts`.
201
+ */
202
+ function printReference(stack: string): number {
203
+ intro('aitk tooling reference')
204
+
205
+ const root = process.cwd()
206
+ const resolved = resolveReference(root, stack)
207
+
208
+ if (!resolved) {
209
+ logWarn(`Unknown stack: ${stack}`)
210
+ logStep('Available stacks')
211
+ for (const each of listStacks(PROJECT_ROOT)) logInfo(each)
212
+ logError("Run 'aitk tooling list' for descriptions.")
213
+ outro()
214
+ return 1
215
+ }
216
+
217
+ logStep(resolved.source)
218
+ process.stdout.write(readReference(resolved))
219
+ outro()
220
+ return 0
221
+ }
222
+
175
223
  /**
176
224
  * Resolves the chain and rejects the same inputs the bash rejected, so a bad
177
225
  * stack name fails before anything touches the target.
@@ -248,10 +296,9 @@ async function runSync(
248
296
  return 1
249
297
  }
250
298
 
251
- const includeReferences = opts.ref !== false
252
- const result = scan(prepared.chain, prepared.target, { includeReferences })
299
+ const result = scan(prepared.chain, prepared.target)
253
300
 
254
- report(result, includeReferences)
301
+ report(result)
255
302
 
256
303
  const mode = resolveWriteMode(opts)
257
304
  const { GREEN, NC } = palette(process.stderr)
@@ -290,15 +337,6 @@ async function runSync(
290
337
  await injectSeeds(prepared.chain, prepared.target)
291
338
  await injectManifest(prepared.chain, prepared.target)
292
339
 
293
- const pending = result.references
294
- .filter((entry) => entry.state === 'pending')
295
- .map((entry) => entry.stack)
296
-
297
- if (pending.length > 0) {
298
- logStep('Applying references')
299
- await applyReferences(prepared.chain, prepared.target, pending)
300
- }
301
-
302
340
  await stampChain(prepared.chain, prepared.target)
303
341
 
304
342
  outro()
@@ -448,7 +486,7 @@ async function promptForStack(): Promise<string | undefined> {
448
486
  })
449
487
  }
450
488
 
451
- function report(result: ScanResult, includeReferences: boolean): void {
489
+ function report(result: ScanResult): void {
452
490
  logStep('Scanning configs')
453
491
  for (const entry of result.configs) {
454
492
  if (entry.state === 'matching') logInfo(entry.rel)
@@ -483,16 +521,6 @@ function report(result: ScanResult, includeReferences: boolean): void {
483
521
  for (const entry of result.gitignore) {
484
522
  if (entry.state === 'missing') logAdd(entry.entry)
485
523
  }
486
-
487
- if (!includeReferences) return
488
-
489
- logStep('Scanning references')
490
- for (const entry of result.references) {
491
- if (entry.state === 'matching') logInfo(`.claude/tooling/${entry.stack}.md`)
492
- }
493
- for (const entry of result.references) {
494
- if (entry.state === 'pending') logAdd(`.claude/tooling/${entry.stack}.md`)
495
- }
496
524
  }
497
525
 
498
526
  function reportPackage(result: ScanResult): void {
@@ -540,10 +568,6 @@ function summarize(result: ScanResult): string {
540
568
  result.gitignore.filter((entry) => entry.state === 'missing').length,
541
569
  'gitignore',
542
570
  )
543
- add(
544
- result.references.filter((entry) => entry.state === 'pending').length,
545
- 'refs',
546
- )
547
571
 
548
572
  return parts.join(', ')
549
573
  }
package/src/sync/check.ts CHANGED
@@ -103,7 +103,6 @@ export interface ToolingCounts {
103
103
  readonly scripts: number
104
104
  readonly deps: number
105
105
  readonly gitignore: number
106
- readonly references: number
107
106
  }
108
107
 
109
108
  /**
@@ -134,7 +133,6 @@ const UNMEASURED_TOOLING: ToolingReport = {
134
133
  scripts: 0,
135
134
  deps: 0,
136
135
  gitignore: 0,
137
- references: 0,
138
136
  },
139
137
  changes: 0,
140
138
  }
@@ -219,7 +217,7 @@ export function buildToolingReport(
219
217
 
220
218
  if (manifests.length === 0) return { ...UNMEASURED_TOOLING, chain }
221
219
 
222
- const result = scan(manifests, target, { includeReferences: true })
220
+ const result = scan(manifests, target)
223
221
  const record = stamp?.domains.tooling
224
222
 
225
223
  return {
@@ -236,8 +234,6 @@ export function buildToolingReport(
236
234
  deps: result.deps.filter((entry) => entry.state === 'missing').length,
237
235
  gitignore: result.gitignore.filter((entry) => entry.state === 'missing')
238
236
  .length,
239
- references: result.references.filter((entry) => entry.state === 'pending')
240
- .length,
241
237
  },
242
238
  changes: result.totalChanges,
243
239
  }
@@ -81,9 +81,10 @@ export interface ScanEntry {
81
81
  /** Toolkit revision this file's content came from, when history proved it. */
82
82
  readonly since?: string
83
83
  /**
84
- * Overrides `report`'s generic text for this entry's state. `collectMissing`
85
- * is the one producer: a stack name is only known to the adapter that
86
- * resolved it, and the generic `missing` line cannot carry one.
84
+ * Overrides `report`'s generic text for this entry's state. Two producers
85
+ * write one: `collectMissing`, since a stack name is only known to the
86
+ * adapter that resolved it, and an orphan sitting outside the declared
87
+ * project subfolder, since the generic line names no destination.
87
88
  */
88
89
  readonly notice?: string
89
90
  }
@@ -143,6 +144,9 @@ export interface SyncAdapter {
143
144
  * location rather than by the name inference `locateSource` runs.
144
145
  * Checked before `locateSource`, so a file here is orphaned even when its
145
146
  * name also matches a toolkit source, and never enters the stamp.
147
+ *
148
+ * Declaring it also gives the report a destination to name when the name
149
+ * inference orphans a file sitting anywhere else.
146
150
  */
147
151
  readonly projectSubdir?: string
148
152
  /** Defaults to applying. */
@@ -200,7 +204,7 @@ export function planSync(adapter: SyncAdapter, target: string): SyncPlan {
200
204
  const source = adapter.locateSource(file)
201
205
 
202
206
  if (source === undefined || !existsSync(source)) {
203
- entries.push({ state: 'orphaned', rel: file.rel })
207
+ entries.push(misplacedOrphan(adapter, target, file))
204
208
  continue
205
209
  }
206
210
 
@@ -419,7 +423,8 @@ function report(adapter: SyncAdapter, plan: SyncPlan): void {
419
423
  logWarn(
420
424
  entry.notice ?? `${entry.rel} (listed by the stack, not installed)`,
421
425
  )
422
- else logWarn(`${entry.rel} (not in toolkit source, skipping)`)
426
+ else
427
+ logWarn(entry.notice ?? `${entry.rel} (not in toolkit source, skipping)`)
423
428
  }
424
429
 
425
430
  for (const surface of plan.retired) {
@@ -486,6 +491,43 @@ function isProjectAuthored(adapter: SyncAdapter, file: InstalledFile): boolean {
486
491
  return file.relToRoot.split('/')[0] === adapter.projectSubdir
487
492
  }
488
493
 
494
+ /**
495
+ * An orphan the name inference caught rather than the location test. No source
496
+ * name matched, which is what a project-authored file looks like and also what
497
+ * a file the toolkit shipped and later renamed looks like, so the line offers
498
+ * the destination on a condition rather than asserting who wrote the file.
499
+ * Moving a toolkit leftover into the project subfolder would mark it the
500
+ * project's permanently, and only the operator can tell the two apart.
501
+ *
502
+ * The stamp cannot tell them apart either. `recordStamp` skips a file whose
503
+ * source is gone and `writeStamp` replaces the domain's whole `files` map, so
504
+ * a renamed rule's entry survives exactly one sync past the rename, and a
505
+ * target installed before stamping shipped has no entry to read at all.
506
+ *
507
+ * Naming the destination is all this does. Moving the file rewrites a path the
508
+ * project's own rules, skills, and docs may cite, so the sync leaves it where
509
+ * it is.
510
+ */
511
+ function misplacedOrphan(
512
+ adapter: SyncAdapter,
513
+ target: string,
514
+ file: InstalledFile,
515
+ ): ScanEntry {
516
+ const subdir = adapter.projectSubdir
517
+ if (subdir === undefined) return { state: 'orphaned', rel: file.rel }
518
+
519
+ const belongs = relative(
520
+ target,
521
+ resolve(adapter.installedRoot(target), subdir, file.relToRoot),
522
+ )
523
+
524
+ return {
525
+ state: 'orphaned',
526
+ rel: file.rel,
527
+ notice: `${file.rel} (not in toolkit source, skipping. Move it to ${belongs} if the project authored it.)`,
528
+ }
529
+ }
530
+
489
531
  function isInside(target: string, path: string): boolean {
490
532
  const rel = relative(target, path)
491
533
  return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)
@@ -1,12 +1,5 @@
1
1
  import { existsSync } from 'node:fs'
2
- import {
3
- copyFile,
4
- mkdir,
5
- readFile,
6
- rm,
7
- rmdir,
8
- writeFile,
9
- } from 'node:fs/promises'
2
+ import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
10
3
  import { dirname, join } from 'node:path'
11
4
  import { $ } from 'bun'
12
5
  import { copyPreservingMode } from '@/copy'
@@ -198,33 +191,3 @@ export async function injectManifest(
198
191
 
199
192
  await injectGitignore(chain, target)
200
193
  }
201
-
202
- /**
203
- * Drops reference docs into `.claude/tooling/`, clearing the legacy
204
- * `tooling/` location the earlier layout used.
205
- */
206
- export async function applyReferences(
207
- chain: readonly Manifest[],
208
- target: string,
209
- stacks: readonly string[],
210
- ): Promise<string[]> {
211
- const destDir = join(target, '.claude', 'tooling')
212
- await mkdir(destDir, { recursive: true })
213
-
214
- const applied: string[] = []
215
- const byName = new Map(chain.map((manifest) => [manifest.name, manifest]))
216
-
217
- for (const stack of stacks) {
218
- const manifest = byName.get(stack)
219
- if (!manifest) continue
220
-
221
- await copyFile(manifest.referenceFile, join(destDir, `${stack}.md`))
222
- logAdd(`.claude/tooling/${stack}.md`)
223
- applied.push(stack)
224
- await rm(join(target, 'tooling', `${stack}.md`), { force: true })
225
- }
226
-
227
- await rmdir(join(target, 'tooling')).catch(() => {})
228
-
229
- return applied
230
- }
@@ -14,7 +14,6 @@ export interface Manifest {
14
14
  readonly dir: string
15
15
  readonly configsDir: string
16
16
  readonly seedsDir: string
17
- readonly referenceFile: string
18
17
  readonly scripts: Readonly<Record<string, string>>
19
18
  readonly scriptOverrides: Readonly<Record<string, string>>
20
19
  readonly gitignore: readonly GitignoreSection[]
@@ -100,7 +99,6 @@ export function loadManifest(
100
99
  dir,
101
100
  configsDir: join(dir, 'configs'),
102
101
  seedsDir: join(dir, 'seeds'),
103
- referenceFile: join(dir, 'reference.md'),
104
102
  scripts: pickStrings(scriptsTable),
105
103
  scriptOverrides: pickStrings(asTable(scriptsTable.override)),
106
104
  gitignore: readGitignoreSections(asTable(parsed.gitignore)),
@@ -0,0 +1,65 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { PROJECT_ROOT } from '@/project-root'
4
+
5
+ /**
6
+ * Spells the package root in a report where every other root spells a
7
+ * project-relative path. The `source` field promises a path inside the project
8
+ * and a package copy is the one source that promise cannot cover, so the
9
+ * spelling has to be one nothing will join to a project root.
10
+ */
11
+ const PACKAGE_LABEL = '<aitk>'
12
+
13
+ export interface ReferenceRoot {
14
+ /** Absolute `tooling/` directory to search. */
15
+ readonly dir: string
16
+ /** How a report spells a copy found under this root. */
17
+ readonly label: string
18
+ }
19
+
20
+ export interface ResolvedReference {
21
+ readonly path: string
22
+ /** The label of the root that won, joined to the stack and filename. */
23
+ readonly source: string
24
+ }
25
+
26
+ function toolingSourceDir(root: string): string {
27
+ return join(root, 'tooling')
28
+ }
29
+
30
+ /**
31
+ * The roots a stack reference resolves against, mirroring `standardRoots` in
32
+ * `src/standards/read.ts`. No stack reference installs into a project any
33
+ * more, so the package corpus is the only root that answers there. The
34
+ * authoring root stays ahead of it for the toolkit's own repository, which
35
+ * authors the corpus this reads, and for a project authoring stacks of its
36
+ * own under the same layout.
37
+ */
38
+ export function referenceRoots(root: string): ReferenceRoot[] {
39
+ return [
40
+ { dir: toolingSourceDir(root), label: 'tooling' },
41
+ {
42
+ dir: toolingSourceDir(PROJECT_ROOT),
43
+ label: join(PACKAGE_LABEL, 'tooling'),
44
+ },
45
+ ]
46
+ }
47
+
48
+ /** Resolves a stack's reference doc, working root first. */
49
+ export function resolveReference(
50
+ root: string,
51
+ stack: string,
52
+ ): ResolvedReference | undefined {
53
+ for (const { dir, label } of referenceRoots(root)) {
54
+ const path = join(dir, stack, 'reference.md')
55
+ if (existsSync(path)) {
56
+ return { path, source: join(label, stack, 'reference.md') }
57
+ }
58
+ }
59
+
60
+ return undefined
61
+ }
62
+
63
+ export function readReference(reference: ResolvedReference): string {
64
+ return readFileSync(reference.path, 'utf8')
65
+ }
@@ -26,26 +26,16 @@ export interface EntryState {
26
26
  readonly state: 'missing' | 'present'
27
27
  }
28
28
 
29
- export interface ReferenceState {
30
- readonly stack: string
31
- readonly state: 'pending' | 'matching'
32
- }
33
-
34
29
  export interface ScanResult {
35
30
  readonly configs: readonly ConfigState[]
36
31
  readonly seeds: readonly SeedState[]
37
32
  readonly scripts: readonly ScriptState[]
38
33
  readonly deps: readonly DepState[]
39
34
  readonly gitignore: readonly EntryState[]
40
- readonly references: readonly ReferenceState[]
41
35
  readonly hasPackageJson: boolean
42
36
  readonly totalChanges: number
43
37
  }
44
38
 
45
- export interface ScanOptions {
46
- readonly includeReferences: boolean
47
- }
48
-
49
39
  function isIdentical(a: string, b: string): boolean {
50
40
  if (!existsSync(b)) return false
51
41
  return readFileSync(a).equals(readFileSync(b))
@@ -55,14 +45,10 @@ function isIdentical(a: string, b: string): boolean {
55
45
  * Compares every stack in the chain against the target and reports what would
56
46
  * change. Nothing is written. Which stack wins a duplicate differs per
57
47
  * category and mirrors the bash: configs, seeds, and scripts resolve nearest
58
- * stack first, while dependencies, gitignore entries, and references resolve
59
- * from the furthest ancestor inward.
48
+ * stack first, while dependencies and gitignore entries resolve from the
49
+ * furthest ancestor inward.
60
50
  */
61
- export function scan(
62
- chain: readonly Manifest[],
63
- target: string,
64
- options: ScanOptions,
65
- ): ScanResult {
51
+ export function scan(chain: readonly Manifest[], target: string): ScanResult {
66
52
  const configs: ConfigState[] = []
67
53
  const seenConfigs = new Set<string>()
68
54
 
@@ -109,17 +95,12 @@ export function scan(
109
95
  : ''
110
96
  const gitignore = scanGitignore(chain, gitignoreContent)
111
97
 
112
- const references = options.includeReferences
113
- ? scanReferences(chain, target)
114
- : []
115
-
116
98
  const totalChanges =
117
99
  configs.filter((entry) => entry.state !== 'matching').length +
118
100
  seeds.filter((entry) => entry.state === 'missing').length +
119
101
  scripts.filter((entry) => entry.state !== 'matching').length +
120
102
  deps.filter((entry) => entry.state === 'missing').length +
121
- gitignore.filter((entry) => entry.state === 'missing').length +
122
- references.filter((entry) => entry.state === 'pending').length
103
+ gitignore.filter((entry) => entry.state === 'missing').length
123
104
 
124
105
  return {
125
106
  configs,
@@ -127,7 +108,6 @@ export function scan(
127
108
  scripts,
128
109
  deps,
129
110
  gitignore,
130
- references,
131
111
  hasPackageJson: pkg !== undefined,
132
112
  totalChanges,
133
113
  }
@@ -165,22 +145,3 @@ function scanGitignore(
165
145
 
166
146
  return states
167
147
  }
168
-
169
- function scanReferences(
170
- chain: readonly Manifest[],
171
- target: string,
172
- ): ReferenceState[] {
173
- const states: ReferenceState[] = []
174
-
175
- for (const manifest of ancestorsFirst(chain)) {
176
- if (!existsSync(manifest.referenceFile)) continue
177
-
178
- const dest = join(target, '.claude', 'tooling', `${manifest.name}.md`)
179
- states.push({
180
- stack: manifest.name,
181
- state: isIdentical(manifest.referenceFile, dest) ? 'matching' : 'pending',
182
- })
183
- }
184
-
185
- return states
186
- }
package/standards/rule.md CHANGED
@@ -33,17 +33,27 @@ Write both when both apply. A rule stating the directive and a skill stating how
33
33
 
34
34
  - Rules live at `.claude/rules/<subdirectory>/<n>-<slug>.md`
35
35
  - Subdirectories group by domain: `core/`, `lang/`, `framework/`, `lib/`, `ui/`, `claude/`
36
- - `<n>` is a number in the subdirectory's band and `<slug>` is a one-to-three-word kebab topic
37
- - Scaffold a rule with a number that collides with neither the project's rules nor any installed shared rule set
36
+ - `<n>` is a number from the band reserved for the rule's source and `<slug>` is a one-to-three-word kebab topic
37
+ - A rule the project authored itself lives at `.claude/rules/project/<subdirectory>/<n>-<slug>.md`, keeping the same subdirectory names
38
38
  - Give every rule a numeric prefix. A bare-word filename reads as a folder name where a stack names its rules, so a rule without one is unreachable from a stack entry.
39
39
 
40
+ ## Reserved numbers
41
+
42
+ `000-899` is reserved for a rule set that ships to targets. `000-599` is what the six subdirectories above already divide, at 100 per subdirectory, and `600-899` is held for subdirectories a shipped set has not added yet.
43
+
44
+ A project-authored rule takes `900-999`, one sequence across every subdirectory under `.claude/rules/project/`. Scanning for a free number instead is what fails, because a shipped set fills its own band release by release, so what reads as free today is what a later release lands on. One target authored `claude/561-self-check.md` on a day nothing shipped at 561, met `claude/561-teach.md` on its next install, and now reads two numbers differently from everywhere else.
45
+
46
+ The cost is that a project-authored rule's leading digit stops naming its domain. Its subdirectory names it instead, and install preserves that either way. What a shared band costs is worse: a session that loads two rules reading as one number, with nothing in the folder to say which is which.
47
+
48
+ `aitk gov sync` reports a rule it finds no toolkit source for, outside `.claude/rules/project/`, and offers the path under it. The offer is conditional on the project having authored the rule, because a rule the toolkit shipped and later renamed looks identical from the outside and moving one there would mark it the project's for good. Nothing is moved either way, since a rule's installed path is one the project's own rules, skills, and docs may cite.
49
+
40
50
  ## Two sources numbering into one folder
41
51
 
42
- A shared rule set and a project's own rules land in the same installed folder and draw from the same band, so the two need a division or they collide. Divide the band by source rather than by topic: one source takes the top of each band and the other takes the gaps between the tens. A rule set that ships to targets should take the tens, since it is the source a project cannot renumber.
52
+ The reservation above divides one pair, being a shipped set against the rules a project wrote for itself. A third source numbering into the same folder needs its own division, which is the case for a rule set held back from targets and installed only where it was authored. Divide by source rather than by topic: one source takes the top of each subdirectory's band and the other takes the gaps between the tens.
43
53
 
44
- The collision this prevents is silent. Two rules that resolve to the same `<n>-<slug>` path leave one file in the installed folder, and neither the install nor the session that reads it reports which source lost. Nothing checks the division, so it holds only while both sources follow it.
54
+ The collision this prevents is silent. Two rules that resolve to the same `<n>-<slug>` path leave one file in the installed folder, and neither the install nor the session that reads it reports which source lost. Nothing checks a division outside the reserved bands, so it holds only while both sources follow it.
45
55
 
46
- State the division where the rule sources are described, not in the rules themselves. A rule states its own topic, and a numbering convention spanning two sources belongs to whatever documents the pair.
56
+ State that division where the rule sources are described, not inside the rules it divides. A rule states its own topic, and a numbering convention spanning two sources belongs to whatever documents the pair.
47
57
 
48
58
  ## Frontmatter
49
59
 
@@ -1,147 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -e
3
- set -o pipefail
4
-
5
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
- PROJECT_ROOT="${PROJECT_ROOT:-$(dirname "$(dirname "$SCRIPT_DIR")")}"
7
-
8
- source "$PROJECT_ROOT/scripts/lib/ui.sh"
9
- source "$PROJECT_ROOT/scripts/lib/tooling.sh"
10
- trap close_timeline EXIT
11
-
12
- show_help() {
13
- echo -e "${GREY}┌${NC}"
14
- log_step "Tooling ref usage"
15
- echo -e "${GREY}│${NC} ${WHITE}Usage:${NC} aitk tooling ref [stack] [target-path]"
16
- echo -e "${GREY}│${NC}"
17
- echo -e "${GREY}│${NC} Drops reference docs only. No config or dependency changes."
18
- echo -e "${GREY}│${NC}"
19
- echo -e "${GREY}│${NC} ${WHITE}Arguments:${NC}"
20
- echo -e "${GREY}│${NC} stack Name of the tooling stack (e.g., base, vite-react)"
21
- echo -e "${GREY}│${NC} target-path Target directory (default: current directory)"
22
- echo -e "${GREY}│${NC}"
23
- echo -e "${GREY}│${NC} ${WHITE}Options:${NC}"
24
- echo -e "${GREY}│${NC} -h, --help ${GREY}# Show this help message${NC}"
25
- echo -e "${GREY}└${NC}"
26
- exit 0
27
- }
28
-
29
- select_stack() {
30
- local stacks=()
31
- mapfile -t stacks < <(list_tooling_stacks)
32
-
33
- if [ ${#stacks[@]} -eq 0 ]; then
34
- log_error "No tooling stacks found in $PROJECT_ROOT/tooling"
35
- fi
36
-
37
- select_option "Select tooling stack:" "${stacks[@]}"
38
- echo "$SELECTED_OPTION"
39
- }
40
-
41
- collect_references() {
42
- local stack="$1"
43
- local target="$2"
44
- local -n _pending=$3
45
-
46
- local manifest="$PROJECT_ROOT/tooling/$stack/manifest.toml"
47
- [ ! -f "$manifest" ] && return
48
-
49
- local extends
50
- extends=$(grep '^extends' "$manifest" 2>/dev/null | cut -d'"' -f2)
51
-
52
- if [ -n "$extends" ]; then
53
- collect_references "$extends" "$target" "$3"
54
- fi
55
-
56
- local reference_file="$PROJECT_ROOT/tooling/$stack/reference.md"
57
- [ ! -f "$reference_file" ] && return
58
-
59
- local dest="$target/.claude/tooling/$stack.md"
60
-
61
- if [ -f "$dest" ] && diff -q "$reference_file" "$dest" >/dev/null 2>&1; then
62
- log_info ".claude/tooling/$stack.md"
63
- return
64
- fi
65
-
66
- log_add ".claude/tooling/$stack.md"
67
- _pending+=("$stack")
68
- }
69
-
70
- apply_references() {
71
- local target="$1"
72
- shift
73
- local stacks=("$@")
74
-
75
- mkdir -p "$target/.claude/tooling"
76
-
77
- for stack in "${stacks[@]}"; do
78
- local src="$PROJECT_ROOT/tooling/$stack/reference.md"
79
- cp "$src" "$target/.claude/tooling/$stack.md"
80
- log_add ".claude/tooling/$stack.md"
81
- rm -f "$target/tooling/$stack.md"
82
- done
83
- rmdir "$target/tooling" 2>/dev/null || true
84
- }
85
-
86
- main() {
87
- if [[ "$1" == "-h" || "$1" == "--help" ]]; then
88
- show_help
89
- fi
90
-
91
- local stack="$1"
92
- local target="${2:-.}"
93
-
94
- # Every frame below goes to stderr, so it asks about that stream rather than
95
- # reading the source-time answer, which is for stdout and is what `show_help`
96
- # above wants. Declared after the help branch so that call keeps it.
97
- local GREEN RED YELLOW WHITE GREY NC
98
- set_palette 2
99
-
100
- echo -e "${GREY}┌${NC}" >&2
101
- echo -e "${GREY}│${NC} ${WHITE}aitk tooling ref${NC}" >&2
102
-
103
- if [ -z "$stack" ]; then
104
- stack=$(select_stack)
105
- fi
106
-
107
- if is_tooling_stack_excluded "$stack"; then
108
- log_error "Claude is managed by \`aitk claude\`, not \`aitk tooling\`."
109
- fi
110
-
111
- if [ ! -d "$PROJECT_ROOT/tooling/$stack" ]; then
112
- log_error "Stack not found: $stack"
113
- fi
114
-
115
- guard_root "$target"
116
-
117
- local pending=()
118
-
119
- echo -e "${GREY}├${NC} ${WHITE}Scanning references: $stack${NC}" >&2
120
- collect_references "$stack" "$target" pending
121
-
122
- if [ "${#pending[@]}" -eq 0 ]; then
123
- trap - EXIT
124
- echo -e "${GREY}└${NC}\n" >&2
125
- echo -e "${GREEN}✓ References up to date${NC}" >&2
126
- exit 0
127
- fi
128
-
129
- local dest_display="$target/.claude/tooling/"
130
- dest_display="${dest_display#./}"
131
-
132
- select_option "Sync ${#pending[@]} reference(s) to $dest_display?" "Yes" "No"
133
-
134
- if [ "$SELECTED_OPTION" = "No" ]; then
135
- log_warn "Cancelled"
136
- exit 0
137
- fi
138
-
139
- log_step "Applying changes"
140
- apply_references "$target" "${pending[@]}"
141
-
142
- trap - EXIT
143
- echo -e "${GREY}└${NC}\n" >&2
144
- echo -e "${GREEN}✓ References synced${NC}" >&2
145
- }
146
-
147
- main "$@"