@erclx/aitk 3.12.0 → 3.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.12.0",
4
+ "version": "3.12.1",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -14,6 +14,7 @@ Two failure modes cost more than the rest. A track that fans out to subagents re
14
14
  ## Must
15
15
 
16
16
  - Detect open, resume, and close from the folder itself, matching the topic against tracks already there before deriving a slug
17
+ - Name a newly opened folder with a two-digit ordinal ahead of the slug, taken from the highest one already present across groundwork and intake
17
18
  - Apply the qualifying test in open mode alone, so a track already measured is not refused by the test that admitted it
18
19
  - Measure the current state now rather than carrying a figure from a previous session
19
20
  - Carry a lean and the finding that would overturn it on every open question, or admit that a measurement is missing
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: claude-groundwork
3
- description: Opens and runs a numbered groundwork folder under `.claude/groundwork/<slug>/` for a topic that has to be measured before it can be planned. Detects open, resume, and close from the folder itself. Use when asked to "research X", "dig into X", "work out what we should do about X", "measure this before we commit", or "open a groundwork folder". Do NOT use to write a feature plan or to implement. That is `claude-feature`.
3
+ description: Opens and runs a numbered groundwork folder under `.claude/groundwork/<nn>-<slug>/` for a topic that has to be measured before it can be planned. Detects open, resume, and close from the folder itself. Use when asked to "research X", "dig into X", "work out what we should do about X", "measure this before we commit", or "open a groundwork folder". Do NOT use to write a feature plan or to implement. That is `claude-feature`.
4
4
  ---
5
5
 
6
6
  # Claude groundwork
@@ -19,10 +19,10 @@ Read `${CLAUDE_SKILL_DIR}/../../standards/groundwork.md` before writing any file
19
19
 
20
20
  ## Write scope
21
21
 
22
- - Write only inside `.claude/groundwork/<slug>/`. A feature plan, source changes, a standard, a rule, and a reference doc all live outside that folder, so this one rule forbids every one of them.
22
+ - Write only inside `.claude/groundwork/<nn>-<slug>/`. A feature plan, source changes, a standard, a rule, and a reference doc all live outside that folder, so this one rule forbids every one of them.
23
23
  - One exception, at close only: write one task file recording what the track concluded.
24
24
  - A second exception, for what a spike reads: write an input under `.claude/.tmp/groundwork-fixtures/<slug>/`. Keep it out of `.claude/groundwork/` so mode detection never matches a fixture as a track. A fixture a headless run is pointed at goes outside the repository instead, per the rule in `## Running a spike`.
25
- - What a spike produces stays inside the track rather than joining the two exceptions above: write evidence a spike file cites under `.claude/groundwork/<slug>/evidence/`, which the first rule already permits. Mode detection matches entries at the top level of the tracks directory, so the sibling the fixtures rule guards against cannot be a folder nested inside a track, and the scratch path holds only what can be deleted without loss, which a recording a finding rests on is not.
25
+ - What a spike produces stays inside the track rather than joining the two exceptions above: write evidence a spike file cites under `.claude/groundwork/<nn>-<slug>/evidence/`, which the first rule already permits. Mode detection matches entries at the top level of the tracks directory, so the sibling the fixtures rule guards against cannot be a folder nested inside a track, and the scratch path holds only what can be deleted without loss, which a recording a finding rests on is not.
26
26
  - Reading is not restricted. External research is in scope, so read documentation, comparable projects, and papers whenever a live question needs them.
27
27
  - Every claim about a source outside the project carries a link to it. A source found and not read is listed as a lead and is never cited.
28
28
  - Treat the folder as gitignored and unbacked. It dies with the machine, so `07-next-session.md` repeats what it needs instead of pointing at its siblings.
@@ -41,11 +41,11 @@ Record method, result, measured cost, and caveats in `08-spikes.md`. Put whateve
41
41
 
42
42
  ## Step 1: detect the mode
43
43
 
44
- List `.claude/groundwork/` from the project root and match the topic against the tracks already there before deriving anything. A resume pass rarely phrases the topic the way the folder was named, so a fresh slug derived from the wording would miss a live track and restart it.
44
+ List `.claude/groundwork/` from the project root and match the topic against the slug half of each `<nn>-<slug>` folder already there before deriving anything. A resume pass rarely phrases the topic the way the folder was named, so a fresh slug derived from the wording would miss a live track and restart it.
45
45
 
46
46
  Never match against `.claude/` itself. That directory holds every other workflow surface, so a topic matched there lands on a folder that was never a track.
47
47
 
