@erclx/aitk 3.12.0 → 3.13.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-groundwork/REQUIREMENT.md +1 -0
  3. package/claude/skills/claude-groundwork/SKILL.md +12 -12
  4. package/claude/skills/claude-intake/REQUIREMENT.md +1 -0
  5. package/claude/skills/claude-intake/SKILL.md +7 -7
  6. package/docs/agents/install-and-sync.md +55 -28
  7. package/docs/agents/intake.md +3 -1
  8. package/docs/ai-workflow.md +2 -2
  9. package/docs/target-projects.md +7 -5
  10. package/governance/rules/claude/556-groundwork.md +5 -1
  11. package/governance/rules/claude/557-intake.md +5 -1
  12. package/governance/rules/lang/120-bash.md +18 -0
  13. package/governance/rules/lib/305-e2e-reliability.md +34 -0
  14. package/governance/rules/lib/306-test-scope.md +25 -0
  15. package/governance/rules/lib/350-security-web.md +1 -0
  16. package/governance/rules/ui/450-link-behavior.md +19 -0
  17. package/governance/stacks/astro.toml +1 -1
  18. package/governance/stacks/base.toml +1 -1
  19. package/governance/stacks/react.toml +1 -1
  20. package/package.json +1 -1
  21. package/src/commands/gov.ts +10 -1
  22. package/src/commands/sync.ts +18 -5
  23. package/src/demo/compile.ts +1 -1
  24. package/src/gov/adapter.ts +29 -0
  25. package/src/gov/install.ts +22 -1
  26. package/src/gov/stacks.ts +33 -1
  27. package/src/intake/folder.ts +49 -9
  28. package/src/sync/check.ts +53 -15
  29. package/src/sync/engine.ts +25 -0
  30. package/src/sync/stamp.ts +73 -24
  31. package/src/tooling/stamp.ts +1 -1
  32. package/standards/groundwork.md +10 -3
  33. package/standards/index.md +2 -2
  34. package/standards/intake.md +10 -3
  35. package/tooling/web/manifest.toml +1 -0
  36. package/tooling/web/reference.md +1 -0
@@ -5,7 +5,7 @@ import type { Beat, Draft } from '@/demo/beats'
5
5
  * separate artifacts on purpose: a beat carries no target, no wait condition,
6
6
  * and no timing, and putting those four fields on every beat would destroy the
7
7
  * property the draft was designed around. See
8
- * `.claude/groundwork/demo-recorder/06-decision.md`.
8
+ * `.claude/groundwork/38-demo-recorder/06-decision.md`.
9
9
  *
10
10
  * A compiled plan is committed rather than scratch, because the timing below is
11
11
  * a starting point the operator tunes and the draft cannot reproduce a tuned
@@ -1,6 +1,8 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { basename, join, relative, resolve } from 'node:path'
3
+ import { resolveMissingRules } from '@/gov/stacks'
3
4
  import type { InstalledFile, RetiredSurface, SyncAdapter } from '@/sync/engine'
5
+ import { readStamp, stampedChain } from '@/sync/stamp'
4
6
 
5
7
  const RETIRED_GOV_FILE = join('.claude', 'GOV.md')
6
8
 
@@ -54,11 +56,38 @@ export function createGovAdapter(root: string): SyncAdapter {
54
56
  locateSource: (file: InstalledFile) =>
55
57
  index.get(basename(file.path, '.md')),
56
58
  collectRetired: (target: string) => collectRetiredGov(target),
59
+ collectMissing: (target: string) => collectMissingGov(root, target),
57
60
  projectSubdir: 'project',
58
61
  stamp: { domain: 'governance', toolkitRoot: root },
59
62
  }
60
63
  }
61
64
 
