@erclx/aitk 0.66.1 → 0.68.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.
Files changed (36) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/claude-orchestrate/REQUIREMENT.md +3 -0
  3. package/claude/skills/claude-orchestrate/SKILL.md +4 -6
  4. package/claude/skills/claude-orchestrate/references/orchestrator-handoff.md +12 -7
  5. package/claude/skills/claude-worktree/REQUIREMENT.md +8 -5
  6. package/claude/skills/claude-worktree/SKILL.md +24 -4
  7. package/claude/skills/claude-worktree/references/branch.md +60 -0
  8. package/claude/skills/git-branch/references/branch.md +1 -1
  9. package/claude/skills/git-followup/REQUIREMENT.md +3 -3
  10. package/claude/skills/git-followup/SKILL.md +3 -2
  11. package/claude/skills/git-pr/references/branch.md +1 -1
  12. package/claude/skills/git-split/references/branch.md +1 -1
  13. package/claude/skills/project-commands/SKILL.md +13 -5
  14. package/docs/agents/commands.md +1 -1
  15. package/docs/agents/context-audit.md +5 -1
  16. package/docs/agents/index.md +1 -1
  17. package/docs/agents/install-and-sync.md +23 -0
  18. package/docs/agents/markdown-audit.md +26 -6
  19. package/docs/ai-workflow.md +1 -1
  20. package/docs/target-projects.md +5 -5
  21. package/package.json +1 -1
  22. package/scripts/core/verify.sh +34 -0
  23. package/src/commands/markdown.ts +27 -6
  24. package/src/commands/sync.ts +51 -6
  25. package/src/commands/tooling.ts +35 -0
  26. package/src/markdown/gate.ts +28 -0
  27. package/src/sync/check.ts +125 -8
  28. package/src/sync/stamp.ts +64 -4
  29. package/src/tooling/stamp.ts +44 -0
  30. package/standards/bundled/branch.md +1 -1
  31. package/standards/slug.md +4 -2
  32. package/tooling/astro/reference.md +1 -1
  33. package/tooling/base/reference.md +1 -1
  34. package/tooling/claude/seeds/CLAUDE.md +1 -1
  35. package/tooling/vite-react/reference.md +1 -1
  36. package/tooling/web/reference.md +1 -1
@@ -272,6 +272,40 @@ main() {
272
272
  run_check "cd $PROJECT_ROOT && bun src/cli.ts context audit --citations-only" "A cited context path does not resolve. Run bun src/cli.ts context audit."
273
273
  log_info "Context citations resolve"
274
274
 
275
+ # A banned character, word, or spelling is a fact rather than a threshold, so
276
+ # it fails the push while bullet, paragraph, and depth weight stay advisory
277
+ # for the reason the stage above leaves its own thresholds so.
278
+ #
279
+ # The whole corpus is measured rather than the changed files, because a
280
+ # `Do not use` bullet added to a standard bans a token retroactively and no
281
+ # file in the push that adds it was edited.
282
+ #
283
+ # `--json` sends the record to stdout and the frame to stderr, so a passing
284
+ # run stays silent and a failing one is re-run for its frame rather than
285
+ # parsed out of a stream this script would have to strip. `bun src/cli.ts`
286
+ # rather than `aitk` for the reason the stage above uses it.
287
+ log_step "Markdown bans"
288
+ local ban_status=0 ban_frame
289
+ (cd "$PROJECT_ROOT" && bun src/cli.ts markdown audit --json >/dev/null 2>&1) || ban_status=$?
290
+ case $ban_status in
291
+ 0)
292
+ log_info "No banned character, word, or spelling"
293
+ ;;
294
+ 1)
295
+ log_warn "Skipped, the markdown audit refused and measured nothing"
296
+ ;;
297
+ 2)
298
+ # `|| true` because the re-run exits non-zero by construction, and `set -e`
299
+ # would take the script down before log_error names the remedy.
300
+ ban_frame=$(cd "$PROJECT_ROOT" && bun src/cli.ts markdown audit 2>&1 || true)
301
+ echo "$ban_frame" | pipe_output
302
+ log_error "Markdown prose carries a banned character, word, or spelling. Rewrite the sentence, and reach for a code span only where the token is genuinely an identifier under discussion."
303
+ ;;
304
+ *)
305
+ log_error "The markdown audit exited $ban_status, which is neither a pass nor a finding."
306
+ ;;
307
+ esac
308
+
275
309
  # The stage above audits this repository. Its seed tree ships into every
276
310
  # scaffolded project, so a seed breaking the standard it seeds propagates
277
311
  # instead of sitting still, and no rule path reaches the tree to report it.
@@ -3,6 +3,7 @@ import { resolve } from 'node:path'
3
3
  import type { Command } from 'commander'
4
4
  import { type BanReport, banReport, loadStandards } from '@/markdown/bans'
5
5
  import { resolveMarkdown } from '@/markdown/files'
6
+ import { isGating } from '@/markdown/gate'
6
7
  import { type BanFinding, bodyLines, scanBans } from '@/markdown/scan'
7
8
  import {
8
9
  type Checkpoints,
@@ -20,6 +21,8 @@ import {
20
21
  plural,
21
22
  } from '@/ui'
22
23
 
24
+ const EXIT_GATE = 2
25
+
23
26
  interface AuditCommandOptions {
24
27
  readonly json?: boolean
25
28
  }
@@ -39,7 +42,7 @@ export function register(program: Command): void {
39
42
  markdown
40
43
  .command('audit')
41
44
  .description(
42
- 'Report the character bans, the word bans, and bullet, paragraph, and depth weight',
45
+ 'Fail on a banned character, word, or spelling, and report bullet, paragraph, and depth weight',
43
46
  )
44
47
  .argument(
45
48
  '[path...]',
@@ -52,12 +55,18 @@ export function register(program: Command): void {
52
55
  [
53
56
  '',
54
57
  'Exit codes:',
55
- ' 0 the audit completed',
58
+ ' 0 the audit completed with no gating finding',
56
59
  ' 1 refused, with the reason on stderr',
60
+ ' 2 a banned character, word, or spelling is present',
61
+ '',
62
+ 'A ban hit is a fact and gates unconditionally. Bullet, paragraph, and',
63
+ 'depth weight are judgments a reader settles, so all three report and',
64
+ 'none of them fails a run.',
57
65
  '',
58
- 'Every finding reports and none gates. A banned character is a fact and',
59
- 'will gate once the corpus has been measured and fixed, which is the',
60
- 'follow-up rather than this command.',
66
+ 'Rewrite the sentence carrying a hit rather than swapping the token for',
67
+ 'a near-synonym. A code span clears the report and is the answer only',
68
+ 'where the token is genuinely an identifier under discussion, which is',
69
+ 'what markdown.md reserves the span for.',
61
70
  '',
62
71
  'Bans and checkpoints are read from markdown.md and prose.md, resolved',
63
72
  'under .claude/standards/ then standards/. No folder has to resolve and',
@@ -156,7 +165,12 @@ async function runAudit(
156
165
  )
157
166
  }
158
167
 
159
- return 0
168
+ const gating = isGating({
169
+ bans: reports.flatMap((report) => report.bans),
170
+ structure: reports.map((report) => report.structure),
171
+ })
172
+
173
+ return gating ? EXIT_GATE : 0
160
174
  }
161
175
 
162
176
  function refuse(message: string): number {
@@ -218,6 +232,13 @@ function reportBans(reports: readonly FileReport[], bans: BanReport): void {
218
232
 
219
233
  const total = carrying.reduce((sum, report) => sum + report.bans.length, 0)
220
234
  logWarn(`${plural(total, 'hit')} across ${plural(carrying.length, 'file')}`)
235
+ logWarn('This fails the run. Every other check below reports.')
236
+ logInfo(
237
+ 'Rewrite the sentence rather than swapping the token for a near-synonym.',
238
+ )
239
+ logInfo(
240
+ 'A code span clears the report and is the answer only where the token is genuinely an identifier under discussion, which is what markdown.md reserves the span for.',
241
+ )
221
242
  pipeOutput(
222
243
  carrying
223
244
  .map(
@@ -3,9 +3,13 @@ import { join } from 'node:path'
3
3
  import type { Command } from 'commander'
4
4
  import { cliPath, cliRun } from '@/cli-run'
5
5
  import { PROJECT_ROOT } from '@/exec'
6
- import { buildCheckReport, type CheckReport, hasDrift } from '@/sync/check'
6
+ import {
7
+ buildCheckReport,
8
+ type CheckReport,
9
+ hasDrift,
10
+ SCANNED_DOMAINS,
11
+ } from '@/sync/check'
7
12
  import { createGitRunner, createPullRequestOpener, hasGh } from '@/sync/git'
8
- import { STAMP_DOMAINS } from '@/sync/stamp'
9
13
  import {
10
14
  detectDomains,
11
15
  installedDomains,
@@ -133,6 +137,8 @@ function renderCheck(report: CheckReport): void {
133
137
  }
134
138
  }
135
139
 
140
+ renderTooling(report)
141
+
136
142
  for (const entry of report.unmigrated) {
137
143
  logStep(`${entry.domain} (not migrated)`)
138
144
  logWarn(
@@ -157,17 +163,56 @@ function renderCheck(report: CheckReport): void {
157
163
  }
158
164
 
159
165
  outro()
166
+ // Scanned domains only. Tooling renders a section on every managed target, so
167
+ // naming it here repeats what that section already said under a second
168
+ // remedy, where a scanned domain nobody installed has no section at all and
169
+ // this line is the only place it appears.
160
170
  const unmigrated = report.unmigrated.map((entry) => entry.domain)
161
- const uncovered = STAMP_DOMAINS.filter(
171
+ const uncovered = SCANNED_DOMAINS.filter(
162
172
  (domain) => !report.covers.includes(domain) && !unmigrated.includes(domain),
163
173
  )
164
- const unstamped =
165
- uncovered.length === 0 ? '' : `Unstamped: ${uncovered.join(', ')}. `
174
+ if (uncovered.length === 0) return
175
+
166
176
  process.stderr.write(
167
- `${GREY}${unstamped}Tooling is never stamped, run \`aitk tooling\` to reconcile configs.${NC}\n`,
177
+ `${GREY}Unstamped: ${uncovered.join(', ')}. Run the matching sync to record one.${NC}\n`,
168
178
  )
169
179
  }
170
180
 
181
+ /**
182
+ * Tooling prints whether it was measured before it prints any count, because a
183
+ * target with no chain recorded produces the same zero a current target does.
184
+ * Naming the state is the whole reason the section exists.
185
+ */
186
+ function renderTooling(report: CheckReport): void {
187
+ const { tooling } = report
188
+ logStep('tooling')
189
+
190
+ if (!tooling.measured) {
191
+ logWarn(
192
+ tooling.chain.length === 0
193
+ ? 'Not stamped. No chain recorded, so tooling drift is unmeasured.'
194
+ : `Recorded chain names no stack this toolkit ships: ${tooling.chain.join(' < ')}.`,
195
+ )
196
+ logInfo('Run `aitk tooling sync <stack>` to record what this target holds.')
197
+ return
198
+ }
199
+
200
+ logInfo(`Chain: ${tooling.chain.join(' < ')}`)
201
+ if (tooling.commit !== undefined) {
202
+ logInfo(`Synced from ${tooling.commit} on ${tooling.syncedAt}`)
203
+ }
204
+
205
+ if (tooling.changes === 0) {
206
+ logInfo('up to date')
207
+ return
208
+ }
209
+
210
+ for (const [category, count] of Object.entries(tooling.counts)) {
211
+ if (count > 0) logWarn(`${count} ${category}`)
212
+ }
213
+ logInfo('Run `aitk tooling sync` to reconcile these.')
214
+ }
215
+
171
216
  /**
172
217
  * Seeds print their own section because no sync command applies them. A `stale`
173
218
  * seed is safe to take whole and a `drifted` one holds edits, which is the split
@@ -18,6 +18,7 @@ import {
18
18
  stackExists,
19
19
  } from '@/tooling/manifest'
20
20
  import { scan, type ScanResult } from '@/tooling/scan'
21
+ import { recordToolingChain } from '@/tooling/stamp'
21
22
  import { intro, logAdd, logInfo, logStep, logWarn, outro, select } from '@/ui'
22
23
 
23
24
  const GREEN = '\x1b[0;32m'
@@ -219,6 +220,7 @@ async function runSync(
219
220
  report(result, includeReferences)
220
221
 
221
222
  if (result.totalChanges === 0) {
223
+ await stampChain(prepared.chain, prepared.target)
222
224
  outro()
223
225
  process.stderr.write(`${GREEN}✓ Everything up to date${NC}\n`)
224
226
  return 0
@@ -255,11 +257,35 @@ async function runSync(
255
257
  await applyReferences(prepared.chain, prepared.target, pending)
256
258
  }
257
259
 
260
+ await stampChain(prepared.chain, prepared.target)
261
+
258
262
  outro()
259
263
  process.stderr.write(`${GREEN}✓ Tooling sync complete${NC}\n`)
260
264
  return 0
261
265
  }
262
266
 
267
+ /**
268
+ * Writes the chain after the copies land, so a partial apply that throws leaves
269
+ * the previous record rather than a claim the target does not meet.
270
+ */
271
+ async function stampChain(chain: Manifest[], target: string): Promise<void> {
272
+ const recorded = await recordToolingChain(
273
+ PROJECT_ROOT,
274
+ target,
275
+ chain,
276
+ new Date(),
277
+ )
278
+
279
+ if (recorded) {
280
+ logInfo(`Recorded chain: ${chain.map((entry) => entry.name).join(' < ')}`)
281
+ return
282
+ }
283
+
284
+ logWarn(
285
+ 'Workspace root: no chain recorded. Tooling reports unmeasured because the answer differs per package.',
286
+ )
287
+ }
288
+
263
289
  async function runInject(
264
290
  stack: string,
265
291
  target: string,
@@ -290,6 +316,15 @@ async function runInject(
290
316
  await injectGitignore(prepared.chain, prepared.target)
291
317
  }
292
318
 
319
+ // Only a whole-stack inject records the chain. A flag-scoped run installs one
320
+ // category, and a chain recorded from it would send the report scanning for
321
+ // configs and deps the caller never asked to install. The claude stack is
322
+ // excluded here rather than in `prepare`, which is what keeps `aitk claude`
323
+ // able to drive injection while its stack stays out of the tooling record.
324
+ if (applyAll && !isStackExcluded(stack)) {
325
+ await stampChain(prepared.chain, prepared.target)
326
+ }
327
+
293
328
  if (framed) outro()
294
329
  return 0
295
330
  }
@@ -0,0 +1,28 @@
1
+ import type { BanFinding } from '@/markdown/scan'
2
+ import type { StructureReport } from '@/markdown/structure'
3
+
4
+ export interface GateInput {
5
+ /** Every ban hit across every file measured, flattened. */
6
+ readonly bans: readonly BanFinding[]
7
+ /**
8
+ * Every structural measure the run made, read by nothing here.
9
+ *
10
+ * Naming it is what makes the split checkable: bullet, paragraph, and depth
11
+ * weight are judgments a reader settles, and a push failing on one teaches a
12
+ * contributor to route around the stage. A signature taking the ban list
13
+ * alone states the same rule and leaves no place to assert it.
14
+ */
15
+ readonly structure: readonly StructureReport[]
16
+ }
17
+
18
+ /**
19
+ * Whether the audit found something that should fail the caller.
20
+ *
21
+ * A banned character, word, or spelling is a fact rather than a threshold, so
22
+ * it gates unconditionally and there is no widened mode to reach for. The
23
+ * standards decide what counts as banned, which keeps this answering how many
24
+ * rather than which.
25
+ */
26
+ export function isGating({ bans }: GateInput): boolean {
27
+ return bans.length > 0
28
+ }
package/src/sync/check.ts CHANGED
@@ -13,32 +13,47 @@ import {
13
13
  import { buildSeedsReport, type SeedsReport } from '@/sync/seeds-report'
14
14
  import {
15
15
  readStamp,
16
- STAMP_DOMAINS,
17
16
  type Stamp,
17
+ stampedChain,
18
18
  stampedCommit,
19
19
  type StampDomain,
20
20
  } from '@/sync/stamp'
21
21
  import { createStandardsAdapter } from '@/standards/adapter'
22
22
  import { isDirectory } from '@/target'
23
+ import { loadManifest } from '@/tooling/manifest'
24
+ import { scan } from '@/tooling/scan'
25
+
26
+ /**
27
+ * Domains the sync engine walks file by file. Tooling is a stamp domain without
28
+ * being one of these, because `src/tooling/` never calls `planSync`, so the
29
+ * three lookups below have no entry to offer it.
30
+ */
31
+ export const SCANNED_DOMAINS = [
32
+ 'standards',
33
+ 'snippets',
34
+ 'governance',
35
+ ] as const satisfies readonly StampDomain[]
36
+
37
+ export type ScannedDomain = (typeof SCANNED_DOMAINS)[number]
23
38
 
24
39
  /**
25
40
  * The toolkit path whose commits change what each domain holds. `claude/skills/`
26
41
  * is deliberately absent: skills load live from the plugin directory, so they
27
42
  * never go stale and belong in the read-only section instead.
28
43
  */
29
- const SYNCED_SOURCES: Record<StampDomain, string> = {
44
+ const SYNCED_SOURCES: Record<ScannedDomain, string> = {
30
45
  standards: 'standards/',
31
46
  snippets: 'snippets/',
32
47
  governance: 'governance/rules/',
33
48
  }
34
49
 
35
- const ADAPTERS: Record<StampDomain, (root: string) => SyncAdapter> = {
50
+ const ADAPTERS: Record<ScannedDomain, (root: string) => SyncAdapter> = {
36
51
  standards: createStandardsAdapter,
37
52
  snippets: createSnippetsAdapter,
38
53
  governance: createGovAdapter,
39
54
  }
40
55
 
41
- const INSTALL_MARKERS: Record<StampDomain, readonly string[]> = {
56
+ const INSTALL_MARKERS: Record<ScannedDomain, readonly string[]> = {
42
57
  standards: ['.claude', 'standards'],
43
58
  snippets: ['.claude', 'snippets'],
44
59
  governance: ['.claude', 'rules'],
@@ -54,7 +69,7 @@ export interface StateCounts {
54
69
  }
55
70
 
56
71
  export interface DomainReport {
57
- readonly domain: StampDomain
72
+ readonly domain: ScannedDomain
58
73
  readonly stamped: boolean
59
74
  /** This domain's own anchor, not the target's most recent sync. */
60
75
  readonly commit?: string
@@ -74,11 +89,60 @@ export interface UpstreamCommit {
74
89
  readonly subject: string
75
90
  }
76
91
 
92
+ /** Pending changes per category, from the same scan `aitk tooling sync` reads. */
93
+ export interface ToolingCounts {
94
+ readonly configs: number
95
+ readonly seeds: number
96
+ readonly scripts: number
97
+ readonly deps: number
98
+ readonly gitignore: number
99
+ readonly references: number
100
+ }
101
+
102
+ /**
103
+ * Tooling's own section. `measured` is the field the report exists for: without
104
+ * it a target that never installed tooling and a target whose tooling is current
105
+ * both render as zero changes, which is a claim rather than an absence of one.
106
+ */
107
+ export interface ToolingReport {
108
+ readonly measured: boolean
109
+ /**
110
+ * Stack names the install resolved, nearest first. Carried even when the
111
+ * report is unmeasured, so a chain naming stacks this toolkit no longer ships
112
+ * stays distinguishable from a target that recorded none.
113
+ */
114
+ readonly chain: readonly string[]
115
+ readonly commit?: string
116
+ readonly syncedAt?: string
117
+ readonly counts: ToolingCounts
118
+ readonly changes: number
119
+ }
120
+
121
+ const UNMEASURED_TOOLING: ToolingReport = {
122
+ measured: false,
123
+ chain: [],
124
+ counts: {
125
+ configs: 0,
126
+ seeds: 0,
127
+ scripts: 0,
128
+ deps: 0,
129
+ gitignore: 0,
130
+ references: 0,
131
+ },
132
+ changes: 0,
133
+ }
134
+
77
135
  export interface CheckReport {
78
136
  readonly covers: readonly StampDomain[]
79
137
  /** False when the target is not a toolkit project, so every section stays empty. */
80
138
  readonly managed: boolean
81
139
  readonly domains: readonly DomainReport[]
140
+ /**
141
+ * Reported beside the domains rather than as one of them, because tooling
142
+ * carries no per-file entries and no upstream range, so it fills almost none
143
+ * of `DomainReport`.
144
+ */
145
+ readonly tooling: ToolingReport
82
146
  /**
83
147
  * Reported beside the domains rather than as one of them, because seeds carry
84
148
  * no stamp and produce no change. See `@/sync/seeds-report`.
@@ -89,12 +153,58 @@ export interface CheckReport {
89
153
  readonly newSkills: readonly string[]
90
154
  }
91
155
 
92
- export function installedStampDomains(target: string): StampDomain[] {
93
- return STAMP_DOMAINS.filter((domain) =>
156
+ export function installedStampDomains(target: string): ScannedDomain[] {
157
+ return SCANNED_DOMAINS.filter((domain) =>
94
158
  isDirectory(join(target, ...INSTALL_MARKERS[domain])),
95
159
  )
96
160
  }
97
161
 
162
+ /**
163
+ * Reads the chain the install recorded and scans against those stacks rather
164
+ * than re-resolving from the leaf. A run that passed `--skip` installed fewer
165
+ * layers than the leaf's own chain reproduces, so re-resolving would report
166
+ * drift against a layer the target deliberately does not carry.
167
+ *
168
+ * A recorded stack the toolkit no longer ships resolves to nothing, and a chain
169
+ * where none resolve reads as unmeasured. Scanning the survivors would measure
170
+ * against a chain neither side agrees on.
171
+ */
172
+ export function buildToolingReport(
173
+ toolkitRoot: string,
174
+ target: string,
175
+ stamp: Stamp | undefined,
176
+ ): ToolingReport {
177
+ const chain = stampedChain(stamp)
178
+ const manifests = chain
179
+ .map((name) => loadManifest(toolkitRoot, name))
180
+ .filter((manifest) => manifest !== undefined)
181
+
182
+ if (manifests.length === 0) return { ...UNMEASURED_TOOLING, chain }
183
+
184
+ const result = scan(manifests, target, { includeReferences: true })
185
+ const record = stamp?.domains.tooling
186
+
187
+ return {
188
+ measured: true,
189
+ chain,
190
+ commit: record?.commit,
191
+ syncedAt: record?.syncedAt,
192
+ counts: {
193
+ configs: result.configs.filter((entry) => entry.state !== 'matching')
194
+ .length,
195
+ seeds: result.seeds.filter((entry) => entry.state === 'missing').length,
196
+ scripts: result.scripts.filter((entry) => entry.state !== 'matching')
197
+ .length,
198
+ deps: result.deps.filter((entry) => entry.state === 'missing').length,
199
+ gitignore: result.gitignore.filter((entry) => entry.state === 'missing')
200
+ .length,
201
+ references: result.references.filter((entry) => entry.state === 'pending')
202
+ .length,
203
+ },
204
+ changes: result.totalChanges,
205
+ }
206
+ }
207
+
98
208
  /**
99
209
  * Whether the target is a toolkit-managed project at all. Seeds are enumerated
100
210
  * from the source rather than from what a target installed, so without this gate
@@ -143,6 +253,11 @@ export function countStates(entries: readonly ScanEntry[]): StateCounts {
143
253
  * the user can move content they wrote, so failing a job on it leaves the job
144
254
  * red with no mechanical remedy. Seeds are excluded on the same grounds, since
145
255
  * every seed a project edits would otherwise fail the check forever.
256
+ *
257
+ * Tooling is excluded on exactly the seeds grounds: a golden config is one the
258
+ * project is expected to edit, so a job counting it stays red with no remedy.
259
+ * Being unmeasured is not what excludes it, since an unmeasured report carries
260
+ * zero changes and would pass a count either way.
146
261
  */
147
262
  export function hasDrift(report: CheckReport): boolean {
148
263
  if (report.unmigrated.length > 0) return true
@@ -186,6 +301,7 @@ export async function buildCheckReport(
186
301
  covers: [],
187
302
  managed,
188
303
  domains: [],
304
+ tooling: UNMEASURED_TOOLING,
189
305
  seeds: { entries: [], historyUnavailable: false },
190
306
  superseded: [],
191
307
  unmigrated: [],
@@ -197,6 +313,7 @@ export async function buildCheckReport(
197
313
  covers: stamp?.covers ?? [],
198
314
  managed,
199
315
  domains,
316
+ tooling: buildToolingReport(toolkitRoot, target, stamp),
200
317
  seeds: buildSeedsReport(toolkitRoot, target),
201
318
  superseded: collectSuperseded(target),
202
319
  unmigrated,
@@ -208,7 +325,7 @@ async function buildDomainReport(
208
325
  toolkitRoot: string,
209
326
  target: string,
210
327
  stamp: Stamp | undefined,
211
- domain: StampDomain,
328
+ domain: ScannedDomain,
212
329
  ): Promise<DomainReport> {
213
330
  const plan = planSync(ADAPTERS[domain](toolkitRoot), target)
214
331
  const record = stamp?.domains[domain]
package/src/sync/stamp.ts CHANGED
@@ -5,10 +5,16 @@ import { dirname, join, sep } from 'node:path'
5
5
  import { execa } from 'execa'
6
6
 
7
7
  /**
8
- * Domains the stamp can attribute. Tooling runs its own inject and manifest
9
- * machinery rather than the sync engine, so it is absent by design.
8
+ * Domains the stamp can record. The first three attribute file by file through
9
+ * the sync engine. Tooling runs its own inject and manifest machinery, so it
10
+ * records the stack chain it resolved instead and carries no file hashes.
10
11
  */
11
- export const STAMP_DOMAINS = ['standards', 'snippets', 'governance'] as const
12
+ export const STAMP_DOMAINS = [
13
+ 'standards',
14
+ 'snippets',
15
+ 'governance',
16
+ 'tooling',
17
+ ] as const
12
18
 
13
19
  export type StampDomain = (typeof STAMP_DOMAINS)[number]
14
20
 
@@ -31,6 +37,13 @@ export interface DomainStamp {
31
37
  readonly commit?: string
32
38
  readonly syncedAt: string
33
39
  readonly files: DomainHashes
40
+ /**
41
+ * Stack names the install resolved, nearest stack first. Present only for
42
+ * tooling. An ordered chain rather than the leaf name, because a stack that
43
+ * extends another cannot be reinstalled from its leaf alone, and a `--skip`
44
+ * run installs fewer layers than the leaf's own chain would reproduce.
45
+ */
46
+ readonly chain?: readonly string[]
34
47
  }
35
48
 
36
49
  export interface Stamp {
@@ -91,6 +104,15 @@ export function stampedHashes(
91
104
  return stamp.domains[domain]?.files ?? {}
92
105
  }
93
106
 
107
+ /**
108
+ * The stack chain tooling last installed. An empty result is the state every
109
+ * target predating the tooling record sits in, and the report reads it as
110
+ * unmeasured rather than as clean.
111
+ */
112
+ export function stampedChain(stamp: Stamp | undefined): readonly string[] {
113
+ return stamp?.domains.tooling?.chain ?? []
114
+ }
115
+
94
116
  /**
95
117
  * Replaces one domain's record and leaves the others untouched, because domains
96
118
  * install and sync independently but share the one file.
@@ -100,6 +122,34 @@ export async function writeStamp(
100
122
  source: StampSource,
101
123
  hashes: DomainHashes,
102
124
  now: Date,
125
+ ): Promise<void> {
126
+ await putDomain(target, source, { files: sortKeys(hashes) }, now)
127
+ }
128
+
129
+ /**
130
+ * Records what tooling installed. `files` stays empty because `src/tooling/`
131
+ * never runs the sync engine, so there is no per-file attribution to store and
132
+ * the chain is the whole record.
133
+ */
134
+ export async function writeChainStamp(
135
+ target: string,
136
+ toolkitRoot: string,
137
+ chain: readonly string[],
138
+ now: Date,
139
+ ): Promise<void> {
140
+ await putDomain(
141
+ target,
142
+ { domain: 'tooling', toolkitRoot },
143
+ { files: {}, chain: [...chain] },
144
+ now,
145
+ )
146
+ }
147
+
148
+ async function putDomain(
149
+ target: string,
150
+ source: StampSource,
151
+ payload: Pick<DomainStamp, 'files' | 'chain'>,
152
+ now: Date,
103
153
  ): Promise<void> {
104
154
  const previous = readStamp(target)
105
155
  const commit = await toolkitCommit(source.toolkitRoot)
@@ -107,7 +157,7 @@ export async function writeStamp(
107
157
  const record: DomainStamp = {
108
158
  ...(commit === undefined ? {} : { commit }),
109
159
  syncedAt: now.toISOString(),
110
- files: sortKeys(hashes),
160
+ ...payload,
111
161
  }
112
162
 
113
163
  const domains = sortDomains({
@@ -187,16 +237,26 @@ function isStamp(value: unknown): value is Stamp {
187
237
  )
188
238
  }
189
239
 
240
+ /**
241
+ * `chain` is optional, which is what keeps a stamp written before tooling
242
+ * joined the domains readable rather than parsing as corrupt and discarding
243
+ * the three records it does carry.
244
+ */
190
245
  function isDomainStamp(value: unknown): value is DomainStamp {
191
246
  if (!isRecord(value) || !isRecord(value.files)) return false
192
247
  if (typeof value.syncedAt !== 'string') return false
193
248
  if (value.commit !== undefined && typeof value.commit !== 'string') {
194
249
  return false
195
250
  }
251
+ if (value.chain !== undefined && !isStringArray(value.chain)) return false
196
252
 
197
253
  return Object.values(value.files).every((hash) => typeof hash === 'string')
198
254
  }
199
255
 
256
+ function isStringArray(value: unknown): value is string[] {
257
+ return Array.isArray(value) && value.every((item) => typeof item === 'string')
258
+ }
259
+
200
260
  function isRecord(value: unknown): value is Record<string, unknown> {
201
261
  return typeof value === 'object' && value !== null && !Array.isArray(value)
202
262
  }
@@ -0,0 +1,44 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { writeChainStamp } from '@/sync/stamp'
4
+ import type { Manifest } from '@/tooling/manifest'
5
+ import { readPackage } from '@/tooling/package'
6
+
7
+ /**
8
+ * Whether the target is a workspace root, where every package resolves its own
9
+ * stack. `package.json` covers bun, npm, and yarn, and pnpm declares the same
10
+ * fact in its own file instead.
11
+ */
12
+ export function isWorkspaceRoot(target: string): boolean {
13
+ if (existsSync(join(target, 'pnpm-workspace.yaml'))) return true
14
+
15
+ const workspaces = readPackage(join(target, 'package.json'))?.workspaces
16
+ return Array.isArray(workspaces) || isRecord(workspaces)
17
+ }
18
+
19
+ /**
20
+ * Records the chain an install resolved, and returns whether it wrote one. A
21
+ * workspace root is the single refusal: one chain recorded there would be a
22
+ * guess at what its packages hold, and the report reading tooling as unmeasured
23
+ * is the true answer rather than a clean one.
24
+ */
25
+ export async function recordToolingChain(
26
+ toolkitRoot: string,
27
+ target: string,
28
+ chain: readonly Manifest[],
29
+ now: Date,
30
+ ): Promise<boolean> {
31
+ if (isWorkspaceRoot(target)) return false
32
+
33
+ await writeChainStamp(
34
+ target,
35
+ toolkitRoot,
36
+ chain.map((manifest) => manifest.name),
37
+ now,
38
+ )
39
+ return true
40
+ }
41
+
42
+ function isRecord(value: unknown): value is Record<string, unknown> {
43
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
44
+ }