48
- With no match, derive a kebab-case slug named for the subject rather than the activity. Prefer `ts-migration` over `migration-research`. Then route on `.claude/groundwork/<slug>/`:
48
+ With no match, derive a kebab-case slug named for the subject rather than the activity. Prefer `ts-migration` over `migration-research`. Also list `.claude/intake/` and take `<nn>` as the highest ordinal present across both listings, incremented, per `${CLAUDE_SKILL_DIR}/../../standards/groundwork.md`. Then route on `.claude/groundwork/<nn>-<slug>/`:
49
49
 
50
50
  - Folder absent: open
51
51
  - Folder present without `06-decision.md`: resume
@@ -72,7 +72,7 @@ The standard sets the open question format and requires it inside a topic file a
72
72
 
73
73
  ## Open mode
74
74
 
75
- 1. Create `.claude/groundwork/<slug>/`.
75
+ 1. Create `.claude/groundwork/<nn>-<slug>/`, with `<nn>` and `<slug>` as derived in Step 1.
76
76
  2. Write `README.md` first. Writing it first forces the question of what the track is for.
77
77
  3. Write `01-current-state.md` by measuring now. Never carry a figure from a previous session or from recall without re-measuring it. Measure only what an open question in the folder needs. A number with no question attached is how groundwork turns into the work.
78
78
  4. Write `00-scope.md` when the track is large enough to run away. Skip it on a small track.
@@ -103,12 +103,12 @@ Emit the full relative path from the project root for every file written or upda
103
103
  Open and resume:
104
104
 
105
105
  ```plaintext
106
- 📂 Opened .claude/groundwork/<slug>/
106
+ 📂 Opened .claude/groundwork/<nn>-<slug>/
107
107
 
108
108
  **Written:**
109
109
 
110
- - `.claude/groundwork/<slug>/README.md`
111
- - `.claude/groundwork/<slug>/01-current-state.md`
110
+ - `.claude/groundwork/<nn>-<slug>/README.md`
111
+ - `.claude/groundwork/<nn>-<slug>/01-current-state.md`
112
112
 
113
113
  **Open questions:**
114
114
 
@@ -122,12 +122,12 @@ Use `📂 Resumed` in place of `📂 Opened` on a resume pass.
122
122
  Close:
123
123
 
124
124
  ```plaintext
125
- ✅ Closed .claude/groundwork/<slug>/
125
+ ✅ Closed .claude/groundwork/<nn>-<slug>/
126
126
 
127
127
  **Written:**
128
128
 
129
- - `.claude/groundwork/<slug>/06-decision.md`
130
- - `.claude/groundwork/<slug>/07-next-session.md`
129
+ - `.claude/groundwork/<nn>-<slug>/06-decision.md`
130
+ - `.claude/groundwork/<nn>-<slug>/07-next-session.md`
131
131
 
132
132
  **Uncited external claims:** <count, or none>
133
133
 
@@ -22,6 +22,7 @@ A session with no numbering convention re-decides the folder shape per dump, so
22
22
  - Close every item with a verdict, and pair every open question with a suggested pick
23
23
  - Treat an empty operator slot as unread rather than as agreement
24
24
  - Reserve the index number and carry the domain in every other filename, leaving the rest of the numbering as read order
25
+ - Name a newly opened folder with a two-digit ordinal ahead of the slug, taken from the highest one already present across intake and groundwork
25
26
  - Keep answers on items alone, with the index pointing at them
26
27
  - Name the heading and the act beside every path the pass wrote
27
28
  - Confine writes to the intake folder
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: claude-intake
3
- description: Files a raw brain dump into a numbered intake folder under `.claude/intake/<slug>/`, one item per finding carrying a measured problem, a proposed fix, and a verdict. Use when asked to "file this dump", "triage my notes", "work through this list", "sort out this brain dump", or "run an intake pass". Do NOT use for one question that has to be measured before anyone can plan it. That is `claude-groundwork`.
3
+ description: Files a raw brain dump into a numbered intake folder under `.claude/intake/<nn>-<slug>/`, one item per finding carrying a measured problem, a proposed fix, and a verdict. Use when asked to "file this dump", "triage my notes", "work through this list", "sort out this brain dump", or "run an intake pass". Do NOT use for one question that has to be measured before anyone can plan it. That is `claude-groundwork`.
4
4
  ---
5
5
 
6
6
  # Claude intake
@@ -29,7 +29,7 @@ Using the wrong one fails in two shapes. Intake on a question that needs measuri
29
29
 
30
30
  ## Write scope
31
31
 
32
- - Write only inside `.claude/intake/<slug>/`. A plan file, a task file, a source change, a standard, and a rule all live outside that folder, so this one rule forbids every one of them.
32
+ - Write only inside `.claude/intake/<nn>-<slug>/`. A plan file, a task file, a source change, a standard, and a rule all live outside that folder, so this one rule forbids every one of them.
33
33
  - There is no exception. Promoting an item onto the board runs through `claude-tasks` after the operator has answered, which is a separate invocation.
34
34
  - Reading is unrestricted inside the project. Measuring is the work.
35
35
  - Treat the folder as gitignored and unbacked. No check reaches its contents, so every rule stated here holds only while a session reads it.
@@ -38,11 +38,11 @@ Nothing outside this body carries the write-scope floor, and no path-scoped rule
38
38
 
39
39
  ## Step 1: detect open or resume
40
40
 
41
- List `.claude/intake/` from the project root and match the topic against the folders already there before deriving a slug. A second pass over the same subject rarely phrases the topic the way the folder was named, so a fresh slug would open a duplicate beside a live folder.
41
+ List `.claude/intake/` from the project root and match the topic against the slug half of each `<nn>-<slug>` folder already there before deriving a slug. A second pass over the same subject rarely phrases the topic the way the folder was named, so a fresh slug would open a duplicate beside a live folder.
42
42
 
43
43
  Never match against `.claude/` itself. That directory holds every other workflow surface, so a topic matched there lands on a folder that was never an intake.
44
44
 
45
- With no match, derive a kebab-case slug named for the subject rather than the activity. Prefer `toolkit-overview` over `august-triage`. An absent folder opens, and a present one resumes by appending items and revising verdicts the tree has moved under.
45
+ With no match, derive a kebab-case slug named for the subject rather than the activity. Prefer `toolkit-overview` over `august-triage`. Also list `.claude/groundwork/` and take `<nn>` as the highest ordinal present across both listings, incremented, per `${CLAUDE_SKILL_DIR}/../../standards/intake.md`. An absent folder opens, and a present one resumes by appending items and revising verdicts the tree has moved under.
46
46
 
47
47
  ## Step 2: orient
48
48
 
@@ -90,12 +90,12 @@ Emit the full relative path from the project root for every file written, and na
90
90
  A file the pass only read gets no line, which is what keeps the block short.
91
91
 
92
92
  ```plaintext
93
- 📂 Opened .claude/intake/<slug>/
93
+ 📂 Opened .claude/intake/<nn>-<slug>/
94
94
 
95
95
  **Filed:**
96
96
 
97
- - `.claude/intake/<slug>/05-coverage.md` gains items 6 to 8 under a new `## What the merge gate covers`
98
- - `.claude/intake/<slug>/00-overview.md` cluster rows and verdict counts updated
97
+ - `.claude/intake/<nn>-<slug>/05-coverage.md` gains items 6 to 8 under a new `## What the merge gate covers`
98
+ - `.claude/intake/<nn>-<slug>/00-overview.md` cluster rows and verdict counts updated
99
99
 
100
100
  **Routing:** <N> plan-ready, <N> groundwork candidates, <N> already settled
101
101
 
@@ -16,6 +16,15 @@ 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
+ When the target's install recorded a stack, `aitk gov sync` also reports a
20
+ rule that stack lists and `.claude/rules/` does not hold, as a `missing` entry
21
+ carrying no change. This is what makes a target whose recorded sync point
22
+ postdates a rule joining its stack still see that rule: the report reads the
23
+ target's current entitlement against its current tree rather than diffing
24
+ from an anchor a later sync could advance past the rule's own commit. A
25
+ target whose install predates the recorded chain falls back to the same
26
+ band-inference `newRules` uses in `aitk sync --check`.
27
+
19
28
  There is no `aitk standards sync` and no `aitk standards install`. The corpus
20
29
  installs into no project, so the domain has nothing in a target to reconcile.
21
30
  `aitk standards <name>` prints one, resolving `standards/` at the working root
@@ -94,16 +103,23 @@ It also refuses a target whose working tree is dirty, so commit or stash first.
94
103
  and is safe to run at any time. Each file is classified as `stale` when it still
95
104
  matches what the toolkit installed, `customized` when the project edited it,
96
105
  `stranded` when it sits at a path the toolkit no longer installs to, `orphaned`
97
- when the project authored it, or `drifted` when no stamp covers it.
106
+ when the project authored it, or `drifted` when no stamp covers it. Governance
107
+ also reports `missing`, for a rule the target's recorded stack lists that its
108
+ tree does not hold at all.
98
109
 
99
110
  Use `--json` for the machine-readable report and `--exit-code` to fail a CI job. Orphaned
100
- files are excluded from that exit code, since a project-authored rule never
101
- converges. Attribution reads `.claude/aitk/config.json`, which every install and
102
- sync writes.
103
-
104
- A target installed before stamping shipped has no such file, and neither does one
105
- stamped before the file moved into `.claude/aitk/`, which is read as absent
106
- rather than migrated. Both fall back to the toolkit's own git history.
111
+ and missing files are both excluded from that exit code: a project-authored
112
+ rule never converges, and a sync that added a missing one silently changes
113
+ what the project is governed by, which stays a separate command an operator
114
+ chooses to run. Attribution reads `.claude/aitk/config.json`, which every
115
+ install and sync writes.
116
+
117
+ A target installed before stamping shipped has no such file, and neither does
118
+ that fallback do anything to migrate it. A target stamped before the file
119
+ moved into `.claude/aitk/` still carries it at the retired
120
+ `.claude/aitk.json`, and `aitk sync --check` reads that path when the current
121
+ one is absent, reporting it rather than moving it. Only a target carrying
122
+ neither path falls back to the toolkit's own git history.
107
123
  Installed content matching any
108
124
  version that history ever published proves the file is untouched, so it reports
109
125
  `stale` naming the commit it came from, and content matching no published
@@ -207,10 +223,10 @@ file and proposes relocating the lot.
207
223
 
208
224
  #### Rules the target never received
209
225
 
210
- `newRules` names a rule the toolkit authored after this target last synced
211
- governance. A sync refreshes the files a target already holds and adds none, so
212
- without this section a project's rule set freezes at its install date while every
213
- file it does hold reports as current. That is the report's most confident wrong
226
+ `newRules` names a rule the target could receive and its tree does not hold. A
227
+ sync refreshes the files a target already holds and adds none, so without this
228
+ section a project's rule set freezes at its install date while every file it
229
+ does hold reports as current. That is the report's most confident wrong
214
230
  answer, since a clean result reads as a target holding everything the toolkit
215
231
  publishes.
216
232
 
@@ -225,11 +241,22 @@ installs, nothing counts toward `--exit-code`, and a target can read the list an
225
241
  act on none of it. The value stops at an operator reading it, which is the same
226
242
  contract the skills list already sets.
227
243
 
228
- The measurement anchors on governance's own stamp rather than on the oldest
229
- anchor across domains, since rules are domain-scoped and a shared anchor would
230
- let a snippets sync move the revision rules are measured from. A target carrying
231
- no governance anchor reports nothing at all: it has no date to measure against,
232
- and diffing from the start of history would read the whole catalog as new.
244
+ Since `aitk gov install` records the stack it resolved, a target carrying that
245
+ record answers this by comparing its current entitlement against its current
246
+ tree, with no anchor and no git diff involved. That is what lets the section
247
+ name a rule that shipped before the target's last sync: the anchor a sync
248
+ advances plays no part in the read, where an anchor-bound diff can never see a
249
+ rule on the far side of a window a later sync moved past it. The per-file
250
+ `missing` state `aitk gov sync` reports comes from the same comparison, so a
251
+ rule the chain lists reaches both surfaces the same way.
252
+
253
+ A target stamped before governance recorded a chain falls back to the older
254
+ band-inference read below. The measurement there anchors on governance's own
255
+ stamp rather than on the oldest anchor across domains, since rules are
256
+ domain-scoped and a shared anchor would let a snippets sync move the revision
257
+ rules are measured from. A target carrying no chain and no governance anchor
258
+ reports nothing at all: it has no date to measure against, and diffing from the
259
+ start of history would read the whole catalog as new.
233
260
 
234
261
  An anchor this toolkit cannot resolve reports nothing by the same route, and that
235
262
  one is not visible. A stamp naming a revision the running clone has never seen,
@@ -238,22 +265,22 @@ fails the read and yields an empty list rather than a stated absence. It looks
238
265
  identical to a target holding every rule the toolkit publishes. `newSkills`
239
266
  behaves the same way, and neither carries the `historyUnavailable` flag the
240
267
  per-domain scan uses to separate the two. Treat an empty section on a toolkit
241
- that is not a full clone as unmeasured rather than clean.
268
+ that is not a full clone as unmeasured rather than clean. This gap does not
269
+ reach the chain-based read above, since it consults no anchor at all.
242
270
 
