@erclx/aitk 3.7.0 → 3.8.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/create-rule/REQUIREMENT.md +2 -1
- package/claude/skills/create-rule/SKILL.md +8 -8
- package/claude/skills/create-snippet/REQUIREMENT.md +3 -0
- package/claude/skills/create-snippet/SKILL.md +2 -2
- package/claude/skills/restate/REQUIREMENT.md +41 -0
- package/claude/skills/restate/SKILL.md +39 -0
- package/claude/skills/write-human/REQUIREMENT.md +1 -1
- package/claude/skills/write-human/SKILL.md +1 -1
- package/docs/agents/commands.md +22 -21
- package/docs/agents/index.md +1 -0
- package/docs/agents/install-and-sync.md +6 -2
- package/docs/agents/routing.md +61 -0
- package/docs/ai-workflow.md +1 -0
- package/package.json +1 -1
- package/src/claude/routing.ts +283 -0
- package/src/commands/claude.ts +130 -1
- package/src/gov/adapter.ts +1 -0
- package/src/snippets/adapter.ts +1 -0
- package/src/sync/engine.ts +25 -1
|
@@ -7,7 +7,7 @@ description: Why a project-local rule needs a band, a number checked against two
|
|
|
7
7
|
|
|
8
8
|
## Gap
|
|
9
9
|
|
|
10
|
-
Without this skill, a project rule is written straight into the rules folder on a number already taken, so two rules collide and one loses. The subtler collision
|
|
10
|
+
Without this skill, a project rule is written straight into the rules folder on a number already taken, so two rules collide and one loses. The subtler collision was with the toolkit itself: a number free in the target today could be the number a shipped rule landed on tomorrow, and the next install double-booked it, so a rule the project wrote got overwritten by one it never chose. Writing under `.claude/rules/project/` closes that half regardless of the number, since the sync engine orphans the file by location before it ever compares names, but the band still needs a free number to stay readable against the toolkit's own numbering.
|
|
11
11
|
|
|
12
12
|
The band gets picked by feel, so a UI copy rule lands in the always-on range and loads on every session for the rest of the project's life. The scope key fails in both directions. An always-on rule carrying a path scope fires only on files it was never about, and a path-scoped rule missing it loads constantly. Written from memory rather than from the rule standard, the body comes out in a shape the rest of the catalog does not share.
|
|
13
13
|
|
|
@@ -15,6 +15,7 @@ The band gets picked by feel, so a UI copy rule lands in the always-on range and
|
|
|
15
15
|
|
|
16
16
|
- Resolve what the rule enforces and where it applies, asking only for what the request leaves missing
|
|
17
17
|
- Pick the band from the topic and take its number range and folder from that choice
|
|
18
|
+
- Write under `.claude/rules/project/`, the subfolder the sync engine reads as project-authored by location
|
|
18
19
|
- Check both the target's used prefixes and the toolkit catalog before taking a number
|
|
19
20
|
- Read the rule standard before writing the body
|
|
20
21
|
- Emit the path scope for a path-scoped rule and omit the key entirely for an always-on one
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: create-rule
|
|
3
|
-
description: Scaffolds a project-specific governance rule into `.claude/rules/<subdir>/<n>-<slug>.md` with correct frontmatter and a non-colliding number. Use when asked to "add a rule", "create a governance rule", "write a project rule", or when a project needs a coding rule the toolkit does not ship. Do NOT use to edit toolkit source rules under `governance/rules/`.
|
|
3
|
+
description: Scaffolds a project-specific governance rule into `.claude/rules/project/<subdir>/<n>-<slug>.md` with correct frontmatter and a non-colliding number. Use when asked to "add a rule", "create a governance rule", "write a project rule", or when a project needs a coding rule the toolkit does not ship. Do NOT use to edit toolkit source rules under `governance/rules/`.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Create rule
|
|
7
7
|
|
|
8
|
-
Author a project-local governance rule. The rule lives in the target project, not the toolkit, so
|
|
8
|
+
Author a project-local governance rule. The rule lives in the target project, not the toolkit, so `aitk gov sync` never overwrites it: the sync engine orphans anything under `.claude/rules/project/` by location, before it ever checks the rule's name against the toolkit catalog.
|
|
9
9
|
|
|
10
10
|
## Guards
|
|
11
11
|
|
|
@@ -21,7 +21,7 @@ Resolve both from the request, and ask only for what is missing. Attach a propos
|
|
|
21
21
|
|
|
22
22
|
## Step 2: resolve band and subdir
|
|
23
23
|
|
|
24
|
-
Pick the band from the topic. Each band owns a number range and a subdir under `.claude/rules/`:
|
|
24
|
+
Pick the band from the topic. Each band owns a number range and a subdir under `.claude/rules/project/`:
|
|
25
25
|
|
|
26
26
|
- `core/` 000-099: global persona, testing, error handling, planning. Always-on, no `paths:`.
|
|
27
27
|
- `lang/` 100-199: one programming language.
|
|
@@ -34,15 +34,15 @@ Pick the band from the topic. Each band owns a number range and a subdir under `
|
|
|
34
34
|
|
|
35
35
|
Pick the lowest unused number in the band that collides with neither the project nor the toolkit catalog:
|
|
36
36
|
|
|
37
|
-
- Scan the target's `.claude/rules/<subdir>/` for used prefixes.
|
|
38
|
-
- Run `aitk gov list --json 2>/dev/null` and read the shipped rule numbers in the same range, so a later `aitk gov install`
|
|
39
|
-
- If `aitk` is not on PATH, scan the target only and warn that a
|
|
37
|
+
- Scan the target's `.claude/rules/project/<subdir>/` for used prefixes.
|
|
38
|
+
- Run `aitk gov list --json 2>/dev/null` and read the shipped rule numbers in the same range, so the band stays readable against the toolkit's own numbering even though location already keeps a later `aitk gov install` from touching this file.
|
|
39
|
+
- If `aitk` is not on PATH, scan the target only and warn that the band could read confusingly against a later toolkit install.
|
|
40
40
|
|
|
41
41
|
## Step 4: write the rule
|
|
42
42
|
|
|
43
43
|
Read `${CLAUDE_SKILL_DIR}/../../standards/rule.md` for frontmatter, body shape, and voice before writing the body. Do not work the shape from memory.
|
|
44
44
|
|
|
45
|
-
Write `.claude/rules/<subdir>/<n>-<slug>.md` where `<slug>` is a 1-to-3-word kebab topic. Preview the resolved path, band, number, and frontmatter, then write immediately. The tool permission dialog is the confirmation gate.
|
|
45
|
+
Write `.claude/rules/project/<subdir>/<n>-<slug>.md` where `<slug>` is a 1-to-3-word kebab topic. Preview the resolved path, band, number, and frontmatter, then write immediately. The tool permission dialog is the confirmation gate.
|
|
46
46
|
|
|
47
47
|
Frontmatter carries the Claude shape. Path-scoped rules emit one `paths:` entry per glob. Always-on rules omit `paths:` entirely.
|
|
48
48
|
|
|
@@ -65,4 +65,4 @@ Title casing is sentence case, with proper nouns keeping their own casing (`# Ty
|
|
|
65
65
|
|
|
66
66
|
## After writing
|
|
67
67
|
|
|
68
|
-
Emit the full path on its own line: `.claude/rules/<subdir>/<n>-<slug>.md`. Remind the user that Claude Code loads path-scoped rules when it reads a matching file, and always-on rules every session.
|
|
68
|
+
Emit the full path on its own line: `.claude/rules/project/<subdir>/<n>-<slug>.md`. Remind the user that Claude Code loads path-scoped rules when it reads a matching file, and always-on rules every session.
|
|
@@ -11,10 +11,13 @@ Without this skill, a snippet is drafted the way any other markdown file is draf
|
|
|
11
11
|
|
|
12
12
|
The write surface is the second failure. The toolkit authors at `snippets/` and a target project holds only the installed copy under `.claude/snippets/`, so a session picks whichever folder it noticed first. A toolkit snippet written into the consumed copy fails the drift assertion that regenerates that folder. A snippet written there in a target project is correct and looks identical, which is why the surface has to be resolved from what the project has rather than guessed.
|
|
13
13
|
|
|
14
|
+
Inside the project surface, a third failure sits one level deeper. A project-authored snippet sharing a name with a toolkit one used to be indistinguishable from it, so `aitk snippets sync` could not tell which side owned the file. The sync engine now reads ownership by location for anything under `.claude/snippets/project/`, so the skill has to write there rather than beside the toolkit-installed files.
|
|
15
|
+
|
|
14
16
|
## Must
|
|
15
17
|
|
|
16
18
|
- Read the bundled snippet reference before drafting, since the shape rules are what the invocation channels depend on
|
|
17
19
|
- Resolve the write surface from which snippet folder the project has, rather than from whichever one the session noticed first
|
|
20
|
+
- On the project surface, write under its `project/` subfolder so the sync engine reads the file as project-authored by location
|
|
18
21
|
- Confirm the slug and the full body with the user before writing
|
|
19
22
|
- Emit the written path in full, so the terminal can resolve it
|
|
20
23
|
- State what the resolved surface implies after writing, since a root file needs the consumed copy regenerated and a project-local one needs copying to the toolkit to ship
|
|
@@ -20,11 +20,11 @@ Creates one snippet file. Read these files in parallel:
|
|
|
20
20
|
1. Resolve the write surface: `snippets/` at the root if present, which is the toolkit's own authoring source. Otherwise `.claude/snippets/`, a target project's installed copy.
|
|
21
21
|
2. Draft the content from the user's description. The snippet reference governs structure, invocation, and authoring conventions.
|
|
22
22
|
3. Confirm the slug and full content with the user before writing
|
|
23
|
-
4. Write the file
|
|
23
|
+
4. Write the file. On the root surface: `<surface>/<category>/<slug>.md`, or `<surface>/<slug>.md` when the snippet takes no category. On the `.claude/` surface: the same shape nested one level deeper under `project/`, so the sync engine's location rule holds it apart from a toolkit-installed snippet regardless of what its name matches.
|
|
24
24
|
|
|
25
25
|
## After writing
|
|
26
26
|
|
|
27
27
|
Emit the full path on its own line.
|
|
28
28
|
|
|
29
29
|
- Root surface: this is the toolkit's authoring source. Remind the user to run `bun run check` to regenerate the consumed copy under `.claude/`.
|
|
30
|
-
- `.claude/` surface: the file is project-local
|
|
30
|
+
- `.claude/` surface: the file is project-local, under `.claude/snippets/project/`. `aitk snippets sync` leaves everything there alone by location rather than by a name it fails to recognize. Remind the user to copy it to the toolkit repo, under `snippets/<category>/<name>.md`, if it should ship to every project.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: restate
|
|
3
|
+
description: Why a plain restatement is asked for by name, and where its boundary sits against the skill that drafts and the command that measures
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Restate requirement
|
|
7
|
+
|
|
8
|
+
## Gap
|
|
9
|
+
|
|
10
|
+
Without this skill, a reader who cannot follow a dense answer has to ask for it again in ordinary words, and the second answer is drafted by the same session that produced the first. Nothing tells that session which half of its own text carried the decision, so the rewrite shortens the passage and keeps the abstraction that made it hard to read.
|
|
11
|
+
|
|
12
|
+
The corpus around it covers the neighboring jobs and not this one. `write-human` governs a passage being drafted or revised and arrives on a markdown edit, so it never sees an answer in chat and never reaches a document nobody is editing. `aitk markdown audit` reports sentence spread and repeated openings against a stated range, which measures how a passage moves and says nothing about whether a reader can act on it.
|
|
13
|
+
|
|
14
|
+
A restatement also fails in a way a rewrite does not. A run that compresses by dropping what supports a claim leaves a reader who acts on the plain version and meets a different answer in the source, and that failure is invisible in the restatement itself.
|
|
15
|
+
|
|
16
|
+
## Must
|
|
17
|
+
|
|
18
|
+
- Take a named markdown path or the preceding answer, resolving the path first, since a reader pointing at a file has already said which one they mean
|
|
19
|
+
- Keep every point that changes a decision and cut what only supports one, which is the split that separates a restatement from a summary
|
|
20
|
+
- Preserve a hedge the source carries, since dropping it manufactures a certainty
|
|
21
|
+
- Cite the carrier of the rhythm and density rules rather than restating them, because a second copy drifts with nothing comparing the two
|
|
22
|
+
- Fire on an explicit request and refuse the model's own judgment about its own output
|
|
23
|
+
|
|
24
|
+
## Must not
|
|
25
|
+
|
|
26
|
+
- Write a file. A restatement is read once to reach a decision, and a file makes a record nobody opens twice.
|
|
27
|
+
- Add a fact, number, or name the source does not carry
|
|
28
|
+
- Restate the voice, rhythm, or density rules, which live in the skill this body cites
|
|
29
|
+
- Claim the restatement is verified. Nothing checks whether a plain version kept the deciding half, so the sandbox arm asserts what the run did rather than that the output is good.
|
|
30
|
+
|
|
31
|
+
## Guards
|
|
32
|
+
|
|
33
|
+
- A named path that does not resolve stops the run rather than falling back to the preceding answer, since the two inputs are different requests
|
|
34
|
+
- A request with no path and nothing preceding it stops rather than restating the request itself
|
|
35
|
+
|
|
36
|
+
## Out of scope
|
|
37
|
+
|
|
38
|
+
- Drafting or revising a passage, which is `write-human` on a markdown edit
|
|
39
|
+
- Measuring cadence in finished output, which `aitk markdown audit` reports from package data
|
|
40
|
+
- Rewriting a document into a file, which is a proposal against the source rather than a restatement of it
|
|
41
|
+
- The banned words, characters, and spellings, which `markdown.md` states and the audit gates
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: restate
|
|
3
|
+
description: Restates a dense answer or a named markdown document in plain language, cutting jargon and keeping only what changes a decision. Use when asked to "restate that in plain language", "say that plainly", "what does this actually mean", "cut the jargon", "give me the plain version", or "restate this document". Do NOT use to draft or revise prose, which is `write-human`, and do NOT fire on the model's own judgment that its own output was dense.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Restate
|
|
7
|
+
|
|
8
|
+
Return the plain version of something already written. The reader asked because they stopped to decode rather than to decide, so the restatement earns its place by getting them to the decision.
|
|
9
|
+
|
|
10
|
+
## What to restate
|
|
11
|
+
|
|
12
|
+
- Restate the file when the request names a markdown path, and the preceding answer in the conversation when it names none. A named path wins over an answer sitting in the same turn.
|
|
13
|
+
- Stop when the request names a path that does not resolve: `❌ No file at <path>. Name a path that exists, or ask for the preceding answer instead.`
|
|
14
|
+
- Stop when nothing precedes the request and no path is named: `❌ Nothing to restate. Name a markdown path, or ask right after the answer you want in plain words.`
|
|
15
|
+
- Refuse a request to restate output on the model's own initiative. A person asks for this by name.
|
|
16
|
+
|
|
17
|
+
## What survives
|
|
18
|
+
|
|
19
|
+
- Keep every point that changes a decision, and cut every point that only supports one. A reader who acts on the restatement and is surprised by the original has been given the wrong half.
|
|
20
|
+
- Name the thing the source names. Replace a term of art with its plain equivalent, and keep the term where the reader has to search for it later.
|
|
21
|
+
- Say what is uncertain where the source hedges, and say it plainly. Dropping a hedge invents a certainty the source does not carry.
|
|
22
|
+
- Introduce no fact, number, or name the source lacks. A restatement changes wording and never claims.
|
|
23
|
+
- Report the reasoning, not the metaphor. An abstraction standing in for a mechanism is what made the source dense.
|
|
24
|
+
|
|
25
|
+
## How it reads
|
|
26
|
+
|
|
27
|
+
Load the `write-human` skill before writing the restatement. Compression is where a plain version turns into a list of verbless fragments, and that skill states the rhythm and density rules this one does not copy.
|
|
28
|
+
|
|
29
|
+
## Output
|
|
30
|
+
|
|
31
|
+
Answer in chat. Write no file, since a restatement is read once to reach a decision.
|
|
32
|
+
|
|
33
|
+
Lead with the plain version in prose. Close with one line naming what was cut and why, so the reader can go back for it:
|
|
34
|
+
|
|
35
|
+
```plaintext
|
|
36
|
+
Cut: <what left, and why it changes no decision>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Name the source path on its own line when the restatement came from a file.
|
|
@@ -41,6 +41,6 @@ Importing that catalog wholesale fails on a different measurement. External mate
|
|
|
41
41
|
|
|
42
42
|
- The banned words, characters, and spellings, which `markdown.md` states for a reader and `aitk markdown audit` gates from package data
|
|
43
43
|
- Markdown mechanics, being headings, lists, code spans, punctuation, and file references
|
|
44
|
-
- Restating text that already exists in plainer words, which
|
|
44
|
+
- Restating text that already exists in plainer words, which `restate` owns on a different trigger, since a restatement is asked for by name and this arrives on a glob match. The rules here still govern how that restatement reads, and that body cites this one rather than carrying a copy.
|
|
45
45
|
- What voice a given surface declares, which belongs to the standard governing that surface
|
|
46
46
|
- Measuring whether finished output followed these rules, which needs a check this skill does not carry
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: write-human
|
|
3
|
-
description: Carries the voice, rhythm, sentence construction, and information density rules a banned-word list cannot express, plus the catalog of machine tells that survive a clean ban scan. Use when writing or revising prose a person will read, when a passage reads flat or uniform, or when asked to "make this read like a person", "fix the cadence", "this reads like AI wrote it", "vary the sentences", or "tighten this without gutting it". Do NOT use for banned words, characters, or spellings, which `markdown.md` states and `aitk markdown audit` gates, and do NOT use to restate text that already exists in plainer words.
|
|
3
|
+
description: Carries the voice, rhythm, sentence construction, and information density rules a banned-word list cannot express, plus the catalog of machine tells that survive a clean ban scan. Use when writing or revising prose a person will read, when a passage reads flat or uniform, or when asked to "make this read like a person", "fix the cadence", "this reads like AI wrote it", "vary the sentences", or "tighten this without gutting it". Do NOT use for banned words, characters, or spellings, which `markdown.md` states and `aitk markdown audit` gates, and do NOT use to restate text that already exists in plainer words, which is `restate`, whose body loads these rules from here.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Write human
|
package/docs/agents/commands.md
CHANGED
|
@@ -47,6 +47,7 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
|
|
|
47
47
|
| `aitk claude skills audit` | Report both skill corpora against the mechanical rules in `standards/skill.md` |
|
|
48
48
|
| `aitk claude skills drift` | Name the shipped skill bodies rewritten between a given ref and `HEAD`, and the installed version against the newest published (`--json`) |
|
|
49
49
|
| `aitk claude skills reach` | Report the shipped bodies citing a toolkit path no target project receives, exiting 2 on an unqualified one |
|
|
50
|
+
| `aitk claude routing` | Report per `CLAUDE.md` section how many bullets name a path and how many of those a path-scoped rule already covers (`--json`) |
|
|
50
51
|
| `aitk gov test-order` | Report where an implementation reached history ahead of the test covering it (`--json`) |
|
|
51
52
|
| `aitk gov superseded` | Report where the tree still asserts a value a changed convention no longer produces, keyed on the value (`--json`) |
|
|
52
53
|
| `aitk secrets scan` | Report credential-shaped values in the tree the package ships, keyed on issued values rather than on words (`--json`) |
|
|
@@ -63,27 +64,27 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
|
|
|
63
64
|
|
|
64
65
|
Each domain exposes a consistent shape where applicable: `list`, `install`, `sync`, `create`.
|
|
65
66
|
|
|
66
|
-
| Domain | Subcommands
|
|
67
|
-
| ----------- |
|
|
68
|
-
| `tooling` | `list`, `sync`, `ref`, `create`, `verify`, `inject`, `prune-gitignore`
|
|
69
|
-
| `snippets` | `list`, `install`, `sync`, `create`
|
|
70
|
-
| `standards` | `list`, `<name>`
|
|
71
|
-
| `gov` | `list`, `install`, `sync`, `build`, `regen`, `test-order`, `superseded`
|
|
72
|
-
| `claude` | `init`, `sync`, `seeds list`, `skills list`, `skills audit`, `skills drift`, `skills reach`, `setup [dest]` |
|
|
73
|
-
| `demo` | `compile`, `run`
|
|
74
|
-
| `wiki` | `init`
|
|
75
|
-
| `design` | `render`
|
|
76
|
-
| `slides` | `render`, `list`
|
|
77
|
-
| `tasks` | `archive`, `validate`
|
|
78
|
-
| `intake` | `list`, `answer`
|
|
79
|
-
| `teach` | `list`, `open`, `resource`, `glossary`
|
|
80
|
-
| `comments` | `scan`
|
|
81
|
-
| `context` | `audit`
|
|
82
|
-
| `markdown` | `audit`
|
|
83
|
-
| `secrets` | `scan`
|
|
84
|
-
| `deps` | `audit`
|
|
85
|
-
| `labels` | `audit`
|
|
86
|
-
| `audits` | `run`, `list`
|
|
67
|
+
| Domain | Subcommands |
|
|
68
|
+
| ----------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
69
|
+
| `tooling` | `list`, `sync`, `ref`, `create`, `verify`, `inject`, `prune-gitignore` |
|
|
70
|
+
| `snippets` | `list`, `install`, `sync`, `create` |
|
|
71
|
+
| `standards` | `list`, `<name>` |
|
|
72
|
+
| `gov` | `list`, `install`, `sync`, `build`, `regen`, `test-order`, `superseded` |
|
|
73
|
+
| `claude` | `init`, `sync`, `routing`, `seeds list`, `skills list`, `skills audit`, `skills drift`, `skills reach`, `setup [dest]` |
|
|
74
|
+
| `demo` | `compile`, `run` |
|
|
75
|
+
| `wiki` | `init` |
|
|
76
|
+
| `design` | `render` |
|
|
77
|
+
| `slides` | `render`, `list` |
|
|
78
|
+
| `tasks` | `archive`, `validate` |
|
|
79
|
+
| `intake` | `list`, `answer` |
|
|
80
|
+
| `teach` | `list`, `open`, `resource`, `glossary` |
|
|
81
|
+
| `comments` | `scan` |
|
|
82
|
+
| `context` | `audit` |
|
|
83
|
+
| `markdown` | `audit` |
|
|
84
|
+
| `secrets` | `scan` |
|
|
85
|
+
| `deps` | `audit` |
|
|
86
|
+
| `labels` | `audit` |
|
|
87
|
+
| `audits` | `run`, `list` |
|
|
87
88
|
|
|
88
89
|
Common patterns:
|
|
89
90
|
|
package/docs/agents/index.md
CHANGED
|
@@ -24,6 +24,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
|
|
|
24
24
|
- [Output shape](output-shape.md): Two framed shapes every command renders into, how JSON and --names modes keep stdout clean, and the exit discipline that lets piped output drain
|
|
25
25
|
- [Overview](overview.md): What this folder covers, the invocation rules every command inherits, and where domain behavior is documented instead
|
|
26
26
|
- [Records](records.md): Validating the session records under .claude/ and the standards corpus, the per-kind checks, the refusal reasons, reading each folder's size and growth, backing the folders to a private remote, and which root each kind defaults to
|
|
27
|
+
- [Routing report](routing.md): Reading per CLAUDE.md section how many bullets name a path, what counts as naming one, when a rule counts as covering it, the two refusals, and why the verb reports rather than gates
|
|
27
28
|
- [Sandbox](sandbox.md): Scenario routing, the expectation scoring surface, and the coverage census over scenarios and skills
|
|
28
29
|
- [Scripting](scripting.md): The runtime catalogs that replace hardcoded names, what each carries, and a headless invocation per domain
|
|
29
30
|
- [Sessions](sessions.md): Resolving live peer sessions to the worktree and branch each holds, the liveness confidence field, the unresolved reasons, and what the read depends on
|
|
@@ -11,7 +11,9 @@ The behavior notes behind the verbs listed in `commands.md`. Each one records wh
|
|
|
11
11
|
|
|
12
12
|
`aitk gov sync` updates only rules already present under `.claude/rules/` and
|
|
13
13
|
never adds new ones. A rule the toolkit does not ship is left alone, which is
|
|
14
|
-
how project-authored rules survive
|
|
14
|
+
how project-authored rules survive, and one under `.claude/rules/project/`
|
|
15
|
+
is left alone regardless of its name, since that subfolder is project-authored
|
|
16
|
+
by location. It also removes a stale `.claude/GOV.md`
|
|
15
17
|
from the retired build. Use `aitk gov install` to add rules.
|
|
16
18
|
|
|
17
19
|
There is no `aitk standards sync` and no `aitk standards install`. The corpus
|
|
@@ -23,7 +25,9 @@ the catalog.
|
|
|
23
25
|
`aitk snippets sync` matches by path relative to `.claude/snippets/`. It
|
|
24
26
|
matches by path relative to that directory, so a snippet the toolkit no longer
|
|
25
27
|
ships, or one authored directly in the target, is reported and skipped rather
|
|
26
|
-
than deleted
|
|
28
|
+
than deleted, and a snippet under `.claude/snippets/project/` skips the same
|
|
29
|
+
way regardless of path, since that subfolder is project-authored by location.
|
|
30
|
+
It is not preset-aware, so a project that installed `essentials`
|
|
27
31
|
does not grow new snippets on a sync. Use `aitk snippets install` to add them.
|
|
28
32
|
|
|
29
33
|
## Install guards
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Routing report
|
|
3
|
+
description: Reading per CLAUDE.md section how many bullets name a path, what counts as naming one, when a rule counts as covering it, the two refusals, and why the verb reports rather than gates
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Routing report
|
|
7
|
+
|
|
8
|
+
`aitk claude routing` reports, per `CLAUDE.md` section, how many top-level bullets name a path and how many of those a path-scoped rule already covers. It answers the tier test in `.claude/rules/claude/592-claude-md.md`, which asks whether a fact applies every session or fires on one path, and which until this verb existed was a judgment nothing counted.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
aitk claude routing
|
|
12
|
+
aitk claude routing --json
|
|
13
|
+
aitk claude routing ../my-app
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
| Option | Behavior |
|
|
17
|
+
| -------- | ------------------------------------------------------------ |
|
|
18
|
+
| `[path]` | Repository root to read, defaulting to the current directory |
|
|
19
|
+
| `--json` | Add a machine-readable record on stdout, keeping the frame |
|
|
20
|
+
|
|
21
|
+
Under `--json` the record holds stdout alone and the frame still renders on stderr, refusals included, which is the split `output-shape.md` fixes for every mode.
|
|
22
|
+
|
|
23
|
+
The root is the argument rather than the toolkit, matching the reach and drift verbs, so a linked worktree reads its own branch and a target reads its own file.
|
|
24
|
+
|
|
25
|
+
## What it reads
|
|
26
|
+
|
|
27
|
+
Every H2 and H3 owning at least one top-level bullet. An H3 is reported under the H2 containing it, as `Behavior / Scope discipline`, so a section and its subsections are counted apart rather than summed.
|
|
28
|
+
|
|
29
|
+
Three things are read past. A nested bullet belongs to the one above it rather than to the section. A bullet inside a fenced block is example text rather than instruction. A heading carrying no bullet answers nothing the report asks, so it is dropped rather than listed at zero.
|
|
30
|
+
|
|
31
|
+
## What counts as naming a path
|
|
32
|
+
|
|
33
|
+
A bullet is path-scoped here when it names a path. That is evidence for the tier judgment rather than the judgment itself, and the gap runs both ways: a bullet naming a folder can still apply every session, and one firing on a path it never spells is invisible to the count. Read a section's number as a place to look rather than as a verdict on it.
|
|
34
|
+
|
|
35
|
+
A backticked token counts when it carries a separator or an alphabetic extension, which admits `src/cli.ts` and `cspell.json` while leaving a flag, a bare word, and a version string out.
|
|
36
|
+
|
|
37
|
+
A shape counts as the folder above its placeholder. `.claude/context/<domain>.md` names `.claude/context/` and nothing narrower, so dropping the token whole would report the section carrying it as naming no path at all. A placeholder opening the first segment has no openable prefix and is dropped.
|
|
38
|
+
|
|
39
|
+
## When a rule counts as covering it
|
|
40
|
+
|
|
41
|
+
A rule covers a named path only when its glob anchors to a location. A glob opening `**` reaches every folder in the tree, so it answers that a file type is governed and never that a named path is.
|
|
42
|
+
|
|
43
|
+
The corpus-wide markdown rules are what force that. Counting one reports every markdown path as covered, which collapses the column to a constant and tells a reader nothing about which folder somebody actually scoped a rule to.
|
|
44
|
+
|
|
45
|
+
A folder is probed with a handful of extensions rather than matched literally, because a glob narrowed by file type reaches under a folder without ever matching the folder's own name. Globs are read from each rule's frontmatter block alone, so a rule quoting a path in its body does not register a scope it never declared.
|
|
46
|
+
|
|
47
|
+
The paths a section names that no rule reaches are listed beside it, which is where a reader looks first when deciding what a new rule would cover.
|
|
48
|
+
|
|
49
|
+
## Refusals
|
|
50
|
+
|
|
51
|
+
Two, each naming what a reader does about it. `no-claude-md` is a tree with no always-loaded file. `no-rules` is a tree carrying no path-scoped rule under `.claude/rules/`, where every path would report as uncovered and the column would say nothing.
|
|
52
|
+
|
|
53
|
+
An always-on rule declaring no `paths` is skipped rather than refused. It applies at the same priority as the always-loaded file, so it covers no path in particular.
|
|
54
|
+
|
|
55
|
+
## Exit codes
|
|
56
|
+
|
|
57
|
+
Exit codes are `0` when the file was read and `1` for a refusal. No finding moves the exit code.
|
|
58
|
+
|
|
59
|
+
Nothing wires this into `bun run check` or into a hook. Whether a bullet belongs in a rule is a judgment, and gating a measure that counts a judgment forces an escape hatch for every deliberate case.
|
|
60
|
+
|
|
61
|
+
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 JSON record's `sections` array rather than the exit when a skill consumes this.
|
package/docs/ai-workflow.md
CHANGED
|
@@ -288,6 +288,7 @@ This section is the corpus the coverage claim is measured against: every name `a
|
|
|
288
288
|
| `aitk:youtube-transcripts` | When a video transcript is wanted in the repo as context |
|
|
289
289
|
| `aitk:claude-teach` | To learn a subject across sessions, in a workspace that holds the progress |
|
|
290
290
|
| `aitk:write-human` | Before drafting or revising prose, for voice, rhythm, and density |
|
|
291
|
+
| `aitk:restate` | When an answer or a document has to be read again in plain words |
|
|
291
292
|
|
|
292
293
|
Every row answers a question rather than marking a point in a project's life, so a phase above would send a reader to the wrong group.
|
|
293
294
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const CLAUDE_MD = 'CLAUDE.md'
|
|
5
|
+
const RULES_DIR = join('.claude', 'rules')
|
|
6
|
+
|
|
7
|
+
/** A backticked token, which is how the always-loaded file spells a path. */
|
|
8
|
+
const TOKEN = /`([^`\s]+)`/g
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A path a reader could open. Admits no `$`, `*`, or `=`, so an assignment
|
|
12
|
+
* (`AITK_NON_INTERACTIVE=1`) and a variable are both excluded.
|
|
13
|
+
*/
|
|
14
|
+
const PATH_TOKEN = /^[.A-Za-z0-9_][A-Za-z0-9._/-]*$/
|
|
15
|
+
|
|
16
|
+
/** A placeholder segment, which a shape carries and a real path does not. */
|
|
17
|
+
const SHAPE = /<[^>]*>/
|
|
18
|
+
|
|
19
|
+
/** An alphabetic extension, which separates `cspell.json` from `3.6.0`. */
|
|
20
|
+
const EXTENSION = /\.[a-z]{2,5}$/
|
|
21
|
+
|
|
22
|
+
/** Extensions a folder is probed with, so a glob narrowed by type still hits. */
|
|
23
|
+
const PROBES = ['probe.md', 'probe.ts', 'probe.tsx', 'probe.sh', 'probe.json']
|
|
24
|
+
|
|
25
|
+
export interface RuleGlobs {
|
|
26
|
+
/** Path under `.claude/rules/`, which is how a reader cites a rule. */
|
|
27
|
+
readonly rule: string
|
|
28
|
+
readonly globs: readonly string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Section {
|
|
32
|
+
/** `Behavior` for an H2, `Behavior / Scope` for the H3 beneath it. */
|
|
33
|
+
readonly heading: string
|
|
34
|
+
/** The top-level bullet lines the heading owns, in reading order. */
|
|
35
|
+
readonly lines: readonly string[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SectionReport {
|
|
39
|
+
readonly heading: string
|
|
40
|
+
readonly bullets: number
|
|
41
|
+
/** Bullets naming at least one path, which is the reading this measures. */
|
|
42
|
+
readonly pathScoped: number
|
|
43
|
+
/** Path-scoped bullets whose named path some rule glob already reaches. */
|
|
44
|
+
readonly covered: number
|
|
45
|
+
/** Distinct named paths no rule glob reaches, in reading order. */
|
|
46
|
+
readonly uncovered: readonly string[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Why a scan produced no reading, which is never the same as a clean one. */
|
|
50
|
+
export type RoutingRefusal = 'no-claude-md' | 'no-rules'
|
|
51
|
+
|
|
52
|
+
export type RoutingReport =
|
|
53
|
+
| {
|
|
54
|
+
readonly kind: 'measured'
|
|
55
|
+
readonly rules: number
|
|
56
|
+
readonly sections: readonly SectionReport[]
|
|
57
|
+
}
|
|
58
|
+
| { readonly kind: 'refused'; readonly reason: RoutingRefusal }
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Every distinct path one bullet names, in reading order.
|
|
62
|
+
*
|
|
63
|
+
* Naming a path is what this measures, not firing only on it. A bullet can
|
|
64
|
+
* name a folder and still apply every session, and one can fire on a path it
|
|
65
|
+
* never spells, so the count is a reading a person still has to judge.
|
|
66
|
+
*/
|
|
67
|
+
export function namedPaths(line: string): string[] {
|
|
68
|
+
const paths: string[] = []
|
|
69
|
+
|
|
70
|
+
for (const match of line.matchAll(TOKEN)) {
|
|
71
|
+
const token = concretePrefix(match[1])
|
|
72
|
+
if (token === undefined) continue
|
|
73
|
+
if (!PATH_TOKEN.test(token)) continue
|
|
74
|
+
if (!token.includes('/') && !EXTENSION.test(token)) continue
|
|
75
|
+
if (!paths.includes(token)) paths.push(token)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return paths
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The openable part of a token, which for a shape is the folder above the
|
|
83
|
+
* placeholder.
|
|
84
|
+
*
|
|
85
|
+
* `.claude/context/<domain>.md` names `.claude/context/` and nothing narrower,
|
|
86
|
+
* so dropping the whole token would report the section that carries it as
|
|
87
|
+
* naming no path at all. A token whose placeholder sits in the first segment
|
|
88
|
+
* has no openable prefix and is dropped.
|
|
89
|
+
*/
|
|
90
|
+
function concretePrefix(token: string): string | undefined {
|
|
91
|
+
if (!SHAPE.test(token)) return token
|
|
92
|
+
|
|
93
|
+
const prefix = token.slice(0, token.indexOf('<'))
|
|
94
|
+
return prefix.endsWith('/') ? prefix : undefined
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Splits the file into the sections a reader sees, counting top-level bullets.
|
|
99
|
+
*
|
|
100
|
+
* A nested bullet belongs to the one above it rather than to the section, and
|
|
101
|
+
* a fenced block holds example text rather than instruction, so neither is
|
|
102
|
+
* counted. A heading carrying no bullet is dropped, since the report answers
|
|
103
|
+
* how many bullets are path-scoped and a section with none answers nothing.
|
|
104
|
+
*/
|
|
105
|
+
export function splitSections(text: string): Section[] {
|
|
106
|
+
const sections: Section[] = []
|
|
107
|
+
let parent = ''
|
|
108
|
+
let heading: string | undefined
|
|
109
|
+
let lines: string[] = []
|
|
110
|
+
let fenced = false
|
|
111
|
+
|
|
112
|
+
const flush = (): void => {
|
|
113
|
+
if (heading !== undefined && lines.length > 0)
|
|
114
|
+
sections.push({ heading, lines })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const open = (next: string): void => {
|
|
118
|
+
flush()
|
|
119
|
+
heading = next
|
|
120
|
+
lines = []
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const line of text.split('\n')) {
|
|
124
|
+
if (line.startsWith('```')) {
|
|
125
|
+
fenced = !fenced
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
if (fenced) continue
|
|
129
|
+
|
|
130
|
+
const h2 = line.match(/^## (.+)$/)
|
|
131
|
+
if (h2) {
|
|
132
|
+
parent = h2[1]
|
|
133
|
+
open(parent)
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const h3 = line.match(/^### (.+)$/)
|
|
138
|
+
if (h3) {
|
|
139
|
+
open(parent === '' ? h3[1] : `${parent} / ${h3[1]}`)
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (line.startsWith('- ')) lines.push(line)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
flush()
|
|
147
|
+
return sections
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Every path-scoped rule the tree installs, with the globs it declares.
|
|
152
|
+
*
|
|
153
|
+
* An always-on rule declares no `paths` and applies at the same priority as
|
|
154
|
+
* the always-loaded file, so it covers no path in particular and is skipped.
|
|
155
|
+
*/
|
|
156
|
+
export function readRuleGlobs(root: string): RuleGlobs[] {
|
|
157
|
+
const rulesRoot = join(root, RULES_DIR)
|
|
158
|
+
if (!existsSync(rulesRoot)) return []
|
|
159
|
+
|
|
160
|
+
const rules: RuleGlobs[] = []
|
|
161
|
+
const files = [
|
|
162
|
+
...new Bun.Glob('**/*.md').scanSync({ cwd: rulesRoot, onlyFiles: true }),
|
|
163
|
+
].sort()
|
|
164
|
+
|
|
165
|
+
for (const file of files) {
|
|
166
|
+
const text = readFileSync(join(rulesRoot, file), 'utf8')
|
|
167
|
+
const globs = [...frontmatter(text).matchAll(/^\s*-\s*'([^']+)'\s*$/gm)]
|
|
168
|
+
.map((match) => match[1])
|
|
169
|
+
.filter((glob) => glob.includes('*') || glob.includes('.'))
|
|
170
|
+
|
|
171
|
+
if (globs.length === 0) continue
|
|
172
|
+
rules.push({ rule: file.replaceAll('\\', '/'), globs })
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return rules
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The block between the opening and closing `---`, or nothing for a rule
|
|
180
|
+
* carrying no frontmatter.
|
|
181
|
+
*
|
|
182
|
+
* Bounding the read is what keeps a body bullet out of the glob list. A rule
|
|
183
|
+
* quoting a path in prose would otherwise register it as a scope the rule
|
|
184
|
+
* never declared, and the resulting coverage would be wrong with nothing
|
|
185
|
+
* reporting it.
|
|
186
|
+
*/
|
|
187
|
+
function frontmatter(text: string): string {
|
|
188
|
+
if (!text.startsWith('---\n')) return ''
|
|
189
|
+
|
|
190
|
+
const end = text.indexOf('\n---', 3)
|
|
191
|
+
return end === -1 ? '' : text.slice(4, end)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Whether a glob scopes itself to a location rather than to a file type.
|
|
196
|
+
*
|
|
197
|
+
* A glob opening `**` reaches every folder in the tree, so it answers that a
|
|
198
|
+
* file type is governed and never that a named path is. Counting one would
|
|
199
|
+
* report every markdown path covered by `501-markdown` and leave the column
|
|
200
|
+
* saying nothing a reader could act on.
|
|
201
|
+
*/
|
|
202
|
+
function isAnchored(glob: string): boolean {
|
|
203
|
+
return !glob.startsWith('**/')
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The first rule scoping itself to a named path, or undefined for none.
|
|
208
|
+
*
|
|
209
|
+
* A folder is probed with a handful of extensions rather than matched as a
|
|
210
|
+
* literal, because a glob narrowed by file type reaches under the folder
|
|
211
|
+
* without ever matching the folder's own name.
|
|
212
|
+
*/
|
|
213
|
+
export function coveringRule(
|
|
214
|
+
path: string,
|
|
215
|
+
rules: readonly RuleGlobs[],
|
|
216
|
+
): string | undefined {
|
|
217
|
+
const bare = path.replace(/\/+$/, '')
|
|
218
|
+
const candidates = [bare, ...PROBES.map((probe) => `${bare}/${probe}`)]
|
|
219
|
+
|
|
220
|
+
for (const { rule, globs } of rules) {
|
|
221
|
+
for (const glob of globs) {
|
|
222
|
+
if (!isAnchored(glob)) continue
|
|
223
|
+
const matcher = new Bun.Glob(glob)
|
|
224
|
+
if (candidates.some((candidate) => matcher.match(candidate))) return rule
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return undefined
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Reads the always-loaded file against the rules installed beside it.
|
|
233
|
+
*
|
|
234
|
+
* Measures the tree it is pointed at rather than the toolkit root, so a linked
|
|
235
|
+
* worktree reads its own branch and a target reads its own file.
|
|
236
|
+
*/
|
|
237
|
+
export function scanRouting(root: string): RoutingReport {
|
|
238
|
+
const file = join(root, CLAUDE_MD)
|
|
239
|
+
if (!existsSync(file)) return { kind: 'refused', reason: 'no-claude-md' }
|
|
240
|
+
|
|
241
|
+
const rules = readRuleGlobs(root)
|
|
242
|
+
if (rules.length === 0) return { kind: 'refused', reason: 'no-rules' }
|
|
243
|
+
|
|
244
|
+
const sections = splitSections(readFileSync(file, 'utf8')).map((section) =>
|
|
245
|
+
classify(section, rules),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
return { kind: 'measured', rules: rules.length, sections }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Counts one section's bullets against the rules installed beside the file. */
|
|
252
|
+
function classify(
|
|
253
|
+
section: Section,
|
|
254
|
+
rules: readonly RuleGlobs[],
|
|
255
|
+
): SectionReport {
|
|
256
|
+
let pathScoped = 0
|
|
257
|
+
let covered = 0
|
|
258
|
+
const uncovered: string[] = []
|
|
259
|
+
|
|
260
|
+
for (const line of section.lines) {
|
|
261
|
+
const paths = namedPaths(line)
|
|
262
|
+
if (paths.length === 0) continue
|
|
263
|
+
pathScoped += 1
|
|
264
|
+
|
|
265
|
+
const reached = paths.filter(
|
|
266
|
+
(path) => coveringRule(path, rules) !== undefined,
|
|
267
|
+
)
|
|
268
|
+
if (reached.length > 0) covered += 1
|
|
269
|
+
|
|
270
|
+
for (const path of paths) {
|
|
271
|
+
if (reached.includes(path) || uncovered.includes(path)) continue
|
|
272
|
+
uncovered.push(path)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
heading: section.heading,
|
|
278
|
+
bullets: section.lines.length,
|
|
279
|
+
pathScoped,
|
|
280
|
+
covered,
|
|
281
|
+
uncovered,
|
|
282
|
+
}
|
|
283
|
+
}
|
package/src/commands/claude.ts
CHANGED
|
@@ -21,6 +21,11 @@ import {
|
|
|
21
21
|
type SkillFinding,
|
|
22
22
|
type SkillsAudit,
|
|
23
23
|
} from '@/claude/skills-audit'
|
|
24
|
+
import {
|
|
25
|
+
type RoutingRefusal,
|
|
26
|
+
type RoutingReport,
|
|
27
|
+
scanRouting,
|
|
28
|
+
} from '@/claude/routing'
|
|
24
29
|
import { type DriftReport, readDrift } from '@/claude/skills-drift'
|
|
25
30
|
import { listSkills } from '@/claude/skills-list'
|
|
26
31
|
import {
|
|
@@ -79,6 +84,10 @@ interface SkillsReachOptions {
|
|
|
79
84
|
readonly json?: boolean
|
|
80
85
|
}
|
|
81
86
|
|
|
87
|
+
interface RoutingOptions {
|
|
88
|
+
readonly json?: boolean
|
|
89
|
+
}
|
|
90
|
+
|
|
82
91
|
const SEEDED_FILES: readonly string[] = [
|
|
83
92
|
'ARCHITECTURE.md',
|
|
84
93
|
'REQUIREMENTS.md',
|
|
@@ -91,7 +100,7 @@ const STATUSLINE = 'statusline-command.sh'
|
|
|
91
100
|
export function register(program: Command): void {
|
|
92
101
|
const claude = program
|
|
93
102
|
.command('claude')
|
|
94
|
-
.description('Claude workflow (init, seeds, sync, setup)')
|
|
103
|
+
.description('Claude workflow (init, seeds, sync, setup, routing)')
|
|
95
104
|
.helpOption('-h, --help', 'Show this help message')
|
|
96
105
|
.addHelpText(
|
|
97
106
|
'after',
|
|
@@ -167,6 +176,42 @@ export function register(program: Command): void {
|
|
|
167
176
|
process.exitCode = await runSeedsList(opts)
|
|
168
177
|
})
|
|
169
178
|
|
|
179
|
+
claude
|
|
180
|
+
.command('routing')
|
|
181
|
+
.description('Report per CLAUDE.md section how many bullets name a path')
|
|
182
|
+
.argument('[path]', 'Repository root, defaulting to the current directory')
|
|
183
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
184
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
185
|
+
.addHelpText(
|
|
186
|
+
'after',
|
|
187
|
+
[
|
|
188
|
+
'',
|
|
189
|
+
'Scope:',
|
|
190
|
+
' Every H2 and H3 in CLAUDE.md that owns at least one top-level',
|
|
191
|
+
' bullet, counted against the path-scoped rules under .claude/rules/.',
|
|
192
|
+
' A bullet is path-scoped here when it names a path, which is not the',
|
|
193
|
+
" same as firing only on one. A rule's glob covers a named folder",
|
|
194
|
+
' only when the glob is anchored to a location rather than to a file',
|
|
195
|
+
' type, so **/*.md covers README.md and no folder at all.',
|
|
196
|
+
'',
|
|
197
|
+
'Exit codes:',
|
|
198
|
+
' 0 the file was read',
|
|
199
|
+
' 1 refused, with the reason on stderr',
|
|
200
|
+
'',
|
|
201
|
+
'Reports rather than gates. Whether a bullet belongs in a rule is the',
|
|
202
|
+
'judgment 592-claude-md states, and naming a path is evidence for it',
|
|
203
|
+
'rather than the answer.',
|
|
204
|
+
'',
|
|
205
|
+
'Examples:',
|
|
206
|
+
' aitk claude routing',
|
|
207
|
+
' aitk claude routing --json',
|
|
208
|
+
'',
|
|
209
|
+
].join('\n'),
|
|
210
|
+
)
|
|
211
|
+
.action((path: string | undefined, opts: RoutingOptions) => {
|
|
212
|
+
process.exitCode = runRouting(path, opts)
|
|
213
|
+
})
|
|
214
|
+
|
|
170
215
|
const skills = claude
|
|
171
216
|
.command('skills')
|
|
172
217
|
.description('Plugin skill catalog (list, audit, drift, reach)')
|
|
@@ -612,6 +657,90 @@ function reportSkew(skew: SkewReport): void {
|
|
|
612
657
|
else logInfo(describeSkew(skew))
|
|
613
658
|
}
|
|
614
659
|
|
|
660
|
+
/** What a reader does about each way the reading cannot be taken. */
|
|
661
|
+
const ROUTING_REFUSALS: Record<RoutingRefusal, string> = {
|
|
662
|
+
'no-claude-md': 'No CLAUDE.md here, so this tree has no always-loaded file.',
|
|
663
|
+
'no-rules':
|
|
664
|
+
'No path-scoped rules under .claude/rules/, so nothing covers a path yet.',
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Measures the cwd rather than the toolkit root, matching the reach and drift
|
|
669
|
+
* verbs, so a linked worktree reads its own branch instead of `main`.
|
|
670
|
+
*/
|
|
671
|
+
function runRouting(path: string | undefined, opts: RoutingOptions): number {
|
|
672
|
+
const root = resolve(path ?? process.cwd())
|
|
673
|
+
const report = scanRouting(root)
|
|
674
|
+
|
|
675
|
+
if (report.kind === 'refused') {
|
|
676
|
+
frameError(ROUTING_REFUSALS[report.reason])
|
|
677
|
+
if (opts.json) {
|
|
678
|
+
process.stdout.write(
|
|
679
|
+
`${JSON.stringify({
|
|
680
|
+
root,
|
|
681
|
+
reason: report.reason,
|
|
682
|
+
message: ROUTING_REFUSALS[report.reason],
|
|
683
|
+
})}\n`,
|
|
684
|
+
)
|
|
685
|
+
}
|
|
686
|
+
return 1
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
intro('aitk claude routing')
|
|
690
|
+
reportRouting(report)
|
|
691
|
+
outro()
|
|
692
|
+
|
|
693
|
+
if (opts.json) {
|
|
694
|
+
process.stdout.write(
|
|
695
|
+
`${JSON.stringify({
|
|
696
|
+
root,
|
|
697
|
+
rules: report.rules,
|
|
698
|
+
sections: report.sections,
|
|
699
|
+
})}\n`,
|
|
700
|
+
)
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
return 0
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* States the corpus on every run, so a section naming no path reads as
|
|
708
|
+
* measured rather than as skipped. A reader deciding what to cut needs the
|
|
709
|
+
* sections that stay as much as the ones that move.
|
|
710
|
+
*/
|
|
711
|
+
function reportRouting(
|
|
712
|
+
report: Extract<RoutingReport, { kind: 'measured' }>,
|
|
713
|
+
): void {
|
|
714
|
+
const bullets = report.sections.reduce(
|
|
715
|
+
(total, section) => total + section.bullets,
|
|
716
|
+
0,
|
|
717
|
+
)
|
|
718
|
+
const pathScoped = report.sections.reduce(
|
|
719
|
+
(total, section) => total + section.pathScoped,
|
|
720
|
+
0,
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
logStep('Corpus')
|
|
724
|
+
logInfo(
|
|
725
|
+
`${plural(report.sections.length, 'section')} carrying ${plural(bullets, 'bullet')}, read against ${plural(report.rules, 'path-scoped rule')}`,
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
logStep('Sections')
|
|
729
|
+
logInfo(`${pathScoped} of ${bullets} bullets name a path`)
|
|
730
|
+
pipeOutput(
|
|
731
|
+
report.sections
|
|
732
|
+
.map(
|
|
733
|
+
(section) =>
|
|
734
|
+
`${section.pathScoped}/${section.bullets} path-scoped, ${section.covered} covered ${section.heading}${
|
|
735
|
+
section.uncovered.length === 0
|
|
736
|
+
? ''
|
|
737
|
+
: ` [uncovered: ${section.uncovered.join(', ')}]`
|
|
738
|
+
}`,
|
|
739
|
+
)
|
|
740
|
+
.join('\n'),
|
|
741
|
+
)
|
|
742
|
+
}
|
|
743
|
+
|
|
615
744
|
/** What a reader does about the one way the corpus fails to build. */
|
|
616
745
|
const REACH_REFUSALS: Record<ReachRefusal, string> = {
|
|
617
746
|
'no-skills':
|
package/src/gov/adapter.ts
CHANGED
|
@@ -54,6 +54,7 @@ export function createGovAdapter(root: string): SyncAdapter {
|
|
|
54
54
|
locateSource: (file: InstalledFile) =>
|
|
55
55
|
index.get(basename(file.path, '.md')),
|
|
56
56
|
collectRetired: (target: string) => collectRetiredGov(target),
|
|
57
|
+
projectSubdir: 'project',
|
|
57
58
|
stamp: { domain: 'governance', toolkitRoot: root },
|
|
58
59
|
}
|
|
59
60
|
}
|
package/src/snippets/adapter.ts
CHANGED
|
@@ -21,6 +21,7 @@ export function createSnippetsAdapter(root: string): SyncAdapter {
|
|
|
21
21
|
unit: 'snippets',
|
|
22
22
|
installedRoot: (target: string) => join(target, '.claude', 'snippets'),
|
|
23
23
|
locateSource: (file: InstalledFile) => locateSource(sourceDir, file),
|
|
24
|
+
projectSubdir: 'project',
|
|
24
25
|
stamp: { domain: 'snippets', toolkitRoot: root },
|
|
25
26
|
}
|
|
26
27
|
}
|
package/src/sync/engine.ts
CHANGED
|
@@ -121,6 +121,13 @@ export interface SyncAdapter {
|
|
|
121
121
|
collectRetired?(target: string): RetiredSurface[]
|
|
122
122
|
/** Dropped from the walk, so neither matching nor orphaned. */
|
|
123
123
|
isExcluded?(file: InstalledFile): boolean
|
|
124
|
+
/**
|
|
125
|
+
* Top-level folder under `installedRoot` that is project-authored by
|
|
126
|
+
* location rather than by the name inference `locateSource` runs.
|
|
127
|
+
* Checked before `locateSource`, so a file here is orphaned even when its
|
|
128
|
+
* name also matches a toolkit source, and never enters the stamp.
|
|
129
|
+
*/
|
|
130
|
+
readonly projectSubdir?: string
|
|
124
131
|
/** Defaults to applying. */
|
|
125
132
|
readonly nonInteractive?: NonInteractivePolicy
|
|
126
133
|
/** Runs on a completed sync, including one with no changes. */
|
|
@@ -168,6 +175,11 @@ export function planSync(adapter: SyncAdapter, target: string): SyncPlan {
|
|
|
168
175
|
if (adapter.isExcluded?.(file) === true) continue
|
|
169
176
|
walked.add(toStampKey(file.rel))
|
|
170
177
|
|
|
178
|
+
if (isProjectAuthored(adapter, file)) {
|
|
179
|
+
entries.push({ state: 'orphaned', rel: file.rel })
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
|
|
171
183
|
const source = adapter.locateSource(file)
|
|
172
184
|
|
|
173
185
|
if (source === undefined || !existsSync(source)) {
|
|
@@ -439,6 +451,16 @@ function strandedByRelocation(
|
|
|
439
451
|
return entries
|
|
440
452
|
}
|
|
441
453
|
|
|
454
|
+
/**
|
|
455
|
+
* `Bun.Glob` reports `relToRoot` with `/` separators regardless of platform,
|
|
456
|
+
* so the declared subfolder is compared against the walk's first segment
|
|
457
|
+
* rather than through a path-aware join.
|
|
458
|
+
*/
|
|
459
|
+
function isProjectAuthored(adapter: SyncAdapter, file: InstalledFile): boolean {
|
|
460
|
+
if (adapter.projectSubdir === undefined) return false
|
|
461
|
+
return file.relToRoot.split('/')[0] === adapter.projectSubdir
|
|
462
|
+
}
|
|
463
|
+
|
|
442
464
|
function isInside(target: string, path: string): boolean {
|
|
443
465
|
const rel = relative(target, path)
|
|
444
466
|
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)
|
|
@@ -457,7 +479,8 @@ function hasUnattributedDrift(plan: SyncPlan): boolean {
|
|
|
457
479
|
/**
|
|
458
480
|
* Records what the toolkit placed, after the copies land, so a partial apply
|
|
459
481
|
* that throws leaves the previous stamp rather than a claim the target does not
|
|
460
|
-
* meet.
|
|
482
|
+
* meet. A file with no source, or one orphaned by location, is
|
|
483
|
+
* project-authored and stays out.
|
|
461
484
|
*
|
|
462
485
|
* Reads the installed tree rather than the caller's file list, so a partial
|
|
463
486
|
* install still stamps the domain's whole installed set.
|
|
@@ -474,6 +497,7 @@ export async function recordStamp(
|
|
|
474
497
|
|
|
475
498
|
for (const file of listInstalled(adapter.installedRoot(target), target)) {
|
|
476
499
|
if (adapter.isExcluded?.(file) === true) continue
|
|
500
|
+
if (isProjectAuthored(adapter, file)) continue
|
|
477
501
|
|
|
478
502
|
const source = adapter.locateSource(file)
|
|
479
503
|
if (source === undefined || !existsSync(source)) continue
|