65
+ /**
66
+ * Rules the target's recorded chain entitles it to and its tree does not
67
+ * hold. Reports as `notice` text through the same shape `collectRetired`
68
+ * already returns, since both are surfaces the file walk cannot see: one an
69
+ * absence to remove, this one an absence to add.
70
+ */
71
+ function collectMissingGov(root: string, target: string): RetiredSurface[] {
72
+ const chain = stampedChain(readStamp(target), 'governance')
73
+
74
+ return resolveMissingRules(root, target, chain).map((source) => {
75
+ const dest = join(
76
+ target,
77
+ '.claude',
78
+ 'rules',
79
+ source.subdir,
80
+ `${source.rule}.md`,
81
+ )
82
+ const rel = relative(target, dest)
83
+ return {
84
+ path: dest,
85
+ rel,
86
+ notice: `${rel} (listed by ${chain[0]}, not installed. Run aitk gov install ${chain[0]} to add it.)`,
87
+ }
88
+ })
89
+ }
90
+
62
91
  function collectRetiredGov(target: string): RetiredSurface[] {
63
92
  const path = join(target, RETIRED_GOV_FILE)
64
93
  if (!existsSync(path)) return []
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from 'node:fs'
2
- import { dirname, join, relative } from 'node:path'
2
+ import { basename, dirname, join, relative } from 'node:path'
3
3
  import { copyPreservingMode } from '@/copy'
4
4
 
5
5
  export interface RuleSource {
@@ -21,6 +21,27 @@ export function installedRulesDir(target: string): string {
21
21
  return join(target, '.claude', 'rules')
22
22
  }
23
23
 
24
+ /**
25
+ * Rule names a target already holds, read off the installed tree by basename
26
+ * rather than off a recorded stack, since a target may hold rules `--add`
27
+ * layered on that no stack lists.
28
+ */
29
+ export function installedRuleNames(target: string): Set<string> {
30
+ const dir = installedRulesDir(target)
31
+ const names = new Set<string>()
32
+ if (!existsSync(dir)) return names
33
+
34
+ for (const rel of new Bun.Glob('**/*.md').scanSync({
35
+ cwd: dir,
36
+ onlyFiles: true,
37
+ dot: true,
38
+ })) {
39
+ names.add(basename(rel, '.md'))
40
+ }
41
+
42
+ return names
43
+ }
44
+
24
45
  /**
25
46
  * Mirrors `rule_subdir` in `scripts/lib/gov.sh`, which stays in bash for the
26
47
  * sandbox loops. A rule sitting directly under `governance/rules/` has no
package/src/gov/stacks.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  import { existsSync, readFileSync, statSync } from 'node:fs'
2
2
  import { basename, join } from 'node:path'
3
- import { listRuleSourcePaths, rulesSourceDir } from '@/gov/install'
3
+ import {
4
+ installedRuleNames,
5
+ listRuleSourcePaths,
6
+ lookupRules,
7
+ type RuleSource,
8
+ rulesSourceDir,
9
+ } from '@/gov/install'
4
10
 
5
11
  export interface GovStack {
6
12
  readonly name: string
@@ -151,6 +157,32 @@ export function unreferencedRules(root: string): string[] {
151
157
  .sort()
152
158
  }
153
159
 
160
+ /**
161
+ * Rules the target's recorded chain entitles it to that its installed tree
162
+ * does not hold. `resolveRules` already walks a stack's `extends` ancestors,
163
+ * so reading its leaf entry is enough; no second walk resolves the chain
164
+ * itself. A stack the toolkit no longer ships resolves to nothing rather than
165
+ * throwing, the same way `readNewRules`'s band fallback already treats it.
166
+ */
167
+ export function resolveMissingRules(
168
+ root: string,
169
+ target: string,
170
+ chain: readonly string[],
171
+ ): readonly RuleSource[] {
172
+ const stack = chain[0]
173
+ if (stack === undefined) return []
174
+
175
+ const resolution = resolveRules(root, stack)
176
+ if (!resolution.ok) return []
177
+
178
+ const { found } = lookupRules(root, resolution.rules)
179
+ const held = installedRuleNames(target)
180
+
181
+ return found
182
+ .filter((source) => !held.has(source.rule))
183
+ .sort((left, right) => left.rule.localeCompare(right.rule))
184
+ }
185
+
154
186
  /**
155
187
  * Layers `--add` names on top of a resolved stack. The bash trimmed a single
156
188
  * leading and trailing space per entry; trimming fully is the same result for
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { readdir, readFile, writeFile } from 'node:fs/promises'
3
- import { join, relative } from 'node:path'
3
+ import { basename, join, relative } from 'node:path'
4
4
  import {
5
5
  INDEX_FILE,
6
6
  type IntakeItem,
@@ -13,6 +13,7 @@ import {
13
13
  export const INTAKE_REFUSALS = [
14
14
  'no-intake',
15
15
  'no-folder',
16
+ 'ambiguous-slug',
16
17
  'no-cluster',
17
18
  'no-item',
18
19
  'answered',
@@ -110,6 +111,36 @@ async function listSlugs(dir: string): Promise<string[]> {
110
111
  .sort()
111
112
  }
112
113
 
114
+ type SlugMatch =
115
+ | { readonly kind: 'matched'; readonly name: string }
116
+ | { readonly kind: 'ambiguous'; readonly names: readonly string[] }
117
+ | { readonly kind: 'none' }
118
+
119
+ /**
120
+ * A folder carries a `<nn>-<slug>` name, but a caller names the topic alone.
121
+ * An exact match wins first, since it is what a name with no ordinal, or one
122
+ * already copied in full from a listing, resolves against. Otherwise the one
123
+ * entry whose name is an ordinal ahead of the given slug wins, which is what
124
+ * lets a topic keep working as its folder's identity gains a prefix. Two or
125
+ * more such entries is a collision the caller needs told apart from a typo,
126
+ * not a folder silently picked or silently missing.
127
+ */
128
+ function matchSlug(names: readonly string[], slug: string): SlugMatch {
129
+ if (names.includes(slug)) return { kind: 'matched', name: slug }
130
+
131
+ const suffixed = names.filter(
132
+ (name) => name === `${extractOrdinal(name)}-${slug}`,
133
+ )
134
+
135
+ if (suffixed.length === 1) return { kind: 'matched', name: suffixed[0] }
136
+ if (suffixed.length > 1) return { kind: 'ambiguous', names: suffixed }
137
+ return { kind: 'none' }
138
+ }
139
+
140
+ function extractOrdinal(name: string): string {
141
+ return /^\d{2,}-/.exec(name)?.[0].slice(0, -1) ?? ''
142
+ }
143
+
113
144
  async function openFolder(
114
145
  root: string,
115
146
  slug: string,
@@ -120,17 +151,22 @@ async function openFolder(
120
151
  return refuse('no-intake', `No intake at ${relative(root, dir)}.`)
121
152
  }
122
153
 
123
- const folder = join(dir, slug)
154
+ const names = await listSlugs(dir)
155
+ const match = matchSlug(names, slug)
156
+
157
+ if (match.kind === 'none') {
158
+ return refuse('no-folder', `No intake folder named ${slug}.`, names)
159
+ }
124
160
 
125
- if (!existsSync(folder)) {
161
+ if (match.kind === 'ambiguous') {
126
162
  return refuse(
127
- 'no-folder',
128
- `No intake folder named ${slug}.`,
129
- await listSlugs(dir),
163
+ 'ambiguous-slug',
164
+ `More than one intake folder matches ${slug}.`,
165
+ match.names,
130
166
  )
131
167
  }
132
168
 
133
- return folder
169
+ return join(dir, match.name)
134
170
  }
135
171
 
136
172
  /** Counts per folder, which is what a session picks a folder to work from. */
@@ -180,7 +216,11 @@ export async function readFolder(
180
216
  const opened = await openFolder(root, slug)
181
217
  if (typeof opened !== 'string') return opened
182
218
 
183
- return { ok: true, slug, clusters: await readClusters(opened) }
219
+ return {
220
+ ok: true,
221
+ slug: basename(opened),
222
+ clusters: await readClusters(opened),
223
+ }
184
224
  }
185
225
 
186
226
  /**
@@ -272,7 +312,7 @@ export async function answerItems(
272
312
 
273
313
  return {
274
314
  ok: true,
275
- slug,
315
+ slug: basename(opened),
276
316
  cluster: name,
277
317
  path,
278
318
  answered: selections,
package/src/sync/check.ts CHANGED
@@ -3,7 +3,7 @@ import { basename, join, sep } from 'node:path'
3
3
  import { execa } from 'execa'
4
4
  import { gitEnv } from '@/git-env'
5
5
  import { createGovAdapter, rulesSourceDir } from '@/gov/adapter'
6
- import { loadGovStack } from '@/gov/stacks'
6
+ import { loadGovStack, resolveMissingRules, resolveRules } from '@/gov/stacks'
7
7
  import { createSnippetsAdapter } from '@/snippets/adapter'
8
8
  import { planSync, type ScanEntry, type SyncAdapter } from '@/sync/engine'
9
9
  import {
@@ -19,6 +19,7 @@ import {
19
19
  } from '@/sync/reverse'
20
20
  import { buildSeedsReport, type SeedsReport } from '@/sync/seeds-report'
21
21
  import {
22
+ isLegacyStamped,
22
23
  readStamp,
23
24
  type Stamp,
24
25
  stampedChain,
@@ -71,6 +72,7 @@ export interface StateCounts {
71
72
  readonly drifted: number
72
73
  readonly orphaned: number
73
74
  readonly stranded: number
75
+ readonly missing: number
74
76
  }
75
77
 
76
78
  export interface DomainReport {
@@ -139,6 +141,12 @@ const UNMEASURED_TOOLING: ToolingReport = {
139
141
 
140
142
  export interface CheckReport {
141
143
  readonly covers: readonly StampDomain[]
144
+ /**
145
+ * True when the stamp `readStamp` found sits at the retired
146
+ * `.claude/aitk.json` path rather than the current one. Read only: nothing
147
+ * in the check migrates a target's config as a side effect of reporting it.
148
+ */
149
+ readonly stampAtLegacyPath: boolean
142
150
  /** False when the target is not a toolkit project, so every section stays empty. */
143
151
  readonly managed: boolean
144
152
  readonly domains: readonly DomainReport[]
@@ -204,7 +212,7 @@ export function buildToolingReport(
204
212
  target: string,
205
213
  stamp: Stamp | undefined,
206
214
  ): ToolingReport {
207
- const chain = stampedChain(stamp)
215
+ const chain = stampedChain(stamp, 'tooling')
208
216
  const manifests = chain
209
217
  .map((name) => loadManifest(toolkitRoot, name))
210
218
  .filter((manifest) => manifest !== undefined)
@@ -270,6 +278,7 @@ export function countStates(entries: readonly ScanEntry[]): StateCounts {
270
278
  drifted: count(entries, 'drifted'),
271
279
  orphaned: count(entries, 'orphaned'),
272
280
  stranded: count(entries, 'stranded'),
281
+ missing: count(entries, 'missing'),
273
282
  }
274
283
  }
275
284
 
@@ -293,6 +302,11 @@ export function countStates(entries: readonly ScanEntry[]): StateCounts {
293
302
  * a file the project may own. `detectUnmigrated` already shipped that exact
294
303
  * false positive once, failing a push with no action that cleared it, and a
295
304
  * walk that reports `unattributed` by design would repeat it.
305
+ *
306
+ * `missing` is excluded on the same grounds `newRules` already reports on: a
307
+ * sync that adds a rule silently changes what a project is governed by, and
308
+ * nobody chose that, so gating CI on the count would pressure a target into
309
+ * adopting a rule nobody picked.
296
310
  */
297
311
  export function hasDrift(report: CheckReport): boolean {
298
312
  if (report.unmigrated.length > 0) return true
@@ -338,6 +352,7 @@ export async function buildCheckReport(
338
352
  if (!managed) {
339
353
  return {
340
354
  covers: [],
355
+ stampAtLegacyPath: isLegacyStamped(target),
341
356
  managed,
342
357
  domains: [],
343
358
  tooling: UNMEASURED_TOOLING,
@@ -353,6 +368,7 @@ export async function buildCheckReport(
353
368
 
354
369
  return {
355
370
  covers: stamp?.covers ?? [],
371
+ stampAtLegacyPath: isLegacyStamped(target),
356
372
  managed,
357
373
  domains,
358
374
  tooling: buildToolingReport(toolkitRoot, target, stamp),
@@ -360,11 +376,7 @@ export async function buildCheckReport(
360
376
  superseded: collectSuperseded(target),
361
377
  unmigrated,
362
378
  newSkills: await readNewSkills(toolkitRoot, anchors),
363
- newRules: await readNewRules(
364
- toolkitRoot,
365
- target,
366
- stampedCommit(stamp, 'governance'),
367
- ),
379
+ newRules: await readNewRules(toolkitRoot, target, stamp),
368
380
  reverse: buildReverseReport(toolkitRoot, target),
369
381
  skew: await skewRead,
370
382
  }
@@ -529,14 +541,23 @@ export function baseBands(root: string): Set<string> {
529
541
  }
530
542
 
531
543
  /**
532
- * Rules are domain-scoped, so this measures from governance's own anchor rather
533
- * than from the oldest anchor across domains the way `readNewSkills` does. A
534
- * shared anchor would let a snippets sync move the revision rules are measured
535
- * from and drop a rule out of the read.
544
+ * A recorded chain answers this without the anchor at all: `resolveMissingRules`
545
+ * compares the entitled set against what the target holds right now, so a rule
546
+ * that shipped before the target's anchor is not a permanent blind spot the
547
+ * way the diff below leaves it. This is the primary path, and it is also what
548
+ * `collectMissing` reports per file through the domain scan, so a rule the
549
+ * chain names and the target lacks reaches both surfaces the same way.
550
+ *
551
+ * The diff-and-bands path stays as the fallback for a target stamped before
552
+ * governance recorded a chain. Rules are domain-scoped there too, so it
553
+ * measures from governance's own anchor rather than from the oldest anchor
554
+ * across domains the way `readNewSkills` does. A shared anchor would let a
555
+ * snippets sync move the revision rules are measured from and drop a rule out
556
+ * of the read.
536
557
  *
537
- * A target carrying no governance anchor reports nothing. It has no date to
538
- * measure against, and diffing from the beginning of history would read every
539
- * rule the toolkit ships as new.
558
+ * A target carrying no chain and no governance anchor reports nothing. It has
559
+ * no date to measure against, and diffing from the beginning of history would
560
+ * read every rule the toolkit ships as new.
540
561
  *
541
562
  * An anchor this clone cannot resolve reports nothing by a different route and
542
563
  * says so nowhere. `read` yields an empty string on a non-zero exit, so a stamp
@@ -544,12 +565,29 @@ export function baseBands(root: string): Set<string> {
544
565
  * as a target holding everything. `readNewSkills` carries the same gap, and
545
566
  * neither has the `historyUnavailable` flag the per-domain scan uses to tell an
546
567
  * unmeasured result from a clean one.
568
+ *
569
+ * A recorded chain naming a stack the toolkit no longer ships falls through to
570
+ * the band-based path below rather than reporting the empty list an
571
+ * unresolved chain would otherwise produce. That empty list reads exactly
572
+ * like a target holding everything, which is the same failure this function
573
+ * exists to close, so a retired stack name is read the same as no chain at
574
+ * all instead of reintroducing it.
547
575
  */
548
576
  export async function readNewRules(
549
577
  root: string,
550
578
  target: string,
551
- since: string | undefined,
579
+ stamp: Stamp | undefined,
552
580
  ): Promise<string[]> {
581
+ const chain = stampedChain(stamp, 'governance')
582
+ const stack = chain[0]
583
+
584
+ if (stack !== undefined && resolveRules(root, stack).ok) {
585
+ return resolveMissingRules(root, target, chain)
586
+ .map((source) => source.rule)
587
+ .sort()
588
+ }
589
+
590
+ const since = stampedCommit(stamp, 'governance')
553
591
  if (since === undefined) return []
554
592
 
555
593
  const paths = await read(root, [
@@ -61,6 +61,10 @@ export type SyncChange =
61
61
  * separate because they need opposite treatment. A project-authored file is
62
62
  * orphaned and stays that way forever. A stamped file the toolkit no longer
63
63
  * installs to is stranded, which is a relocation waiting on a decision.
64
+ *
65
+ * `missing` is the one state the walk cannot produce on its own, since the
66
+ * walk only iterates files that exist. It comes from `collectMissing`
67
+ * instead, an adapter naming an entitled file the target does not hold.
64
68
  */
65
69
  export type EntryState =
66
70
  | 'matching'
@@ -69,12 +73,19 @@ export type EntryState =
69
73
  | 'drifted'
70
74
  | 'orphaned'
71
75
  | 'stranded'
76
+ | 'missing'
72
77
 
73
78
  export interface ScanEntry {
74
79
  readonly state: EntryState
75
80
  readonly rel: string
76
81
  /** Toolkit revision this file's content came from, when history proved it. */
77
82
  readonly since?: string
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.
87
+ */
88
+ readonly notice?: string
78
89
  }
79
90
 
80
91
  export interface SyncPlan {
@@ -119,6 +130,12 @@ export interface SyncAdapter {
119
130
  locateSource(file: InstalledFile): string | undefined
120
131
  /** Surfaces the file walk cannot see, such as a retired doc to delete. */
121
132
  collectRetired?(target: string): RetiredSurface[]
133
+ /**
134
+ * Entitled files the walk cannot see because they do not exist yet.
135
+ * Reported as `missing` and queued as no change, since installing one
136
+ * changes what the project is governed by and stays a separate command.
137
+ */
138
+ collectMissing?(target: string): RetiredSurface[]
122
139
  /** Dropped from the walk, so neither matching nor orphaned. */
123
140
  isExcluded?(file: InstalledFile): boolean
124
141
  /**
@@ -210,6 +227,10 @@ export function planSync(adapter: SyncAdapter, target: string): SyncPlan {
210
227
 
211
228
  entries.push(...strandedByRelocation(target, hashes, walked))
212
229
 
230
+ for (const surface of adapter.collectMissing?.(target) ?? []) {
231
+ entries.push({ state: 'missing', rel: surface.rel, notice: surface.notice })
232
+ }
233
+
213
234
  const retired = adapter.collectRetired?.(target) ?? []
214
235
  for (const surface of retired) {
215
236
  changes.push({ kind: 'delete', dest: surface.path, rel: surface.rel })
@@ -394,6 +415,10 @@ function report(adapter: SyncAdapter, plan: SyncPlan): void {
394
415
  logWarn(`${entry.rel} (locally customized)`)
395
416
  else if (entry.state === 'stranded')
396
417
  logWarn(`${entry.rel} (installed here by an older toolkit, now moved)`)
418
+ else if (entry.state === 'missing')
419
+ logWarn(
420
+ entry.notice ?? `${entry.rel} (listed by the stack, not installed)`,
421
+ )
397
422
  else logWarn(`${entry.rel} (not in toolkit source, skipping)`)
398
423
  }
399
424
 
package/src/sync/stamp.ts CHANGED
@@ -38,10 +38,12 @@ export interface DomainStamp {
38
38
  readonly syncedAt: string
39
39
  readonly files: DomainHashes
40
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.
41
+ * Stack names the install resolved. Tooling records the full ancestor chain,
42
+ * nearest stack first, because a stack that extends another cannot be
43
+ * reinstalled from its leaf alone, and a `--skip` run installs fewer layers
44
+ * than the leaf's own chain would reproduce. Governance records the single
45
+ * stack `aitk gov install` was given, since `resolveRules` walks its
46
+ * ancestors internally and a reader needs only the leaf to ask it again.
45
47
  */
46
48
  readonly chain?: readonly string[]
47
49
  }
@@ -56,6 +58,24 @@ export function stampPath(target: string): string {
56
58
  return join(target, '.claude', 'aitk', 'config.json')
57
59
  }
58
60
 
61
+ /**
62
+ * Where `106115ba` moved the stamp from. No migration shipped with that move,
63
+ * so a target stamped before it still carries its config here, and `readStamp`
64
+ * falls back to this path when the current one is absent.
65
+ */
66
+ export function legacyStampPath(target: string): string {
67
+ return join(target, '.claude', 'aitk.json')
68
+ }
69
+
70
+ /**
71
+ * Whether `readStamp` would resolve to the retired path, so a caller can
72
+ * report that a target's config still sits there. False when neither path
73
+ * exists, since there is nothing to migrate off of.
74
+ */
75
+ export function isLegacyStamped(target: string): boolean {
76
+ return !existsSync(stampPath(target)) && existsSync(legacyStampPath(target))
77
+ }
78
+
59
79
  export function hashContent(content: Buffer | string): string {
60
80
  return `sha256:${createHash('sha256').update(content).digest('hex')}`
61
81
  }
@@ -75,9 +95,22 @@ export function toStampKey(rel: string): string {
75
95
  /**
76
96
  * A missing or corrupt stamp reads as absent rather than failing, which is what
77
97
  * keeps every unstamped target on the existing unattributed path.
98
+ *
99
+ * Falls back to the retired path only when the current one does not exist,
100
+ * read only: nothing here migrates a target's config as a side effect of a
101
+ * report. The check is existence rather than a successful parse, so a
102
+ * corrupt current stamp reads as absent rather than silently serving the
103
+ * retired one beside it. `isLegacyStamped` tests the same existence check,
104
+ * which is what keeps the two agreeing on which path a corrupt current file
105
+ * was read from.
78
106
  */
79
107
  export function readStamp(target: string): Stamp | undefined {
80
- const path = stampPath(target)
108
+ return existsSync(stampPath(target))
109
+ ? readStampFile(stampPath(target))
110
+ : readStampFile(legacyStampPath(target))
111
+ }
112
+
113
+ function readStampFile(path: string): Stamp | undefined {
81
114
  if (!existsSync(path)) return undefined
82
115
 
83
116
  try {
@@ -105,17 +138,22 @@ export function stampedHashes(
105
138
  }
106
139
 
107
140
  /**
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.
141
+ * The stack chain a domain's install last recorded. An empty result is the
142
+ * state every target predating that domain's chain recording sits in, and a
143
+ * reader treats it as unmeasured rather than as clean.
111
144
  */
112
- export function stampedChain(stamp: Stamp | undefined): readonly string[] {
113
- return stamp?.domains.tooling?.chain ?? []
145
+ export function stampedChain(
146
+ stamp: Stamp | undefined,
147
+ domain: StampDomain,
148
+ ): readonly string[] {
149
+ return stamp?.domains[domain]?.chain ?? []
114
150
  }
115
151
 
116
152
  /**
117
- * Replaces one domain's record and leaves the others untouched, because domains
118
- * install and sync independently but share the one file.
153
+ * Replaces one domain's file hashes and leaves the others, including that
154
+ * domain's own chain, untouched. Domains install and sync independently but
155
+ * share the one file, and a chain an install recorded is a separate fact a
156
+ * later file-only sync must not erase.
119
157
  */
120
158
  export async function writeStamp(
121
159
  target: string,
@@ -127,37 +165,35 @@ export async function writeStamp(
127
165
  }
128
166
 
129
167
  /**
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.
168
+ * Records the stack chain an install resolved and leaves that domain's file
169
+ * hashes untouched. Tooling calls this with no files ever recorded, since
170
+ * `src/tooling/` never runs the sync engine and the chain is its whole record.
171
+ * Governance calls it alongside `writeStamp`, since it records both.
133
172
  */
134
173
  export async function writeChainStamp(
135
174
  target: string,
136
- toolkitRoot: string,
175
+ source: StampSource,
137
176
  chain: readonly string[],
138
177
  now: Date,
139
178
  ): Promise<void> {
140
- await putDomain(
141
- target,
142
- { domain: 'tooling', toolkitRoot },
143
- { files: {}, chain: [...chain] },
144
- now,
145
- )
179
+ await putDomain(target, source, { chain: [...chain] }, now)
146
180
  }
147
181
 
148
182
  async function putDomain(
149
183
  target: string,
150
184
  source: StampSource,
151
- payload: Pick<DomainStamp, 'files' | 'chain'>,
185
+ payload: Partial<Pick<DomainStamp, 'files' | 'chain'>>,
152
186
  now: Date,
153
187
  ): Promise<void> {
154
188
  const previous = readStamp(target)
189
+ const previousRecord = previous?.domains[source.domain]
155
190
  const commit = await toolkitCommit(source.toolkitRoot)
156
191
 
157
192
  const record: DomainStamp = {
158
193
  ...(commit === undefined ? {} : { commit }),
159
194
  syncedAt: now.toISOString(),
160
- ...payload,
195
+ files: payload.files ?? previousRecord?.files ?? {},
196
+ ...resolveChainField(payload.chain, previousRecord?.chain),
161
197
  }
162
198
 
163
199
  const domains = sortDomains({
@@ -203,6 +239,19 @@ async function readCommit(root: string): Promise<string | undefined> {
203
239
  : undefined
204
240
  }
205
241
 
242
+ /**
243
+ * A write naming no chain keeps the domain's previous one rather than dropping
244
+ * it, since `writeStamp` and `writeChainStamp` each touch one half of a
245
+ * governance record and neither should erase what the other wrote.
246
+ */
247
+ function resolveChainField(
248
+ chain: readonly string[] | undefined,
249
+ previous: readonly string[] | undefined,
250
+ ): Pick<DomainStamp, 'chain'> {
251
+ const resolved = chain ?? previous
252
+ return resolved === undefined ? {} : { chain: resolved }
253
+ }
254
+
206
255
  /** Deterministic key order keeps a re-sync diff empty and a merge conflict local. */
207
256
  function sortKeys(hashes: DomainHashes): DomainHashes {
208
257
  return Object.fromEntries(
@@ -32,7 +32,7 @@ export async function recordToolingChain(
32
32
 
33
33
  await writeChainStamp(
34
34
  target,
35
- toolkitRoot,
35
+ { domain: 'tooling', toolkitRoot },
36
36
  chain.map((manifest) => manifest.name),
37
37
  now,
38
38
  )
@@ -1,17 +1,17 @@
1
1
  ---
2
2
  title: Groundwork reference
3
- description: Folder layout, reserved numbering, frontmatter and dating, required file contents, and conventions for a measurement track
3
+ description: Folder layout, ordinal naming, reserved numbering, frontmatter and dating, required file contents, and conventions for a measurement track
4
4
  ---
5
5
 
6
6
  # Groundwork reference
7
7
 
8
- Applies to a groundwork track at `.claude/groundwork/<slug>/`. A track measures one question that has to be settled before anyone can plan against it. The numbering is the table of contents, so a reader opens the folder and knows where to start and what follows without an index maintained inside each file.
8
+ Applies to a groundwork track at `.claude/groundwork/<nn>-<slug>/`. A track measures one question that has to be settled before anyone can plan against it. The numbering inside the folder is the table of contents, so a reader opens the folder and knows where to start and what follows without an index maintained inside each file.
9
9
 
10
10
  The folder is gitignored and unbacked. No check reaches its contents and no history recovers a deleted one, so every rule here holds only while a session reads it, and the handoff file has to be self-contained.
11
11
 
12
12
  ## Scope
13
13
 
14
- Governs a groundwork track under `.claude/groundwork/<slug>/`: folder layout, reserved numbering, frontmatter and dating, what each required file holds, and the conventions a track keeps.
14
+ Governs a groundwork track under `.claude/groundwork/<nn>-<slug>/`: folder layout, ordinal naming, reserved numbering, frontmatter and dating, what each required file holds, and the conventions a track keeps.
15
15
 
16
16
  Does not govern:
17
17
 
@@ -22,6 +22,13 @@ Does not govern:
22
22
  - Headings, punctuation, word choice, and file references: `markdown.md`
23
23
  - When a project opens a track at all, and the procedure that runs one, which belong to the surface driving it
24
24
 
25
+ ## Folder name
26
+
27
+ - Name the folder `<nn>-<slug>`, a two-digit zero-padded ordinal followed by a kebab-case slug.
28
+ - Take the ordinal from the highest one already present across both `.claude/groundwork/` and `.claude/intake/`, incremented. A listing then sorts by when each folder opened rather than alphabetically, and the count includes both kinds because the two share one creation-order line.
29
+ - With neither folder holding an entry, the first one opened takes `01`. Do not read this off the numbering inside a track, which starts at `00` on a large one and disagrees with intake's own first file.
30
+ - Never renumber an existing folder. The ordinal is the order it opened, and a later reader cites it by that name.
31
+
25
32
  ## What a working track looks like
26
33
 
27
34
  A track works when a session that has never seen it re-enters from the folder alone and can answer each of these: