@erclx/aitk 0.66.0 → 0.67.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.
package/README.md CHANGED
@@ -63,7 +63,7 @@ Each domain has a canonical source in this repo and a thin install or sync CLI o
63
63
 
64
64
  - [Claude Code plugin](.claude/context/claude-plugin/index.md): skills for planning, review, docs sync, and the git ship chain
65
65
  - [Governance rules](.claude/context/governance.md): Cursor rules and stacks, installable per project
66
- - [Standards](.claude/context/standards.md): shared authoring conventions, synced to projects
66
+ - [Standards](.claude/context/standards/index.md): shared authoring conventions, synced to projects
67
67
  - [Snippets](.claude/context/snippets.md): reusable prompts for Claude and Gemini chat
68
68
  - [Tooling stacks](.claude/context/tooling.md): golden configs, seeds, and references per framework
69
69
  - [Design system](.claude/context/design.md): `DESIGN.md` token shape, extract skill and its two paths, render command
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "0.66.0",
4
+ "version": "0.67.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -7,7 +7,7 @@ description: What the standards audit is for, the gaps it closes, and the fixing
7
7
 
8
8
  ## Gap
9
9
 
10
- Without this skill, markdown ships unchecked against the authoring standards it was written under. A session that just wrote prose does not reopen the standard it was meant to follow, banned punctuation survives because it reads fine, and the mapping from a file to the standards governing it gets re-derived by guess on every pass.
10
+ Without this skill, markdown ships unchecked against the authoring standards it was written under. A session fresh from writing prose does not reopen the standard it was meant to follow, banned punctuation survives because it reads fine, and the mapping from a file to the standards governing it gets re-derived by guess on every pass.
11
11
 
12
12
  ## Must
13
13
 
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  name: claude-worktree
3
- description: Why worktree entry is wrapped rather than called directly, covering name derivation, the branch rename downstream slugs depend on, and the shared-config repair
3
+ description: Why worktree entry is wrapped rather than called directly, covering name derivation, the conventional branch rename the ship chain depends on, and the shared-config repair
4
4
  ---
5
5
 
6
6
  # Claude worktree requirement
7
7
 
8
8
  ## Gap
9
9
 
10
- Without this skill, the user names the worktree by hand, and a name matching no plan breaks every slug derived from it afterward. The entry tool then creates the branch as `worktree-<name>`, which diverges from the name the folder carries, so the ship chain looks for a plan file under a slug that does not exist and stops with nothing wrong except the name.
10
+ Without this skill, the user names the worktree by hand, and a name matching no plan breaks every slug derived from it afterward. The entry tool then creates the branch as `worktree-<name>`, which diverges from the name the folder carries, so the ship chain looks for a plan file under a slug that does not exist and stops with nothing wrong except the name. Correcting that to the bare name leaves the branch carrying no type, which `git-pr` refuses on format, so the same run stops a second time with the work already done and uncommitted.
11
11
 
12
12
  Entry also writes the bare flag into the shared config, which strands the main worktree. Every command run there fails while the files sit untouched on disk, and the linked worktree keeps working, so nothing surfaces until the operator returns to the main checkout and finds the repository broken. A rename onto a branch that already exists is the third failure, and it is the one that destroys work rather than blocking it.
13
13
 
@@ -16,7 +16,9 @@ Entry also writes the bare flag into the shared config, which strands the main w
16
16
  - Derive the name from the plan matched to the current branch, falling through the ordered sources rather than picking
17
17
  - Validate and sanitize the derived name against what the entry tool accepts
18
18
  - Preview the resolved name and which source produced it before entering
19
- - Rename the created branch to match the worktree name, so downstream slug derivation resolves
19
+ - Rename the created branch to a conventional `<type>/<name>`, so the pull request guard accepts it and the slug transform still reads `<name>` back out
20
+ - Take the type from the plan the name came from, and default to `feat` when a branch or the user supplied it
21
+ - Test the target branch and the target directory before entering, so a stop costs no worktree
20
22
  - Read the bare flag before writing it, and repair it on both sides of entry
21
23
  - Announce the repair only when a write actually happened
22
24
 
@@ -24,7 +26,7 @@ Entry also writes the bare flag into the shared config, which strands the main w
24
26
 
25
27
  - Pick between plans when more than one could match. Ask.
26
28
  - Enter on a name inferred from session context without confirmation
27
- - Delete or overwrite a branch that already carries the target name
29
+ - Delete or overwrite a branch or a worktree directory that already carries the target name
28
30
  - Rename when the worktree was entered by path, since that branch already has its own identity
29
31
  - Invoke the exit path, which is the user's call
30
32
 
@@ -32,7 +34,8 @@ Entry also writes the bare flag into the shared config, which strands the main w
32
34
 
33
35
  - Already inside a linked worktree: stop rather than nesting
34
36
  - Not a git repository and no creation hook configured: stop
35
- - Target branch already exists: stop and leave it alone, since resolving it automatically risks the wrong branch
37
+ - Target branch already exists: stop before entering and leave it alone, since resolving it automatically risks the wrong branch
38
+ - Target worktree directory already exists: stop before entering, which is the only read that sees two branches differing by type collapsing onto one name
36
39
 
37
40
  ## Out of scope
38
41
 
@@ -43,28 +43,48 @@ Try each source in order. Stop at the first match.
43
43
 
44
44
  Validate the result: letters, digits, dots, underscores, dashes only, max 64 chars (`/` separators are also allowed). If the derived name violates the rule, sanitize by replacing invalid chars with `-` and truncating. Show the sanitized name in the preview before invoking.
45
45
 
46
+ Resolve `<type>` here as well, since Step 3 previews it and Step 5 renames onto it. A name from a plan takes the type that plan's own work carries, read off its `## Summary` and `**Files to touch:**` lines. Every other case takes `feat`, which covers a name from a branch, a name from the user, and a plan whose lines settle nothing. Draw the value from the type vocabulary in `${CLAUDE_SKILL_DIR}/references/branch.md`.
47
+
48
+ A wrong type is cheap. `git-branch` renames to conventional format later in the same chain and runs ahead of `git-pr`, so a `feat/` written over a fix is corrected before any pull request opens.
49
+
50
+ Then test both names the entry is about to claim. Neither read needs a worktree, and a stop after Step 4 leaves one built with the session sitting inside it, so both belong here rather than beside the rename:
51
+
52
+ - Branch. `git show-ref --verify --quiet refs/heads/<type>/<name>` succeeding means the ref exists. Stop: `❌ Branch <type>/<name> already exists. Resolve manually before continuing.`
53
+ - Directory. `<main-root>/.claude/worktrees/<name>/` existing means an earlier entry claimed the name. Stop: `❌ Worktree .claude/worktrees/<name>/ already exists. Resolve manually before continuing.`
54
+
55
+ Leave both in place. Resolving either automatically risks the wrong one.
56
+
57
+ The two tests catch different collisions. The branch test misses the one `.claude/standards/slug.md` records, where two branches differing only in type collapse onto one name: `feat/foo` and `fix/foo` are distinct refs and reach one directory. The directory test is the only read that sees it.
58
+
59
+ The branch test fires on the tier 1 and tier 4 sources whenever the branch the session started on is already conventional, since a name derived from that branch resolves back onto it. Stopping is the answer there. The concern already has a branch, git refuses a second under the same name, and the bare-name rename this replaces only carried the collision forward to the `git-branch` step.
60
+
46
61
  ## Step 3: preview