243
- Entitlement is filtered, because a stack does not receive every rule. The base
244
- stack takes the `core` and `claude` folders whole and every other stack extends
245
- it with individually named rules, so an unfiltered list would tell a base
246
- consumer about rules it can never receive and train the reader to skip the
247
- section.
271
+ In the fallback, entitlement is filtered, because a stack does not receive
272
+ every rule. The base stack takes the `core` and `claude` folders whole and
273
+ every other stack extends it with individually named rules, so an unfiltered
274
+ list would tell a base consumer about rules it can never receive and train the
275
+ reader to skip the section.
248
276
 
249
277
  The filter accepts a band on either of two grounds. A folder the base stack takes
250
278
  whole is entitled to every target, read from the stack file so a folder added to
251
279
  base later needs no code change. Every other band is read off the folders the
252
- target already carries, since `aitk gov install` records file hashes and never
253
- the stack it resolved, which leaves the installed tree as the only evidence of
254
- what a chain reached. One band can be reached by more than one stack, so the test
255
- over-reports inside a folder the target holds, which costs a line where
256
- under-reporting would cost the section its point.
280
+ target already carries, which is the fallback's only evidence of what a target
281
+ was entitled to before it recorded a chain. One band can be reached by more than
282
+ one stack, so the test over-reports inside a folder the target holds, which
283
+ costs a line where under-reporting would cost the section its point.
257
284
 
258
285
  A rule the target already holds is dropped by name. That is what keeps a rule the
259
286
  toolkit moved between band folders out of the list, since a rename reaches this
@@ -9,6 +9,8 @@ description: Reading intake folder counts and items, the three read states an it
9
9
 
10
10
  `aitk intake list` reports the intake folders under `.claude/intake/`, or the items one folder holds. It reads and never writes, because an answer belongs to the operator and a verb that filled one would decide what the folder exists to ask.
11
11
 