47
62
 
48
63
  Output exactly:
49
64
 
50
65
  ```plaintext
51
66
  Worktree: .claude/worktrees/<name>/
52
- Source: <plan|branch|user>
67
+ Branch: <type>/<name>
68
+ Source: <plan|branch|user>
53
69
  ```
54
70
 
71
+ Step 2 resolved `<type>`. Show it, since it is the one part of the entry the user cannot read off the name they supplied.
72
+
55
73
  ## Step 4: enter
56
74
 
57
75
  Call `EnterWorktree` with `name: "<name>"`. Claude Code's tool permission dialog is the confirmation gate. Do not pause for additional confirmation.
58
76
 
59
77
  ## Step 5: align the branch name and repair the shared config
60
78
 
61
- `EnterWorktree` creates a branch named `worktree-<name>`, which diverges from `<name>` and breaks downstream slug derivation in `claude-autoship` and any skill that reads `git branch --show-current`. Rename it to match:
79
+ `EnterWorktree` creates a branch named `worktree-<name>`, which diverges from `<name>` and breaks downstream slug derivation in `claude-autoship` and any skill that reads `git branch --show-current`. A bare `<name>` fixes that and fails the branch-format guard in `git-pr`, which requires `<type>/<description>`. Rename to the conventional form, which satisfies both:
62
80
 
63
81
  ```bash
64
- git branch -m worktree-<name> <name>
82
+ git branch -m worktree-<name> <type>/<name>
65
83
  ```
66
84
 
67
- Before renaming, guard against a collision: if `git show-ref --verify --quiet refs/heads/<name>` succeeds, the target branch already exists. Stop: `❌ Branch <name> already exists. Resolve manually before continuing.` Do not delete the existing branch.
85
+ The slug transform drops a leading type segment, so `<name>` is what every downstream derivation reads back out of the typed branch. See `.claude/standards/slug.md`, or `${CLAUDE_SKILL_DIR}/../../standards/slug.md` when the project does not have it.
86
+
87
+ Step 2 already cleared the target name, so the rename runs unguarded here. Do not repeat the test. A ref created between the two points is a second session racing this one, which a re-read narrows rather than closes.
68
88
 
69
89
  Skip the rename if the worktree was entered via `path` rather than `name`, since the branch already exists under its own identity.
70
90
 
@@ -0,0 +1,60 @@
1
+ ---
2
+ title: Branch reference
3
+ description: Branch naming format and type conventions
4
+ consumers: git-branch, git-split, git-pr, claude-worktree
5
+ ---
6
+
7
+ # Branch reference
8
+
9
+ ## Scope
10
+
11
+ Governs a git branch name: its structure, its length, and the type vocabulary it draws from.
12
+
13
+ Does not govern:
14
+
15
+ - Commit subject format, which shares the type vocabulary: `commit.md`
16
+ - Pull request title and body: `pr.md`
17
+ - Whether a phase label may appear in a branch name: `versioning.md`
18
+ - Deriving a slug from a branch name for use in an output filename: `slug.md`
19
+
20
+ ## Format
21
+
22
+ - Structure: `<type>/<description>` or `<type>/<ticket>-<description>`
23
+ - Length: 50 characters maximum
24
+ - Casing: kebab-case only, no underscores or camelCase
25
+ - Description: 2 words maximum, 3 only when genuinely needed for specificity
26
+ - Capture the core change, not the commit message verbatim
27
+ - For branches with multiple commits, use the unifying concern as the description.
28
+ - Do not duplicate type in description (e.g., `feat/feature-login`)
29
+
30
+ ## Types
31
+
32
+ - `feat`: new feature or capability
33
+ - `fix`: bug fix
34
+ - `refactor`: structural changes (not a fix or feature)
35
+ - `docs`: documentation only (README)
36
+ - `chore`: maintenance tasks (deps, tooling, configs)
37
+ - `perf`: performance improvements
38
+ - `test`: add or modify tests
39
+ - `style`: code formatting (whitespace, semicolons)
40
+ - `build`: build system changes (webpack, npm scripts)
41
+ - `ci`: CI/CD pipeline changes (GitHub Actions)
42
+ - `revert`: revert a previous commit
43
+
44
+ ## Examples
45
+
46
+ ### Correct
47
+
48
+ ```plaintext
49
+ feat/jwt-expiration # clear feature scope
50
+ fix/AUTH-123-connection-pool # includes ticket ID
51
+ refactor/remove-deprecated-endpoints # clear refactor intent
52
+ ```
53
+
54
+ ### Incorrect
55
+
56
+ ```plaintext
57
+ feature/auth_stuff # wrong type + underscore
58
+ feat/feature-add-login # duplicates type in description
59
+ fix/DB-456-fix-the-database-connection-pool-memory-leak # exceeds 50 chars + verbatim message
60
+ ```
@@ -28,5 +28,5 @@ Read a standard from `${CLAUDE_SKILL_DIR}/../../standards/` instead when the pro
28
28
 
29
29
  Emit the full path on its own line.
30
30
 
31
- - Root surface: this is the toolkit's authoring source. Remind the user to run `bun run check` to regenerate the consumed copy under `.claude/`. That pass also regenerates the `standards/index.md` entry, and the user adds a row to the standards table in `.claude/context/standards.md`.
31
+ - Root surface: this is the toolkit's authoring source. Remind the user to run `bun run check` to regenerate the consumed copy under `.claude/`. That pass also regenerates the `standards/index.md` entry, and the user adds a row to the standards table in `.claude/context/standards/overview.md`.
32
32
  - `.claude/` surface: the file is project-local. `aitk standards sync` leaves it alone, since sync only updates filenames it recognizes from the toolkit. Remind the user to copy it to the toolkit repo, under `standards/<slug>.md`, if it should ship to every project.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Branch reference
3
3
  description: Branch naming format and type conventions
4
- consumers: git-branch, git-split, git-pr
4
+ consumers: git-branch, git-split, git-pr, claude-worktree
5
5
  ---
6
6
 
7
7
  # Branch reference
@@ -7,11 +7,12 @@ description: What the follow-up push is for, the gaps it closes, and how it spli
7
7
 
8
8
  ## Gap
9
9
 
10
- Without this skill, an edit made after a pull request is already open ships as a bare push. The body still describes the scope from before the edit, so a reviewer returning to the page reads a description the diff no longer matches. A reviewer who left comments gets no reply, and when a caller has already posted its own reply, a second one lands underneath it. A caller that rebased the branch before handing over hits a rejected push, since the tracking branch no longer reaches the head.
10
+ Without this skill, an edit made after a pull request is already open ships as a bare push. The body still describes the scope from before the edit, so a reviewer returning to the page reads a description the diff no longer matches. A reviewer who left comments gets no reply, and when a caller has already posted its own reply, a second one lands underneath it. A caller that rebased the branch before handing over hits a rejected push, since the tracking branch no longer reaches the head. A branch entered through a worktree hits the opposite shape, carrying an open pull request with no tracking ref at all, which read as a branch that had never been pushed.
11
11
 
12
12
  ## Must
13
13
 
14
- - Refuse unless the branch has an upstream and an open pull request, since every later step addresses one of the two
14
+ - Refuse unless the branch has an open pull request, since every later step addresses it
15
+ - Set the tracking ref at the push when the branch has none, since a branch entered through a worktree carries an open pull request without one
15
16
  - Delegate the message to `git-commit` rather than composing one, so a follow-up commit reads like every other commit
16
17
  - Sync the body when the new commit moves the scope, and the title when the shift makes it inaccurate
17
18
  - Reply on the pull request when it carries review comments, and scan that reply for the banned characters and internal phase labels before posting. The hook watches files and never sees a comment body on its way to the remote.
@@ -29,7 +30,6 @@ Without this skill, an edit made after a pull request is already open ships as a
29
30
 
30
31
  - On `main`: stop and say to switch to a pull request branch
31
32
  - Nothing changed: stop
32
- - No upstream: stop and name both recoveries
33
33
  - No open pull request: stop and route to the skill that opens one
34
34
 
35
35
  ## Out of scope
@@ -14,15 +14,16 @@ its own reply, so skip the comment in step 6. The push and body sync still run.
14
14
 
15
15
  - If `git branch --show-current` returns `main`, stop: `❌ On main. Switch to a PR branch first.`
16
16
  - If `git status --porcelain` is empty, stop: `❌ No changes to ship.`
17
- - If `git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null` is empty, stop: `❌ No upstream. Push the branch first or use git-ship.`
18
17
  - If `gh pr view --json state -q '.state' 2>/dev/null` is not `OPEN`, stop: `❌ No open PR for this branch. Use git-ship to open one.`
19
18
 
19
+ A missing tracking ref is no longer a guard. An open pull request proves the branch reached the remote, and a branch created by worktree entry has been measured carrying an open pull request with no tracking ref, where the old guard reported that the branch still needed pushing. That message describes a state the branch is not in, so the run stopped on a diagnosis nobody could act on. Step 4 sets the ref instead.
20
+
20
21
  ## Sequence
21
22
 
22
23
  1. Run `git status` to confirm the changes are intentional
23
24
  2. Run `git add -A` to stage every change
24
25
  3. Invoke `aitk:git-commit` to generate one conventional commit from the staged diff
25
- 4. Run `git push` to the tracking branch. When `git merge-base --is-ancestor @{u} HEAD` exits non-zero, a caller rewrote the branch and a plain push is rejected, so push with `--force-with-lease` instead. The lease is what stops the force from overwriting a commit this session never read.
26
+ 4. Push. When `git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null` is empty, the branch has an open pull request and no tracking ref, so run `git push -u origin HEAD` to send the commit and set the ref in one step. Otherwise push to the tracking branch with `git push`, and when `git merge-base --is-ancestor @{u} HEAD` exits non-zero, a caller rewrote the branch and a plain push is rejected, so push with `--force-with-lease` instead. The lease is what stops the force from overwriting a commit this session never read. Run the ancestry test only where an upstream resolves, since it reads `@{u}`.
26
27
  5. Check for existing review comments: `gh api 'repos/{owner}/{repo}/pulls/<number>/comments' --jq 'length'`, resolving `<number>` from `gh pr view --json number`.
27
28
  6. When invoked with `reply-owned`, skip this step's comment: the caller posts the reply. Otherwise, if the count is above zero, the followup addresses review feedback: post a one-line summary of the fix with `gh pr comment --body`, first running the scan in `.claude/standards/publish.md` against it, or `${CLAUDE_SKILL_DIR}/../../standards/publish.md` when the project does not have it, since the hook does not see an inline comment body. If it is zero, run `gh pr view --json url,title,body` and update the body with `gh pr edit --body` when the new commit changes scope, and the title with `gh pr edit --title` when the scope shifted enough to make it inaccurate.
28
29
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Branch reference
3
3
  description: Branch naming format and type conventions
4
- consumers: git-branch, git-split, git-pr
4
+ consumers: git-branch, git-split, git-pr, claude-worktree
5
5
  ---
6
6
 
7
7
  # Branch reference
@@ -56,7 +56,7 @@ Run these commands in parallel to gather git context:
56
56
 
57
57
  **All <total> commits accounted for.**
58
58
 
59
- - For the primary concern (the current branch), show `<current_branch> → <new_name>` only when renaming. Show just `<current_branch>` if the existing name already matches the concern.
59
+ - For the primary concern (the current branch), show `<current_branch> → <new_name>` only when renaming. Show `<current_branch>` alone if the existing name already matches the concern.
60
60
  - For stacked mode, list rows in merge order: branches based on `main` first, then each layer above. For independent mode, list the primary concern first.
61
61
 
62
62
  If Mode is Stacked, append this line to the preview:
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Branch reference
3
3
  description: Branch naming format and type conventions
4
- consumers: git-branch, git-split, git-pr
4
+ consumers: git-branch, git-split, git-pr, claude-worktree
5
5
  ---
6
6
 
7
7
  # Branch reference
@@ -51,7 +51,7 @@ When this happens, stop fixing and ask the user whether the underlying pattern s
51
51
  ## Red flags that mean "return to phase 1"
52
52
 
53
53
  - "Quick fix for now, investigate later"
54
- - "Just try X and see if it works"
54
+ - "Try X and see if it works"
55
55
  - "It's probably Y, let me change that"
56
56
  - Proposing a fix before tracing data flow
57
57
  - Adding multiple changes and running tests to see what sticks
@@ -117,6 +117,29 @@ commits touching its own source path. The `covers` field names the domains a
117
117
  target has actually stamped, so a domain that was never stamped is legible
118
118
  rather than reading as a clean one.
119
119
 
120
+ ### Tooling
121
+
122
+ Tooling records the stack chain an install resolved rather than per-file hashes,
123
+ since `src/tooling/` runs its own inject machinery and has no walk to attribute.
124
+ The chain is ordered nearest stack first, which is what a `--skip` run needs:
125
+ recording the leaf alone would send the next report measuring against a layer the
126
+ target deliberately does not carry. The report loads exactly those stacks, scans
127
+ them the way `aitk tooling sync` would, and counts what differs per category
128
+ under `tooling.counts`.
129
+
130
+ `measured` is the field the section exists for. A target carrying no chain
131
+ reports `measured: false`, which separates tooling nobody has ever looked at from
132
+ tooling that is current. Both produce zero changes otherwise. Every target
133
+ installed before the record shipped starts unmeasured and leaves on its next
134
+ `aitk tooling sync`, since backfilling would mean inferring the chain from
135
+ installed files, which is the guess the record replaces.
136
+
137
+ A workspace root records nothing, because each package resolves its own chain and
138
+ one written at the root would be that same guess. Run the check against a package
139
+ to measure it. Tooling never counts toward `--exit-code`, on the grounds seeds
140
+ are already excluded on, since it reports golden configs a project is expected to
141
+ edit and a job counting those stays red with no remedy.
142
+
120
143
  ### Surfaces reported beside the domains
121
144
 
122
145
  Three sections sit outside the per-domain scan, because each names something
@@ -86,7 +86,11 @@ Exit codes are `0` for a completed run and `1` for a refusal. Every finding repo
86
86
 
87
87
  A banned character is a fact rather than a judgment, which is the test that would ordinarily make it gate. What holds it back is that gating on day one against a corpus never checked mechanically fails loudly on work nobody has had a chance to fix. The order is to land the verb reporting, measure the corpus once, fix what it finds, and turn the gate on as its own change. Bullet, paragraph, and depth weight are judgments and stay advisory under any later gate.
88
88
 
89
- Measured across 444 files with the paragraph checkpoint at 600: 8 word hits, no character or spelling hits, 119 heavy bullets, 221 heavy paragraphs across 86 files, and 41 files carrying a run past the depth checkpoint. Of the paragraphs, 88 fire on weight alone. This is the baseline the corpus sweep tracks its work against. The ban count is what a gate would have to hold at zero, and it is the only one of the five a gate should ever read.
89
+ Measured at `4b7b13a2` across 444 files with the paragraph checkpoint at 600: 8 word hits, no character or spelling hits, 119 heavy bullets, 221 heavy paragraphs across 86 files, and 41 files carrying a run past the depth checkpoint. Of the paragraphs, 88 fire on weight alone. This is the baseline the corpus sweep tracks its work against. The ban count is what a gate would have to hold at zero, and it is the only one of the five a gate should ever read.
90
+
91
+ The ban half of that baseline now reports no character, word, or spelling hit, which is the precondition the gate was waiting on. The other four moved with the corpus rather than with any decision, so read them from a run rather than from this paragraph.
92
+
93
+ ### Why a recorded count goes stale
90
94
 
91
95
  A count written into prose goes stale against the corpus it describes, and nothing compares the two. The paragraph figure recorded when the masking fix shipped was already wrong by twelve one release later, which is why the standard states the rule and this page carries the numbers.
92
96
 
@@ -94,6 +98,20 @@ A standard sits inside the corpus this verb measures, so rewriting a rule can br
94
98
 
95
99
  Masking took 7 of the weight-only paragraphs the checkpoint reported at 400 and 4 of the 44 files under their checkpoints, and no bullet at all. The first corpus triage put those at 31 paragraphs and 2 bullets, and neither reproduces: a code span is walked around, so a backticked path holding an angle-bracket placeholder keeps the width the page gives it, and both bullets the triage counted were that shape.
96
100
 
101
+ ### How the ban count reached zero
102
+
103
+ Eight word hits stood between the baseline and a gate, and only three carried the sense `prose.md` bans. `leverage` sat in the requirements worldview, `allows` in the claude stack reference, and one `just` was the vague qualifier in a skill body. Those three lost the qualifier rather than the word.
104
+
105
+ The other five were correct prose the closed set cannot separate from a violation. Four were the temporal `just`, meaning a moment ago, in phrases like the implementation `just` completed and the field the user `just` edited. The fifth quoted an anti-pattern a skill exists to forbid. `prose.md` bans vague qualifiers and lists the tokens those qualifiers happen to spell, so the rule as written reaches none of the five while the scan reaches all of them.
106
+
107
+ ### Why they were rewritten rather than exempted
108
+
109
+ Rewriting all five is what settled them, over building an exemption path. An exemption has three consumers, `src/markdown/scan.ts` for the patterns, `src/markdown/bans.ts` for the sets, and `.claude/hooks/standards-audit.sh`, which greps its own copy of the word bans in awk. A mechanism landing in the verb and not the hook leaves an exempted line still failing on edit, which is the surface an author actually meets. Five sentences lost a small amount of naturalness and the count now means what it says.
110
+
111
+ A code span was the first answer for the quoted anti-pattern and it was the wrong one. The ban scan walks around a code span, so backticking a quotation clears the report, and `## Code and identifiers` reserves the span for commands, API names, file paths, and identifiers, which a quoted utterance is none of. Spending one rule to satisfy another leaves the corpus no cleaner than dropping the qualifier does.
112
+
113
+ The collision is structural rather than a property of five legacy sentences. Writing the task and the plan behind this change each reproduced it, because a fresh file discussing the ban quotes the tokens it discusses. A later author writing about vague qualifiers meets the same thing, and the answer is to name the token in a code span where it is genuinely an identifier being discussed, and to rewrite the sentence where it is not.
114
+
97
115
  ## What it does not cover
98
116
 
99
117
  The verb reads the two attribute standards and nothing else. The five standards declaring `appliesTo: ["*"]` also include `publish.md`, `slug.md`, and `versioning.md`, none of which this implements.
@@ -65,7 +65,7 @@ When features are independent, run them in parallel instead of sequentially. Use
65
65
 
66
66
  - Create a worktree per feature, then start a Claude Code session in each
67
67
  - Invoke `aitk:claude-feature` in each session. Plans land at the main worktree root as `.claude/plans/feature-<slug>.md`, one per feature, no collisions. Small features stay in chat and skip the file.
68
- - Implement, verify, and review each feature independently. `claude-review` and `claude-ui-test` write per-branch files at the main worktree root (`review-<branch>.md`, `ui-checklist-<branch>.md`), so parallel sessions do not overwrite each other
68
+ - Implement, verify, and review each feature independently. `claude-review` and `claude-ui-test` write per-branch files at the main worktree root (`review-<slug>.md`, `ui-checklist-<slug>.md`), so parallel sessions do not overwrite each other. The slug is the branch name with any leading type segment dropped, so `feat/jwt-expiration` and the plan at `feature-jwt-expiration.md` meet on one name
69
69
  - Ship each worktree separately with `aitk:git-ship`
70
70
  - For full autonomy per worktree, invoke `aitk:claude-autoship` instead of the manual chain. Approve the plan, walk away, come back to draft PRs.
71
71
 
@@ -100,7 +100,7 @@ When the toolkit updates, target projects pull changes per domain. There is one
100
100
 
101
101
  `aitk sync --check <path>` reports what has drifted without writing anything. It splits each difference by cause, which is the question that decides what to do next. 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.
102
102
 
103
- That attribution comes from `.claude/aitk.json`, a stamp every install and sync writes recording a hash per installed file. Each domain holds its own toolkit commit, so syncing governance today does not move the revision standards 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.
103
+ That attribution comes from `.claude/aitk.json`, a stamp every install and sync writes. Standards, 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. Each domain holds its own toolkit commit, so syncing governance today does not move the revision standards 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.
104
104
 
105
105
  A project that has never synced under a toolkit new enough to write a stamp falls back to the toolkit's own git history. Installed content matching any version that history published proves the file untouched, so it reports `stale` naming the commit it came from, and content matching no published version stays `drifted`. That fallback needs the toolkit as a git checkout. Installed from the registry it ships source without history, and the report says attribution was unavailable rather than reading every file as a local edit.
106
106
 
@@ -108,9 +108,9 @@ Three further causes sit outside the per-domain scan, each naming something that
108
108
 
109
109
  That last one matters most on an older project. Before it existed, a target holding `standards/` at its root reported zero entries for that domain, so a project that had never migrated was indistinguishable from one that was fully current.
110
110
 
111
- Add `--json` for the machine-readable report, and `--exit-code` to fail a CI job when a target falls behind. Files the project authored itself never count toward that exit code, and neither do superseded artifacts or seed drift, since both need the user to move content only they can place. An unmigrated domain does count, because running the relocation closes it.
111
+ Add `--json` for the machine-readable report, and `--exit-code` to fail a CI job when a target falls behind. Files the project authored itself never count toward that exit code, and neither do superseded artifacts, seed drift, or tooling, since each reports content the project is expected to edit or place itself. An unmigrated domain does count, because running the relocation closes it.
112
112
 
113
- Tooling is not covered by the stamp. Reconcile those configs with `aitk tooling <stack> <path>`.
113
+ Tooling reports under a section of its own, and `measured` there says whether the target ever recorded a chain. One that never ran a tooling sync reports unmeasured rather than clean, which is what separates tooling nobody has looked at from tooling that is current. A workspace root records nothing either way, since each package resolves its own chain. Reconcile the configs with `aitk tooling sync <stack> <path>`.
114
114
 
115
115
  ### Catch-all
116
116
 
@@ -185,5 +185,5 @@ Sync also refuses a target whose working tree is dirty, so commit or stash befor
185
185
 
186
186
  - [agents](agents/index.md): CLI flags, exit codes, and JSON output shapes
187
187
  - [AI workflow](ai-workflow.md): feature-development loop inside a toolkit-managed project
188
- - [tooling](../.claude/context/tooling.md), [governance](../.claude/context/governance.md), [claude plugin](../.claude/context/claude-plugin/index.md), [indexes](../.claude/context/indexes.md), [snippets](../.claude/context/snippets.md), [standards](../.claude/context/standards.md): per-domain mechanics
188
+ - [tooling](../.claude/context/tooling.md), [governance](../.claude/context/governance.md), [claude plugin](../.claude/context/claude-plugin/index.md), [indexes](../.claude/context/indexes.md), [snippets](../.claude/context/snippets.md), [standards](../.claude/context/standards/index.md): per-domain mechanics
189
189
  - [sandbox](../.claude/context/sandbox/index.md): scenario catalog for verifying domain flows
@@ -22,7 +22,7 @@ paths:
22
22
  ## Error placement
23
23
 
24
24
  - Show the error directly under the field that caused it, not under an unrelated field.
25
- - When a change to field A causes a conflict involving field B, show the error under field A (the field the user just edited), not under field B.
25
+ - When a change to field A causes a conflict involving field B, show the error under field A (the field the user edited), not under field B.
26
26
 
27
27
  ## Input sanitization
28
28
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "0.66.0",
4
+ "version": "0.67.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -1,4 +1,4 @@
1
- Recap the implementation just completed. Read the plan file referenced in the session (usually `.claude/plans/feature-<slug>.md`) and produce a verification block.
1
+ Recap the implementation this session completed. Read the plan file referenced in the session (usually `.claude/plans/feature-<slug>.md`) and produce a verification block.
2
2
 
3
3
  Output this shape, nothing else:
4
4
 
@@ -3,9 +3,13 @@ import { join } from 'node:path'
3
3
  import type { Command } from 'commander'
4
4
  import { cliPath, cliRun } from '@/cli-run'
5
5
  import { PROJECT_ROOT } from '@/exec'
6
- import { buildCheckReport, type CheckReport, hasDrift } from '@/sync/check'
6
+ import {
7
+ buildCheckReport,
8
+ type CheckReport,
9
+ hasDrift,
10
+ SCANNED_DOMAINS,
11
+ } from '@/sync/check'
7
12
  import { createGitRunner, createPullRequestOpener, hasGh } from '@/sync/git'
8
- import { STAMP_DOMAINS } from '@/sync/stamp'
9
13
  import {
10
14
  detectDomains,
11
15
  installedDomains,
@@ -133,6 +137,8 @@ function renderCheck(report: CheckReport): void {
133
137
  }
134
138
  }
135
139
 
140
+ renderTooling(report)
141
+
136
142
  for (const entry of report.unmigrated) {
137
143
  logStep(`${entry.domain} (not migrated)`)
138
144
  logWarn(
@@ -157,17 +163,56 @@ function renderCheck(report: CheckReport): void {
157
163
  }
158
164
 
159
165
  outro()
166
+ // Scanned domains only. Tooling renders a section on every managed target, so
167
+ // naming it here repeats what that section already said under a second
168
+ // remedy, where a scanned domain nobody installed has no section at all and
169
+ // this line is the only place it appears.
160
170
  const unmigrated = report.unmigrated.map((entry) => entry.domain)
161
- const uncovered = STAMP_DOMAINS.filter(
171
+ const uncovered = SCANNED_DOMAINS.filter(
162
172
  (domain) => !report.covers.includes(domain) && !unmigrated.includes(domain),
163
173
  )
164
- const unstamped =
165
- uncovered.length === 0 ? '' : `Unstamped: ${uncovered.join(', ')}. `
174
+ if (uncovered.length === 0) return
175
+
166
176
  process.stderr.write(
167
- `${GREY}${unstamped}Tooling is never stamped, run \`aitk tooling\` to reconcile configs.${NC}\n`,
177
+ `${GREY}Unstamped: ${uncovered.join(', ')}. Run the matching sync to record one.${NC}\n`,
168
178
  )
169
179
  }
170
180
 
181
+ /**
182
+ * Tooling prints whether it was measured before it prints any count, because a
183
+ * target with no chain recorded produces the same zero a current target does.
184
+ * Naming the state is the whole reason the section exists.
185
+ */
186
+ function renderTooling(report: CheckReport): void {
187
+ const { tooling } = report
188
+ logStep('tooling')
189
+
190
+ if (!tooling.measured) {
191
+ logWarn(
192
+ tooling.chain.length === 0
193
+ ? 'Not stamped. No chain recorded, so tooling drift is unmeasured.'
194
+ : `Recorded chain names no stack this toolkit ships: ${tooling.chain.join(' < ')}.`,
195
+ )
196
+ logInfo('Run `aitk tooling sync <stack>` to record what this target holds.')
197
+ return
198
+ }
199
+
200
+ logInfo(`Chain: ${tooling.chain.join(' < ')}`)
201
+ if (tooling.commit !== undefined) {
202
+ logInfo(`Synced from ${tooling.commit} on ${tooling.syncedAt}`)
203
+ }
204
+
205
+ if (tooling.changes === 0) {
206
+ logInfo('up to date')
207
+ return
208
+ }
209
+
210
+ for (const [category, count] of Object.entries(tooling.counts)) {
211
+ if (count > 0) logWarn(`${count} ${category}`)
212
+ }
213
+ logInfo('Run `aitk tooling sync` to reconcile these.')
214
+ }
215
+
171
216
  /**
172
217
  * Seeds print their own section because no sync command applies them. A `stale`
173
218
  * seed is safe to take whole and a `drifted` one holds edits, which is the split
@@ -18,6 +18,7 @@ import {
18
18
  stackExists,
19
19
  } from '@/tooling/manifest'
20
20
  import { scan, type ScanResult } from '@/tooling/scan'
21
+ import { recordToolingChain } from '@/tooling/stamp'
21
22
  import { intro, logAdd, logInfo, logStep, logWarn, outro, select } from '@/ui'
22
23
 
23
24
  const GREEN = '\x1b[0;32m'
@@ -219,6 +220,7 @@ async function runSync(
219
220
  report(result, includeReferences)
220
221
 
221
222
  if (result.totalChanges === 0) {
223
+ await stampChain(prepared.chain, prepared.target)
222
224
  outro()
223
225
  process.stderr.write(`${GREEN}✓ Everything up to date${NC}\n`)
224
226
  return 0
@@ -255,11 +257,35 @@ async function runSync(
255
257
  await applyReferences(prepared.chain, prepared.target, pending)
256
258
  }
257
259
 
260
+ await stampChain(prepared.chain, prepared.target)
261
+
258
262
  outro()
259
263
  process.stderr.write(`${GREEN}✓ Tooling sync complete${NC}\n`)
260
264
  return 0
261
265
  }
262
266
 
267
+ /**
268
+ * Writes the chain after the copies land, so a partial apply that throws leaves
269
+ * the previous record rather than a claim the target does not meet.
270
+ */
271
+ async function stampChain(chain: Manifest[], target: string): Promise<void> {
272
+ const recorded = await recordToolingChain(
273
+ PROJECT_ROOT,
274
+ target,
275
+ chain,
276
+ new Date(),
277
+ )
278
+
279
+ if (recorded) {
280
+ logInfo(`Recorded chain: ${chain.map((entry) => entry.name).join(' < ')}`)
281
+ return
282
+ }
283
+
284
+ logWarn(
285
+ 'Workspace root: no chain recorded. Tooling reports unmeasured because the answer differs per package.',
286
+ )
287
+ }
288
+
263
289
  async function runInject(
264
290
  stack: string,
265
291
  target: string,
@@ -290,6 +316,15 @@ async function runInject(
290
316
  await injectGitignore(prepared.chain, prepared.target)
291
317
  }
292
318
 
319
+ // Only a whole-stack inject records the chain. A flag-scoped run installs one
320
+ // category, and a chain recorded from it would send the report scanning for
321
+ // configs and deps the caller never asked to install. The claude stack is
322
+ // excluded here rather than in `prepare`, which is what keeps `aitk claude`
323
+ // able to drive injection while its stack stays out of the tooling record.
324
+ if (applyAll && !isStackExcluded(stack)) {
325
+ await stampChain(prepared.chain, prepared.target)
326
+ }
327
+
293
328
  if (framed) outro()
294
329
  return 0
295
330
  }
package/src/sync/check.ts CHANGED
@@ -13,32 +13,47 @@ import {
13
13
  import { buildSeedsReport, type SeedsReport } from '@/sync/seeds-report'
14
14
  import {
15
15
  readStamp,
16
- STAMP_DOMAINS,
17
16
  type Stamp,
17
+ stampedChain,
18
18
  stampedCommit,
19
19
  type StampDomain,
20
20
  } from '@/sync/stamp'
21
21
  import { createStandardsAdapter } from '@/standards/adapter'
22
22
  import { isDirectory } from '@/target'
23
+ import { loadManifest } from '@/tooling/manifest'
24
+ import { scan } from '@/tooling/scan'
25
+
26
+ /**
27
+ * Domains the sync engine walks file by file. Tooling is a stamp domain without
28
+ * being one of these, because `src/tooling/` never calls `planSync`, so the
29
+ * three lookups below have no entry to offer it.
30
+ */
31
+ export const SCANNED_DOMAINS = [
32
+ 'standards',
33
+ 'snippets',
34
+ 'governance',
35
+ ] as const satisfies readonly StampDomain[]
36
+
37
+ export type ScannedDomain = (typeof SCANNED_DOMAINS)[number]
23
38
 
24
39
  /**
25
40
  * The toolkit path whose commits change what each domain holds. `claude/skills/`
26
41
  * is deliberately absent: skills load live from the plugin directory, so they
27
42
  * never go stale and belong in the read-only section instead.
28
43
  */
29
- const SYNCED_SOURCES: Record<StampDomain, string> = {
44
+ const SYNCED_SOURCES: Record<ScannedDomain, string> = {
30
45
  standards: 'standards/',
31
46
  snippets: 'snippets/',
32
47
  governance: 'governance/rules/',
33
48
  }
34
49
 
35
- const ADAPTERS: Record<StampDomain, (root: string) => SyncAdapter> = {
50
+ const ADAPTERS: Record<ScannedDomain, (root: string) => SyncAdapter> = {
36
51
  standards: createStandardsAdapter,
37
52
  snippets: createSnippetsAdapter,
38
53
  governance: createGovAdapter,
39
54
  }
40
55
 
41
- const INSTALL_MARKERS: Record<StampDomain, readonly string[]> = {
56
+ const INSTALL_MARKERS: Record<ScannedDomain, readonly string[]> = {
42
57
  standards: ['.claude', 'standards'],
43
58
  snippets: ['.claude', 'snippets'],
44
59
  governance: ['.claude', 'rules'],
@@ -54,7 +69,7 @@ export interface StateCounts {
54
69
  }
55
70
 
56
71
  export interface DomainReport {
57
- readonly domain: StampDomain
72
+ readonly domain: ScannedDomain
58
73
  readonly stamped: boolean
59
74
  /** This domain's own anchor, not the target's most recent sync. */
60
75
  readonly commit?: string
@@ -74,11 +89,60 @@ export interface UpstreamCommit {
74
89
  readonly subject: string
75
90
  }
76
91
 
92
+ /** Pending changes per category, from the same scan `aitk tooling sync` reads. */
93
+ export interface ToolingCounts {
94
+ readonly configs: number
95
+ readonly seeds: number
96
+ readonly scripts: number
97
+ readonly deps: number
98
+ readonly gitignore: number
99
+ readonly references: number
100
+ }
101
+
102
+ /**
103
+ * Tooling's own section. `measured` is the field the report exists for: without
104
+ * it a target that never installed tooling and a target whose tooling is current
105
+ * both render as zero changes, which is a claim rather than an absence of one.
106
+ */
107
+ export interface ToolingReport {
108
+ readonly measured: boolean
109
+ /**
110
+ * Stack names the install resolved, nearest first. Carried even when the
111
+ * report is unmeasured, so a chain naming stacks this toolkit no longer ships
112
+ * stays distinguishable from a target that recorded none.
113
+ */
114
+ readonly chain: readonly string[]
115
+ readonly commit?: string
116
+ readonly syncedAt?: string
117
+ readonly counts: ToolingCounts
118
+ readonly changes: number
119
+ }
120
+
121
+ const UNMEASURED_TOOLING: ToolingReport = {
122
+ measured: false,
123
+ chain: [],
124
+ counts: {
125
+ configs: 0,
126
+ seeds: 0,
127
+ scripts: 0,
128
+ deps: 0,
129
+ gitignore: 0,
130
+ references: 0,
131
+ },
132
+ changes: 0,
133
+ }
134
+
77
135
  export interface CheckReport {
78
136
  readonly covers: readonly StampDomain[]
79
137
  /** False when the target is not a toolkit project, so every section stays empty. */
80
138
  readonly managed: boolean
81
139
  readonly domains: readonly DomainReport[]
140
+ /**
141
+ * Reported beside the domains rather than as one of them, because tooling
142
+ * carries no per-file entries and no upstream range, so it fills almost none
143
+ * of `DomainReport`.
144
+ */
145
+ readonly tooling: ToolingReport
82
146
  /**
83
147
  * Reported beside the domains rather than as one of them, because seeds carry
84
148
  * no stamp and produce no change. See `@/sync/seeds-report`.
@@ -89,12 +153,58 @@ export interface CheckReport {
89
153
  readonly newSkills: readonly string[]
90
154
  }
91
155
 
92
- export function installedStampDomains(target: string): StampDomain[] {
93
- return STAMP_DOMAINS.filter((domain) =>
156
+ export function installedStampDomains(target: string): ScannedDomain[] {
157
+ return SCANNED_DOMAINS.filter((domain) =>
94
158
  isDirectory(join(target, ...INSTALL_MARKERS[domain])),
95
159
  )
96
160
  }
97
161
 
162
+ /**
163
+ * Reads the chain the install recorded and scans against those stacks rather
164
+ * than re-resolving from the leaf. A run that passed `--skip` installed fewer
165
+ * layers than the leaf's own chain reproduces, so re-resolving would report
166
+ * drift against a layer the target deliberately does not carry.
167
+ *
168
+ * A recorded stack the toolkit no longer ships resolves to nothing, and a chain
169
+ * where none resolve reads as unmeasured. Scanning the survivors would measure
170
+ * against a chain neither side agrees on.
171
+ */
172
+ export function buildToolingReport(
173
+ toolkitRoot: string,
174
+ target: string,
175
+ stamp: Stamp | undefined,
176
+ ): ToolingReport {
177
+ const chain = stampedChain(stamp)
178
+ const manifests = chain
179
+ .map((name) => loadManifest(toolkitRoot, name))
180
+ .filter((manifest) => manifest !== undefined)
181
+
182
+ if (manifests.length === 0) return { ...UNMEASURED_TOOLING, chain }
183
+
184
+ const result = scan(manifests, target, { includeReferences: true })
185
+ const record = stamp?.domains.tooling
186
+
187
+ return {
188
+ measured: true,
189
+ chain,
190
+ commit: record?.commit,
191
+ syncedAt: record?.syncedAt,
192
+ counts: {
193
+ configs: result.configs.filter((entry) => entry.state !== 'matching')
194
+ .length,
195
+ seeds: result.seeds.filter((entry) => entry.state === 'missing').length,
196
+ scripts: result.scripts.filter((entry) => entry.state !== 'matching')
197
+ .length,
198
+ deps: result.deps.filter((entry) => entry.state === 'missing').length,
199
+ gitignore: result.gitignore.filter((entry) => entry.state === 'missing')
200
+ .length,
201
+ references: result.references.filter((entry) => entry.state === 'pending')
202
+ .length,
203
+ },
204
+ changes: result.totalChanges,
205
+ }
206
+ }
207
+
98
208
  /**
99
209
  * Whether the target is a toolkit-managed project at all. Seeds are enumerated
100
210
  * from the source rather than from what a target installed, so without this gate
@@ -143,6 +253,11 @@ export function countStates(entries: readonly ScanEntry[]): StateCounts {
143
253
  * the user can move content they wrote, so failing a job on it leaves the job
144
254
  * red with no mechanical remedy. Seeds are excluded on the same grounds, since
145
255
  * every seed a project edits would otherwise fail the check forever.
256
+ *
257
+ * Tooling is excluded on exactly the seeds grounds: a golden config is one the
258
+ * project is expected to edit, so a job counting it stays red with no remedy.
259
+ * Being unmeasured is not what excludes it, since an unmeasured report carries
260
+ * zero changes and would pass a count either way.
146
261
  */
147
262
  export function hasDrift(report: CheckReport): boolean {
148
263
  if (report.unmigrated.length > 0) return true
@@ -186,6 +301,7 @@ export async function buildCheckReport(
186
301
  covers: [],
187
302
  managed,
188
303
  domains: [],
304
+ tooling: UNMEASURED_TOOLING,
189
305
  seeds: { entries: [], historyUnavailable: false },
190
306
  superseded: [],
191
307
  unmigrated: [],
@@ -197,6 +313,7 @@ export async function buildCheckReport(
197
313
  covers: stamp?.covers ?? [],
198
314
  managed,
199
315
  domains,
316
+ tooling: buildToolingReport(toolkitRoot, target, stamp),
200
317
  seeds: buildSeedsReport(toolkitRoot, target),
201
318
  superseded: collectSuperseded(target),
202
319
  unmigrated,
@@ -208,7 +325,7 @@ async function buildDomainReport(
208
325
  toolkitRoot: string,
209
326
  target: string,
210
327
  stamp: Stamp | undefined,
211
- domain: StampDomain,
328
+ domain: ScannedDomain,
212
329
  ): Promise<DomainReport> {
213
330
  const plan = planSync(ADAPTERS[domain](toolkitRoot), target)
214
331
  const record = stamp?.domains[domain]
package/src/sync/stamp.ts CHANGED
@@ -5,10 +5,16 @@ import { dirname, join, sep } from 'node:path'
5
5
  import { execa } from 'execa'
6
6
 
7
7
  /**
8
- * Domains the stamp can attribute. Tooling runs its own inject and manifest
9
- * machinery rather than the sync engine, so it is absent by design.
8
+ * Domains the stamp can record. The first three attribute file by file through
9
+ * the sync engine. Tooling runs its own inject and manifest machinery, so it
10
+ * records the stack chain it resolved instead and carries no file hashes.
10
11
  */
11
- export const STAMP_DOMAINS = ['standards', 'snippets', 'governance'] as const
12
+ export const STAMP_DOMAINS = [
13
+ 'standards',
14
+ 'snippets',
15
+ 'governance',
16
+ 'tooling',
17
+ ] as const
12
18
 
13
19
  export type StampDomain = (typeof STAMP_DOMAINS)[number]
14
20
 
@@ -31,6 +37,13 @@ export interface DomainStamp {
31
37
  readonly commit?: string
32
38
  readonly syncedAt: string
33
39
  readonly files: DomainHashes
40
+ /**
41
+ * Stack names the install resolved, nearest stack first. Present only for
42
+ * tooling. An ordered chain rather than the leaf name, because a stack that
43
+ * extends another cannot be reinstalled from its leaf alone, and a `--skip`
44
+ * run installs fewer layers than the leaf's own chain would reproduce.
45
+ */
46
+ readonly chain?: readonly string[]
34
47
  }
35
48
 
36
49
  export interface Stamp {
@@ -91,6 +104,15 @@ export function stampedHashes(
91
104
  return stamp.domains[domain]?.files ?? {}
92
105
  }
93
106
 
107
+ /**
108
+ * The stack chain tooling last installed. An empty result is the state every
109
+ * target predating the tooling record sits in, and the report reads it as
110
+ * unmeasured rather than as clean.
111
+ */
112
+ export function stampedChain(stamp: Stamp | undefined): readonly string[] {
113
+ return stamp?.domains.tooling?.chain ?? []
114
+ }
115
+
94
116
  /**
95
117
  * Replaces one domain's record and leaves the others untouched, because domains
96
118
  * install and sync independently but share the one file.
@@ -100,6 +122,34 @@ export async function writeStamp(
100
122
  source: StampSource,
101
123
  hashes: DomainHashes,
102
124
  now: Date,
125
+ ): Promise<void> {
126
+ await putDomain(target, source, { files: sortKeys(hashes) }, now)
127
+ }
128
+
129
+ /**
130
+ * Records what tooling installed. `files` stays empty because `src/tooling/`
131
+ * never runs the sync engine, so there is no per-file attribution to store and
132
+ * the chain is the whole record.
133
+ */
134
+ export async function writeChainStamp(
135
+ target: string,
136
+ toolkitRoot: string,
137
+ chain: readonly string[],
138
+ now: Date,
139
+ ): Promise<void> {
140
+ await putDomain(
141
+ target,
142
+ { domain: 'tooling', toolkitRoot },
143
+ { files: {}, chain: [...chain] },
144
+ now,
145
+ )
146
+ }
147
+
148
+ async function putDomain(
149
+ target: string,
150
+ source: StampSource,
151
+ payload: Pick<DomainStamp, 'files' | 'chain'>,
152
+ now: Date,
103
153
  ): Promise<void> {
104
154
  const previous = readStamp(target)
105
155
  const commit = await toolkitCommit(source.toolkitRoot)
@@ -107,7 +157,7 @@ export async function writeStamp(
107
157
  const record: DomainStamp = {
108
158
  ...(commit === undefined ? {} : { commit }),
109
159
  syncedAt: now.toISOString(),
110
- files: sortKeys(hashes),
160
+ ...payload,
111
161
  }
112
162
 
113
163
  const domains = sortDomains({
@@ -187,16 +237,26 @@ function isStamp(value: unknown): value is Stamp {
187
237
  )
188
238
  }
189
239
 
240
+ /**
241
+ * `chain` is optional, which is what keeps a stamp written before tooling
242
+ * joined the domains readable rather than parsing as corrupt and discarding
243
+ * the three records it does carry.
244
+ */
190
245
  function isDomainStamp(value: unknown): value is DomainStamp {
191
246
  if (!isRecord(value) || !isRecord(value.files)) return false
192
247
  if (typeof value.syncedAt !== 'string') return false
193
248
  if (value.commit !== undefined && typeof value.commit !== 'string') {
194
249
  return false
195
250
  }
251
+ if (value.chain !== undefined && !isStringArray(value.chain)) return false
196
252
 
197
253
  return Object.values(value.files).every((hash) => typeof hash === 'string')
198
254
  }
199
255
 
256
+ function isStringArray(value: unknown): value is string[] {
257
+ return Array.isArray(value) && value.every((item) => typeof item === 'string')
258
+ }
259
+
200
260
  function isRecord(value: unknown): value is Record<string, unknown> {
201
261
  return typeof value === 'object' && value !== null && !Array.isArray(value)
202
262
  }
@@ -0,0 +1,44 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { writeChainStamp } from '@/sync/stamp'
4
+ import type { Manifest } from '@/tooling/manifest'
5
+ import { readPackage } from '@/tooling/package'
6
+
7
+ /**
8
+ * Whether the target is a workspace root, where every package resolves its own
9
+ * stack. `package.json` covers bun, npm, and yarn, and pnpm declares the same
10
+ * fact in its own file instead.
11
+ */
12
+ export function isWorkspaceRoot(target: string): boolean {
13
+ if (existsSync(join(target, 'pnpm-workspace.yaml'))) return true
14
+
15
+ const workspaces = readPackage(join(target, 'package.json'))?.workspaces
16
+ return Array.isArray(workspaces) || isRecord(workspaces)
17
+ }
18
+
19
+ /**
20
+ * Records the chain an install resolved, and returns whether it wrote one. A
21
+ * workspace root is the single refusal: one chain recorded there would be a
22
+ * guess at what its packages hold, and the report reading tooling as unmeasured
23
+ * is the true answer rather than a clean one.
24
+ */
25
+ export async function recordToolingChain(
26
+ toolkitRoot: string,
27
+ target: string,
28
+ chain: readonly Manifest[],
29
+ now: Date,
30
+ ): Promise<boolean> {
31
+ if (isWorkspaceRoot(target)) return false
32
+
33
+ await writeChainStamp(
34
+ target,
35
+ toolkitRoot,
36
+ chain.map((manifest) => manifest.name),
37
+ now,
38
+ )
39
+ return true
40
+ }
41
+
42
+ function isRecord(value: unknown): value is Record<string, unknown> {
43
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
44
+ }
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Branch reference
3
3
  description: Branch naming format and type conventions
4
- consumers: git-branch, git-split, git-pr
4
+ consumers: git-branch, git-split, git-pr, claude-worktree
5
5
  ---
6
6
 
7
7
  # Branch reference
package/standards/slug.md CHANGED
@@ -16,9 +16,11 @@ Does not govern:
16
16
 
17
17
  ## The transform
18
18
 
19
- Run `git branch --show-current` and replace every `/` with `-`. The result is `<slug>`. Anything reading a branch-derived name uses this transform, so two surfaces cannot spell it differently.
19
+ Run `git branch --show-current`. Drop a leading segment naming one of the types `branch.md` defines when a further segment follows it, then replace every remaining `/` with `-`. The result is `<slug>`. Anything reading a branch-derived name uses this transform, so two surfaces cannot spell it differently.
20
20
 
21
- A surface that persists output to a shared folder carries the slug in the filename, which is what keeps parallel worktrees from overwriting each other's output.
21
+ The type comes off because a plan takes its slug from the concern it covers and the branch executing that plan carries the same slug behind a type prefix. Keeping the prefix sends every surface that finds a plan from a branch name looking for `feat-jwt-expiration`, which no plan is filed under. A first segment matching no type stays, so `spike/parser` still yields `spike-parser`.
22
+
23
+ A surface that persists output to a shared folder carries the slug in the filename, which is what keeps parallel worktrees from overwriting each other's output. Two branches differing only in type collapse onto one slug. That collision reaches the worktree directory as well, since it takes its name from the same string, so the branch is not where it would first surface.
22
24
 
23
25
  ## An empty result
24
26
 
@@ -53,11 +53,11 @@ A project installed before the diagram surface became a folder still holds `.cla
53
53
 
54
54
  ## CLI
55
55
 
56
- | Command | What it does |
57
- | ------------------- | --------------------------------------------------------------------------------------------------------------------------- |
58
- | `aitk claude init` | Seeds `.claude/` workflow docs, updates `.gitignore` |
59
- | `aitk claude sync` | Reconciles `.gitignore` against the manifest. Reports seeded file status. |
60
- | `aitk claude setup` | Installs user-level config to `~/.claude/`: statusline, attribution, and permission allows or denies. Run once per machine. |
56
+ | Command | What it does |
57
+ | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
58
+ | `aitk claude init` | Seeds `.claude/` workflow docs, updates `.gitignore` |
59
+ | `aitk claude sync` | Reconciles `.gitignore` against the manifest. Reports seeded file status. |
60
+ | `aitk claude setup` | Installs user-level config to `~/.claude/`: statusline, attribution, and the `permissions.allow` and `permissions.deny` lists. Run once per machine. |
61
61
 
62
62
  ## CLAUDE.md
63
63