12
+ A folder carries a two-digit ordinal ahead of its slug, as in `21-toolkit-overview`, so a listing sorts by when each opened. A bare slug still resolves: passing `toolkit-overview` matches the one folder whose name is an ordinal ahead of it, and the folder's real name is what every command reports back.
13
+
12
14
  ```bash
13
15
  aitk intake list
14
16
  aitk intake list toolkit-overview --unread --json
@@ -58,7 +60,7 @@ One call writes one cluster. A call per selection is the alternative, and severa
58
60
 
59
61
  An item already carrying an answer refuses rather than being overwritten, and one filled item refuses the whole batch, so a partly applied write never lands. Drop the named item and send the rest.
60
62
 
61
- Exit codes: `0` every named item now carries its answer, `1` refused. The `reason` field carries `no-intake`, `no-folder`, `no-cluster`, `no-item`, `answered`, or `bad-input`.
63
+ Exit codes: `0` every named item now carries its answer, `1` refused. The `reason` field carries `no-intake`, `no-folder`, `ambiguous-slug`, `no-cluster`, `no-item`, `answered`, or `bad-input`. A bare slug matching more than one ordinal-prefixed folder refuses as `ambiguous-slug` rather than `no-folder`, naming every match in `detail`.
62
64
 
63
65
  An exit code says nothing about a call made from a session, since a shell profile may wrap the binary in a function taking its status from a later command. Read the record's `reason` rather than the exit when a skill consumes this, which matters most here because the verb writes.
64
66
 
@@ -44,8 +44,8 @@ One session works for most features. Prefer splitting across two sessions only w
44
44
 
45
45
  Work in Claude Code directly. It reads `CLAUDE.md` automatically and has full file access, no pasting needed.
46
46
 
47
- - When the input is a pile of findings rather than one feature, invoke `aitk:claude-intake` first. It files the dump into `.claude/intake/<slug>/`, one item per finding carrying a problem measured against the tree, a proposed fix, and a verdict, then names which items are plan-ready, which need measuring, and which are already settled.
48
- - When the current state is unmeasured and more than one approach is live, invoke `aitk:claude-groundwork` first. It opens a track folder under `.claude/groundwork/<slug>/` and ends in a decision, which may be to do nothing. Skip it when the approach is already settled.
47
+ - When the input is a pile of findings rather than one feature, invoke `aitk:claude-intake` first. It files the dump into `.claude/intake/<nn>-<slug>/`, one item per finding carrying a problem measured against the tree, a proposed fix, and a verdict, then names which items are plan-ready, which need measuring, and which are already settled.
48
+ - When the current state is unmeasured and more than one approach is live, invoke `aitk:claude-groundwork` first. It opens a track folder under `.claude/groundwork/<nn>-<slug>/` and ends in a decision, which may be to do nothing. Skip it when the approach is already settled.
49
49
  - Invoke `aitk:claude-feature` to scan for code-level conflicts and ambiguities, confirm approach before proceeding
50
50
  - Implement the feature, then Claude Code runs the commands defined in `CLAUDE.md`, fixes failures, and iterates until all pass
51
51
  - For UI changes, invoke `aitk:claude-ui-test` to generate and run Playwright e2e tests
@@ -124,7 +124,7 @@ The report opens by naming the binary running it. The installed version reads ag
124
124
 
125
125
  A `stale` file still matches what the toolkit installed, so the update is mechanical. A `customized` file carries local edits, so taking the upstream version is a decision and `aitk:claude-seed-sync` is the tool for it. A `stranded` file sits where an older toolkit installed it and the toolkit has since moved, which is what `aitk:migration-standards` handles.
126
126
 
127
- That attribution comes from `.claude/aitk/config.json`, a stamp every install and sync writes. Snippets and governance record a hash per installed file. Tooling records the stack chain it resolved instead, since its install runs no per-file walk to attribute.
127
+ That attribution comes from `.claude/aitk/config.json`, a stamp every install and sync writes. A target stamped before that path shipped is read from the retired `.claude/aitk.json` instead, reported rather than migrated. Snippets and governance record a hash per installed file. Governance also records the stack `aitk gov install` was given, and tooling records the stack chain it resolved instead of any file hash, since its install runs no per-file walk to attribute.
128
128
 
129
129
  Each domain holds its own toolkit commit, so syncing governance today does not move the revision snippets measures against, and each domain reports the upstream commits touching its own source path. Running any sync stamps that domain, and the report names the ones still unstamped.
130
130
 
@@ -136,13 +136,15 @@ That third one matters most on an older project. Before it existed, a target hol
136
136
 
137
137
  #### Rules you never received
138
138
 
139
- A sync refreshes the files you already hold and adds none, so your rule set is frozen at the date you installed governance while every file in it reports as current. `newRules` names the rules the toolkit has authored since then, which is the one section that reads your absence rather than your contents.
139
+ A sync refreshes the files you already hold and adds none, so your rule set is frozen at the date you installed governance while every file in it reports as current. `newRules` names a rule you could receive that your tree does not hold, which is the one section that reads your absence rather than your contents. `aitk gov sync` reports the same rules per file, marked `missing`, so you see them either way you check.
140
140
 
141
141
  Take a clean section as reporting rather than as delivering. Nothing here installs, and it counts toward nothing, so pick the rules up with `aitk gov install <stack>` or take one with `--add <rule>`. That separation is deliberate: adopting a rule changes what your project is governed by, which is a choice a sync has no business making for you.
142
142
 
143
- The list is filtered to what your stack can receive, read off the rule folders you already carry plus the folders the base stack takes whole. A rule under `lang/` or `ui/` belongs to some stacks and not others, so an unfiltered list would name rules you can never install. A target that has never stamped governance reports nothing at all, since there is no date to measure against.
143
+ Since `aitk gov install` records the stack you gave it, the list is read by comparing that stack's current rules against what you hold right now, with no date involved at all. That is what lets it name a rule the toolkit shipped before you last synced: nothing here depends on when you installed. A target stamped before this recording shipped falls back to the older read below.
144
144
 
145
- An empty section is not proof either way when the toolkit running the check is not a full git clone. The read needs history to reach your anchor, and an install from the registry ships none, so the section goes quiet rather than saying it could not measure. Run the check from a clone before reading a clean result as a complete one.
145
+ The fallback filters to what your stack can receive, read off the rule folders you already carry plus the folders the base stack takes whole. A rule under `lang/` or `ui/` belongs to some stacks and not others, so an unfiltered list would name rules you can never install. A target with neither a recorded stack nor a governance anchor reports nothing at all, since there is no date to measure against either.
146
+
147
+ In the fallback, an empty section is not proof either way when the toolkit running the check is not a full git clone. The read needs history to reach your anchor, and an install from the registry ships none, so the section goes quiet rather than saying it could not measure. Run the check from a clone before reading a clean result as a complete one. The stack-based read above is untouched by this, since it consults no history.
146
148
 
147
149
  This also closes the case where a rule arrives citing a sibling you do not have. A sync refreshing a rule can land a version pointing at a file it never installs, and the section names the missing sibling rather than the broken citation, which is enough to act on.
148
150
 
@@ -177,7 +179,7 @@ Standards take no part in that run. Nothing installed them, so there is no copy
177
179
  ### Targeted
178
180
 
179
181
  - 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.
180
- - Governance rules already installed: `aitk gov sync <path>` diffs and applies, and never adds new rules
182
+ - 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.
181
183
  - Tooling configs and seeds: `aitk tooling <stack> <path>` overwrites golden configs and merges seeds
182
184
  - Reference docs for a stack: `aitk tooling ref <stack> <path>`
183
185
  - Index regeneration after markdown edits: `aitk indexes regen`
@@ -1,11 +1,15 @@
1
1
  ---
2
- description: Route .claude/groundwork/ edits to the groundwork standard for numbering, frontmatter, and required file contents
2
+ description: Route .claude/groundwork/ edits to the groundwork standard for ordinal naming, numbering, frontmatter, and required file contents
3
3
  paths:
4
4
  - '.claude/groundwork/**'
5
5
  ---
6
6
 
7
7
  # Groundwork standards
8
8
 
9
+ ## Folder name
10
+
11
+ - Name a new track `.claude/groundwork/<nn>-<slug>/`, a two-digit zero-padded ordinal followed by a kebab-case slug. Take the ordinal from the highest one already present across both `.claude/groundwork/` and `.claude/intake/`, incremented, or `01` when neither folder holds an entry. Never renumber an existing folder.
12
+
9
13
  ## Measuring and closing
10
14
 
11
15
  - Never carry a figure from an earlier session without re-measuring it. Mark an inference as an inference.
@@ -1,11 +1,15 @@
1
1
  ---
2
- description: Route .claude/intake/ edits to the intake standard for the item format, the answer contract, and numbering
2
+ description: Route .claude/intake/ edits to the intake standard for the item format, the answer contract, and ordinal naming
3
3
  paths:
4
4
  - '.claude/intake/**'
5
5
  ---
6
6
 
7
7
  # Intake standards
8
8
 
9
+ ## Folder name
10
+
11
+ - Name a new dump `.claude/intake/<nn>-<slug>/`, a two-digit zero-padded ordinal followed by a kebab-case slug. Take the ordinal from the highest one already present across both `.claude/intake/` and `.claude/groundwork/`, incremented, or `01` when neither folder holds an entry. Never renumber an existing folder.
12
+
9
13
  ## The answer contract
10
14
 
11
15
  - Never fill a `You:` slot and never infer a disposition from an empty one. Empty means unread, never agreement.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "3.12.0",
4
+ "version": "3.12.1",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -35,6 +35,7 @@ import {
35
35
  type TestOrderReport,
36
36
  } from '@/gov/test-order'
37
37
  import { recordStamp, runDomainSync } from '@/sync/engine'
38
+ import { writeChainStamp } from '@/sync/stamp'
38
39
  import { resolveTarget } from '@/target'
39
40
  import {
40
41
  intro,
@@ -757,7 +758,15 @@ async function runInstall(
757
758
 
758
759
  logStep('Installing rules')
759
760
  for (const rel of await installRules(found, resolved)) logAdd(rel)
760
- await recordStamp(createGovAdapter(PROJECT_ROOT), resolved, new Date())
761
+
762
+ const now = new Date()
763
+ await recordStamp(createGovAdapter(PROJECT_ROOT), resolved, now)
764
+ await writeChainStamp(
765
+ resolved,
766
+ { domain: 'governance', toolkitRoot: PROJECT_ROOT },
767
+ [selected],
768
+ now,
769
+ )
761
770
 
762
771
  const { GREEN, NC } = palette(process.stderr)
763
772
  outro()
@@ -99,6 +99,12 @@ function renderCheck(report: CheckReport): void {
99
99
 
100
100
  renderSkew(report)
101
101
 
102
+ if (report.stampAtLegacyPath) {
103
+ logWarn(
104
+ 'Stamp found at the retired .claude/aitk.json. Move it to .claude/aitk/config.json.',
105
+ )
106
+ }
107
+
102
108
  if (!report.managed) {
103
109
  logStep('Not a toolkit project')
104
110
  logWarn('No .claude/ directory and no CLAUDE.md at the target.')
@@ -124,13 +130,20 @@ function renderCheck(report: CheckReport): void {
124
130
 
125
131
  for (const entry of domain.entries) {
126
132
  if (entry.state === 'matching' || entry.state === 'orphaned') continue
127
- logWarn(`${entry.rel} (${entry.state})`)
133
+ logWarn(entry.notice ?? `${entry.rel} (${entry.state})`)
128
134
  }
129
135
 
130
- const { stale, customized, drifted, stranded, orphaned } = domain.counts
136
+ const { stale, customized, drifted, stranded, orphaned, missing } =
137
+ domain.counts
131
138
  if (stale + customized + drifted + stranded === 0) {
132
139
  logInfo(orphaned === 0 ? 'up to date' : `up to date (${orphaned} local)`)
133
140
  }
141
+ // A local file is not a deficiency and a missing rule is, so this never
142
+ // shares the up-to-date line's parenthetical. It prints on its own,
143
+ // regardless of whether anything above needs a sync.
144
+ if (missing > 0) {
145
+ logWarn(`${missing} listed by the stack, not installed`)
146
+ }
134
147
 
135
148
  for (const commit of domain.upstream) {
136
149
  logInfo(`${commit.sha} ${commit.subject}`)
@@ -168,10 +181,10 @@ function renderCheck(report: CheckReport): void {
168
181
  if (report.newRules.length > 0) {
169
182
  logStep('New rules, never installed')
170
183
  for (const name of report.newRules) logWarn(name)
171
- // Names the stack as the reader's to supply, since no target records one
172
- // and `--add` layers onto a resolved stack rather than standing in for it.
184
+ // `install` re-resolves the whole stack rather than adding one rule, so
185
+ // the remedy names both routes rather than assuming the reader wants all.
173
186
  logInfo(
174
- 'Run `aitk gov install <stack>`, naming the stack yourself since no target records it. Add `--add <rule>` to take one.',
187
+ 'Run `aitk gov install <stack>` to take the whole stack again, or `--add <rule>` to take one.',
175
188
  )
176
189
  }
177
190
 
@@ -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:
@@ -14,8 +14,8 @@ Reference docs for consistent authoring across the toolkit and target projects.
14
14
  - [Design reference](design.md): Shape and content rules for .claude/DESIGN.md
15
15
  - [Diagram reference](diagrams.md): Shape and content rules for .claude/diagrams/<kind>.md files
16
16
  - [Glossary reference](glossary.md): Frontmatter, entry shape, ordering, and the rules deciding which terms a glossary carries
17
- - [Groundwork reference](groundwork.md): Folder layout, reserved numbering, frontmatter and dating, required file contents, and conventions for a measurement track
18
- - [Intake reference](intake.md): Folder layout, reserved index number, frontmatter and dating, the item template, the answer contract, and retrieval
17
+ - [Groundwork reference](groundwork.md): Folder layout, ordinal naming, reserved numbering, frontmatter and dating, required file contents, and conventions for a measurement track
18
+ - [Intake reference](intake.md): Folder layout, ordinal naming, reserved index number, frontmatter and dating, the item template, the answer contract, and retrieval
19
19
  - [Issue reference](issue.md): GitHub issue title, labels, and body conventions
20
20
  - [Markdown reference](markdown.md): Headings, paragraph and list structure, code spans, the date form, punctuation, emphasis, file references, banned words, and frontmatter wording
21
21
  - [Memory reference](memory.md): Filename and type prefix, frontmatter, the body shape per type, links between entries, and the lifecycle from write to retire
@@ -1,17 +1,17 @@
1
1
  ---
2
2
  title: Intake reference
3
- description: Folder layout, reserved index number, frontmatter and dating, the item template, the answer contract, and retrieval
3
+ description: Folder layout, ordinal naming, reserved index number, frontmatter and dating, the item template, the answer contract, and retrieval
4
4
  ---
5
5
 
6
6
  # Intake reference
7
7
 
8
- Applies to an intake folder at `.claude/intake/<slug>/`. One folder holds one dump, filed by domain, and every finding in it is an item carrying a measured problem, one proposed fix, and a verdict.
8
+ Applies to an intake folder at `.claude/intake/<nn>-<slug>/`. One folder holds one dump, filed by domain, and every finding in it is an item carrying a measured problem, one proposed fix, and a verdict.
9
9
 
10
10
  The folder is gitignored and unbacked. No check reaches its contents, so the shape below survives only by being read.
11
11
 
12
12
  ## Scope
13
13
 
14
- Governs an intake folder under `.claude/intake/<slug>/`: folder layout, the reserved index number, frontmatter and dating, the item format, the answer contract, and retrieval.
14
+ Governs an intake folder under `.claude/intake/<nn>-<slug>/`: folder layout, ordinal naming, the reserved index number, frontmatter and dating, the item format, the answer contract, and retrieval.
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
  - Which findings belong in a dump at all, and the procedure that files 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. This is the folder's own ordinal, distinct from the `NN-<domain>.md` numbering a cluster file carries inside it.
28
+ - Take the ordinal from the highest one already present across both `.claude/intake/` and `.claude/groundwork/`, 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 dump, which starts at `00` and disagrees with groundwork's own first required 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 intake looks like
26
33
 
27
34
  An intake works when a reader returning weeks later can act on it from the folder alone: