@erclx/aitk 3.12.1 → 3.14.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
@@ -116,7 +116,7 @@ git init
116
116
  aitk init
117
117
  ```
118
118
 
119
- `aitk init` installs base tooling configs, Claude seeds, governance rules, and snippets in one pass, and scaffolds a `.claude/wiki/` stub for your project's own reference pages. Governance defaults to the `base` stack, so a bare init lands the coding and doc-authoring rules in `.claude/rules/`. Each rule names the standard it answers to and reads it with `aitk standards <name>`, so no corpus is copied into your project. Pass `--stack <name>` for a framework stack, or `--skip governance` to leave rules out. Run `aitk tooling list --json` to see the catalog.
119
+ `aitk init` installs base tooling configs, Claude seeds, and governance rules in one pass, and scaffolds a `.claude/wiki/` stub for your project's own reference pages. Governance defaults to the `base` stack, so a bare init lands the coding and doc-authoring rules in `.claude/rules/`. Each rule names the standard it answers to and reads it with `aitk standards <name>`, so no corpus is copied into your project. Pass `--stack <name>` for a framework stack, or `--skip governance` to leave rules out. Snippets install only when you name a category with `--snippets <category>`, such as `--snippets essentials`. Run `aitk tooling list --json` to see the catalog.
120
120
 
121
121
  For the full journey from scaffold through adding a domain later to syncing upstream drift, see [target projects](docs/target-projects.md).
122
122
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "3.12.1",
4
+ "version": "3.14.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -15,7 +15,7 @@ A body written around a named tool fails a third way. A session on a surface car
15
15
 
16
16
  The opposite failure is the one that ships silently. A session that takes a preference decision itself produces work the operator did not ask for and never learns a choice was made, because nothing in the output says a fork was passed.
17
17
 
18
- `CLAUDE.md` states both branches of the rule, sending an ordinary judgment call to a pick with the tradeoff in one sentence and a preference-deciding call to the operator. The first branch has the toolkit's `snippets/decision-help.md` behind it, which installs into a project as `.claude/snippets/decision-help.md`. The second branch now states how a question is shaped and where it is put, so this skill inherits both and adds only what a batch needs on top of one question.
18
+ `CLAUDE.md` states both branches of the rule, sending an ordinary judgment call to a pick with the tradeoff in one sentence and a preference-deciding call to the operator. The first branch has the toolkit's `snippets/decision-help.md` behind it, which installs into a project that took snippets as `.claude/snippets/decision-help.md`. The second branch now states how a question is shaped and where it is put, so this skill inherits both and adds only what a batch needs on top of one question.
19
19
 
20
20
  ## Must
21
21
 
@@ -363,9 +363,17 @@ which is what makes it scriptable.
363
363
 
364
364
  `--stack` defaults to `base`, and the default
365
365
  does not read as a passed flag, so a bare `aitk init` installs governance and
366
- still prompts. `--skip` takes `wiki` and `governance`, and warns without aborting
367
- on any other value. There is no `--standards`, since no run writes a standard
368
- into the target.
366
+ still prompts. `--skip` takes `wiki`, `governance`, and `snippets`, and warns
367
+ without aborting on any other value. There is no `--standards`, since no run
368
+ writes a standard into the target.
369
+
370
+ `--snippets` carries no default. A bare `aitk init` installs no snippets and
371
+ reports the domain as skipped, the same way an explicit `--skip snippets`
372
+ does, both naming `aitk snippets install essentials <target>` as the recovery
373
+ command. `--snippets none` is a distinct, deliberate way to reach the same
374
+ zero-file result while still running the step, which is what lets a scripted
375
+ caller assert "install nothing" without depending on an empty string, which
376
+ `aitk snippets install` refuses.
369
377
 
370
378
  ## Unguarded tooling primitives
371
379
 
@@ -70,7 +70,7 @@ Each diagram entry records the commit and date it was last verified against, and
70
70
 
71
71
  ### Stack decision
72
72
 
73
- The default path is `base`. `aitk init` on `base` installs base tooling configs, Claude seeds, governance core rules, and snippets, and scaffolds an empty `.claude/wiki/`. Most projects need nothing more.
73
+ The default path is `base`. `aitk init` on `base` installs base tooling configs, Claude seeds, and governance core rules, and scaffolds an empty `.claude/wiki/`. Most projects need nothing more.
74
74
 
75
75
  Escalate only for real web apps. The `setup-init` skill reads `package.json` and root configs, then picks the matching tooling stack (`vite-react` today) and the matching governance stack (`react`, `astro`, `node`).
76
76
 
@@ -84,13 +84,16 @@ Run `aitk tooling list --json` and `aitk gov list --json` to see the current cat
84
84
 
85
85
  ### Core domains and skips
86
86
 
87
- `aitk init` installs base tooling, Claude workflow, governance, and snippets, and scaffolds `.claude/wiki/`. Governance defaults to the `base` stack, so a bare init carries the rules that route a project. Pass `--stack <name>` to install a framework stack instead.
87
+ `aitk init` installs base tooling, Claude workflow, and governance, and scaffolds `.claude/wiki/`. Governance defaults to the `base` stack, so a bare init carries the rules that route a project. Pass `--stack <name>` to install a framework stack instead.
88
88
 
89
89
  No standard is written into the project. Each governance rule's authority line names `aitk standards <name>`, which answers from the corpus inside the CLI's own package, and every toolkit skill names the copy in its own plugin root. `aitk markdown audit` needs no standard at all, its ban sets and checkpoints shipping with the package as data.
90
90
 
91
- `governance` and `wiki` are skippable:
91
+ Snippets does not install by default. Pass `--snippets <category>`, such as `--snippets essentials`, to take it. Its own rule describing the `@` reference convention installs alongside it, so a project that declines snippets never receives a rule for a behavior it holds no snippet to exercise.
92
+
93
+ `governance`, `snippets`, and `wiki` are skippable:
92
94
 
93
95
  - `--skip governance`: leave `.claude/rules/` empty, so no coding standard loads on a file match. The preview names any `--add` rules the skip drops, and the run prints the `aitk gov install <stack> <path>` command to add rules afterward, carrying those extras so one paste restores what the skip declined.
96
+ - `--skip snippets`: the explicit spelling for what a bare init already does, since `--snippets` carries no default. Both print the `aitk snippets install essentials <path>` command afterward.
94
97
  - `--skip wiki`: skip the `.claude/wiki/` scaffold. A target that already carries a root `wiki/` keeps it, since the verb reports that folder rather than migrating it.
95
98
 
96
99
  The plugin corpus carries runtime behavior rather than reference prose alone, because the pre-publish scan and the branch-slug transform each have a standard of their own, `publish.md` and `slug.md`, cited by the skills that run them.
@@ -0,0 +1,18 @@
1
+ ---
2
+ description: Route bash script authoring to the interactive or non-interactive skill, and name the lint gate
3
+ paths:
4
+ - '**/*.sh'
5
+ ---
6
+
7
+ # Bash standards
8
+
9
+ ## Skill routing
10
+
11
+ - Use `bash-script` for an interactive or human-facing script: prompts, a visual timeline UI, framed terminal output.
12
+ - Use `cli-script` for a non-interactive script: automation, CI, cron, a pipeline helper, or anything run by an agent rather than watched by a person.
13
+ - Load the matched skill's own reference templates rather than hand-rolling interactivity or logging patterns outside them.
14
+
15
+ ## Lint gate
16
+
17
+ - Format with `shfmt --write --indent 2` and lint with `shellcheck --severity=warning` before committing a script.
18
+ - Fix a shellcheck finding at the source. Suppress one with a directive comment only for a genuine false positive, and state why beside the suppression.
@@ -0,0 +1,34 @@
1
+ ---
2
+ description: Enforce settled waits and falsifiable guards in end-to-end tests
3
+ paths:
4
+ - 'e2e/*.ts'
5
+ - 'e2e/**/*.ts'
6
+ ---
7
+
8
+ # End-to-end reliability standards
9
+
10
+ ## Waiting
11
+
12
+ - Settle on the condition a step waits for. Use `expect.poll` or a web-first assertion ahead of any read.
13
+ - Reserve a fixed duration for an assertion that nothing happened across a window.
14
+ - Bound every settle with an explicit timeout.
15
+ - Do not raise a timeout to clear a failure that reproduces under load. Replace the wait with a settle.
16
+ - Do not read a value once after a pause. Poll it.
17
+
18
+ ## Falsifiable guards
19
+
20
+ - Assert the set under test is non-empty before asserting a property over its members.
21
+ - Raise from an instrument that was refused rather than returning a value a passing assertion accepts.
22
+ - Run a new guard against the defect it was written for, and see it fail, before trusting it.
23
+ - Do not weaken an assertion to clear a failure. Narrow the wait instead.
24
+
25
+ ## Reproducing a failure that only appears in CI
26
+
27
+ - Reproduce under `Emulation.setCPUThrottlingRate` rather than by rerunning the gate.
28
+ - Read the state the assertion does not: which markers were set, which listeners fired, how far a transition ran.
29
+ - Vary the condition under suspicion deliberately. Do not compare counts across runs that differed in something uncontrolled.
30
+ - Read the check conclusion as its own act. A green diff review reports nothing about the gate.
31
+
32
+ ## Authority
33
+
34
+ - Follow `.claude/rules/lib/300-testing-ts.md` for framework choice, file placement, and test naming.
@@ -0,0 +1,25 @@
1
+ ---
2
+ description: Enforce which specs and which engines an end-to-end run covers at each point in the loop
3
+ paths:
4
+ - 'e2e/*.ts'
5
+ - 'e2e/**/*.ts'
6
+ ---
7
+
8
+ # Test scope standards
9
+
10
+ ## Selecting a run
11
+
12
+ - Narrow an end-to-end run by spec path or by test name. Never narrow it by engine.
13
+ - Run one named test while iterating on a behavior: `bun run test:e2e -- -g '<name>'`.
14
+ - Run one surface while iterating on that surface: `bun run test:e2e -- e2e/<area>.spec.ts`.
15
+ - Run `bun run test:e2e:changed` to select specs from the import graph.
16
+ - Run the whole suite before pushing.
17
+ - Pass `--project` in a local run only to reproduce a failure that engine has already reported. The CI matrix passes it on every job, one engine per leg, which is the gate rather than a narrowed run.
18
+ - Do not add a script that pins a default run to one engine.
19
+
20
+ ## Instruments
21
+
22
+ - Answer a question about the running page with a script against the dev server rather than with the suite.
23
+ - Do not enable `fullyParallel` in `playwright.config.ts`.
24
+ - Follow `.claude/rules/ui/440-surface-capture.md` for capture scope.
25
+ - Follow `.claude/rules/lib/305-e2e-reliability.md` for waits and guards.
@@ -4,6 +4,7 @@ paths:
4
4
  - '**/*.tsx'
5
5
  - '**/*.jsx'
6
6
  - '**/*.html'
7
+ - '**/*.astro'
7
8
  ---
8
9
 
9
10
  # Web security standards
@@ -0,0 +1,9 @@
1
+ ---
2
+ description: Execute an @-referenced snippet's instructions immediately using session context
3
+ ---
4
+
5
+ # Snippet reference standards
6
+
7
+ ## Execution
8
+
9
+ - When a snippet is referenced with `@`, execute its instructions immediately using available session context
@@ -0,0 +1,19 @@
1
+ ---
2
+ description: Enforce the same-tab default for an outbound link and for a mail link
3
+ paths:
4
+ - '**/*.tsx'
5
+ - '**/*.jsx'
6
+ - '**/*.astro'
7
+ - '**/*.html'
8
+ ---
9
+
10
+ # Link behavior standards
11
+
12
+ ## Default target
13
+
14
+ - Open a link in the same tab. Reserve `target="_blank"` for a destination that would discard in-progress work if it replaced the current page, such as a document a user is midway through elsewhere.
15
+ - Do not add `target="_blank"` as a default for every external link.
16
+
17
+ ## mailto: links
18
+
19
+ - Open a `mailto:` link in the same tab. It hands off to the mail client rather than replacing page content, so a new tab leaves an empty tab behind.
@@ -1,2 +1,2 @@
1
1
  extends = "node"
2
- rules = ["210-astro", "350-security-web", "400-ui", "410-a11y", "430-ux-completeness", "440-surface-capture"]
2
+ rules = ["210-astro", "350-security-web", "400-ui", "410-a11y", "430-ux-completeness", "440-surface-capture", "450-link-behavior"]
@@ -1,4 +1,4 @@
1
1
  extends = ""
2
2
  # Both folders whole. Every rule authored under them ships to a base consumer,
3
3
  # so adding one is a single edit rather than a file and a name here.
4
- rules = ["core", "claude"]
4
+ rules = ["core", "claude", "120-bash"]
@@ -1,2 +1,2 @@
1
1
  extends = "node"
2
- rules = ["200-react", "230-nextjs", "250-tailwind", "300-testing-ts", "310-zod", "350-security-web", "400-ui", "410-a11y", "420-forms", "430-ux-completeness", "440-surface-capture"]
2
+ rules = ["200-react", "230-nextjs", "250-tailwind", "300-testing-ts", "305-e2e-reliability", "306-test-scope", "310-zod", "350-security-web", "400-ui", "410-a11y", "420-forms", "430-ux-completeness", "440-surface-capture", "450-link-behavior"]
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "3.12.1",
4
+ "version": "3.14.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -67,7 +67,7 @@ mkdir -p "$TARGET_DIR"
67
67
  log_info "Initialized git in $TARGET_DIR"
68
68
 
69
69
  log_step "Run aitk init"
70
- (cd "$TARGET_DIR" && AITK_NON_INTERACTIVE=1 bun run "$CLONE_DIR/src/cli.ts" init --stack base 2>&1 | pipe_output) || log_error "aitk init failed"
70
+ (cd "$TARGET_DIR" && AITK_NON_INTERACTIVE=1 bun run "$CLONE_DIR/src/cli.ts" init --stack base --snippets essentials 2>&1 | pipe_output) || log_error "aitk init failed"
71
71
  log_info "aitk init completed"
72
72
 
73
73
  log_step "Assert scaffold"
@@ -80,10 +80,12 @@ log_step "Assert scaffold"
80
80
  # reads a standard through `aitk standards <name>`, which resolves against the
81
81
  # copy inside the package.
82
82
  #
83
- # The snippets path has to name a slug the default preset still carries, since
84
- # init resolves snippets through `essentials`. Editing that preset without
85
- # editing this line fails the gate on a correct install.
86
- for path in "CLAUDE.md" ".claude/snippets/decision-help.md" ".claude/wiki/index.md" ".claude" ".claude/context/index.md" ".claude/wireframes/index.md" ".claude/diagrams/index.md" \
83
+ # Snippets carries no default, so the run above passes `--snippets essentials`
84
+ # explicitly. The snippets path has to name a slug that preset still carries;
85
+ # editing the preset without editing this line fails the gate on a correct
86
+ # install. The rule path confirms the domain's own convention rule installs
87
+ # alongside it, since a target that declined snippets never receives it.
88
+ for path in "CLAUDE.md" ".claude/snippets/decision-help.md" ".claude/rules/snippets/505-at-references.md" ".claude/wiki/index.md" ".claude" ".claude/context/index.md" ".claude/wireframes/index.md" ".claude/diagrams/index.md" \
87
89
  ".prettierrc" ".editorconfig" ".lintstagedrc" ".husky/pre-commit" ".github/workflows/verify.yml" "scripts/verify.sh" \
88
90
  ".claude/rules/core/000-constitution.md"; do
89
91
  if [ ! -e "$TARGET_DIR/$path" ]; then
@@ -20,10 +20,14 @@ CHANGED_FILES=""
20
20
  SANDBOX_UNDECLARED_CEILING=47
21
21
 
22
22
  # Rules no stack reaches, space separated and sorted the way `aitk gov list`
23
- # emits them. Both are opt-in libraries a project may not want, so they are
24
- # recorded here rather than in a config file: the list is what a reader compares
25
- # a new arrival against, and a config file would absorb the arrival silently.
26
- GOV_EXPECTED_UNREFERENCED="260-shadcn 320-tanstack-query"
23
+ # emits them. `260-shadcn` and `320-tanstack-query` are opt-in libraries a
24
+ # project may not want; `505-at-references` ships with no stack on purpose,
25
+ # since a rule under `claude/` would reach every base consumer through the
26
+ # folder-whole entry, and only `aitk snippets install` writes it, alongside
27
+ # the snippets a caller took. All three are recorded here rather than in a
28
+ # config file: the list is what a reader compares a new arrival against, and a
29
+ # config file would absorb the arrival silently.
30
+ GOV_EXPECTED_UNREFERENCED="260-shadcn 320-tanstack-query 505-at-references"
27
31
 
28
32
  # The retained counts the audit stage compares each run against. Spelled here
29
33
  # rather than derived, because this script only ever names the file in a remedy
@@ -292,12 +296,13 @@ main() {
292
296
  # A stack entry naming a rule folder takes every rule in it, which is what
293
297
  # stops a new rule from needing a second edit to reach a target. The failure
294
298
  # it leaves open is a rule authored into a folder no stack names, which
295
- # installs for nobody and reports nothing on its own.
299
+ # `aitk gov install` never reaches on its own, whether or not another domain
300
+ # installs the file by a different route.
296
301
  #
297
- # This reports and never fails. Both standing findings are opt-in libraries
298
- # this repository ships on purpose, so gating would fail every push over the
299
- # deliberate case and teach a reader to route around the stage. Revisit when a
300
- # third appears and the pattern is either a library set or an accident.
302
+ # This reports and never fails. All three standing findings ship this way on
303
+ # purpose, so gating would fail every push over the deliberate case and teach
304
+ # a reader to route around the stage. Revisit if the set keeps growing and
305
+ # the pattern turns out to be an accident rather than a design.
301
306
  log_step "Unreferenced rules"
302
307
  local gov_json gov_status=0 unreferenced
303
308
  gov_json=$(cd "$PROJECT_ROOT" && bun src/cli.ts gov list --json 2>/dev/null) || gov_status=$?
@@ -331,10 +336,10 @@ main() {
331
336
  elif [ -z "$unreferenced" ]; then
332
337
  log_info "Every rule is reached by a stack"
333
338
  elif [ "$unreferenced" = "$GOV_EXPECTED_UNREFERENCED" ]; then
334
- log_info "Reached by no stack: $unreferenced (opt-in, install with --add)"
339
+ log_info "Reached by no stack: $unreferenced (each recorded above with why)"
335
340
  else
336
341
  log_warn "Reached by no stack: $unreferenced"
337
- log_warn "Expected: $GOV_EXPECTED_UNREFERENCED. Name the new rule in a stack, or update GOV_EXPECTED_UNREFERENCED in this script and say why it ships opt-in."
342
+ log_warn "Expected: $GOV_EXPECTED_UNREFERENCED. Name the new rule in a stack, or update GOV_EXPECTED_UNREFERENCED in this script and say why it reaches no stack."
338
343
  fi
339
344
  fi
340
345
 
@@ -12,7 +12,7 @@ interface InitOptions {
12
12
  /** Always present: the option falls back to `DEFAULT_STACK`. */
13
13
  readonly stack: string
14
14
  readonly add?: string
15
- readonly snippets: string
15
+ readonly snippets?: string
16
16
  readonly skip?: string
17
17
  }
18
18
 
@@ -6,6 +6,7 @@ import { BASE_CATEGORY } from '@/snippets/categories'
6
6
  import {
7
7
  ALL_CATEGORY,
8
8
  installSnippets,
9
+ installSnippetsRule,
9
10
  installableCategories,
10
11
  resolveSnippets,
11
12
  } from '@/snippets/install'
@@ -176,6 +177,9 @@ async function runInstall(
176
177
  for (const rel of await installSnippets(resolution.files, resolved)) {
177
178
  logAdd(rel)
178
179
  }
180
+ for (const rel of await installSnippetsRule(PROJECT_ROOT, resolved)) {
181
+ logAdd(rel)
182
+ }
179
183
  await recordStamp(createSnippetsAdapter(PROJECT_ROOT), resolved, new Date())
180
184
 
181
185
  const { GREEN, NC } = palette(process.stderr)
package/src/init/flags.ts CHANGED
@@ -29,8 +29,7 @@ export const INIT_OPTIONS: readonly InitOptionSpec[] = [
29
29
  {
30
30
  key: 'snippets',
31
31
  flags: '--snippets <category>',
32
- description: "Snippets preset, category, or 'all'",
33
- defaultValue: 'essentials',
32
+ description: "Snippets preset, category, 'none', or 'all'. Omit to skip",
34
33
  },
35
34
  {
36
35
  key: 'skip',
package/src/init/plan.ts CHANGED
@@ -1,4 +1,4 @@
1
- export const SKIPPABLE_DOMAINS = ['wiki', 'governance'] as const
1
+ export const SKIPPABLE_DOMAINS = ['wiki', 'governance', 'snippets'] as const
2
2
 
3
3
  export type SkippableDomain = (typeof SKIPPABLE_DOMAINS)[number]
4
4
 
@@ -30,10 +30,23 @@ export interface InitPlan {
30
30
  export interface InitFlags {
31
31
  readonly stack?: string
32
32
  readonly add?: string
33
- readonly snippets: string
33
+ /** Absent when `--snippets` was never passed, which installs none. */
34
+ readonly snippets?: string
34
35
  readonly skip: SkipPlan
35
36
  }
36
37
 
38
+ /**
39
+ * Reads why the snippets step will not run, or `undefined` when it will.
40
+ * Omitting `--snippets` reaches the same skip as `--skip snippets` explicitly,
41
+ * so a plain `aitk init` installs none and both routes share one recovery
42
+ * line rather than two behaviors with one spelling each.
43
+ */
44
+ export function snippetsSkipReason(flags: InitFlags): string | undefined {
45
+ if (flags.skip.skipped.has('snippets')) return '--skip snippets'
46
+ if (flags.snippets === undefined) return 'no --snippets given'
47
+ return undefined
48
+ }
49
+
37
50
  /**
38
51
  * Resolves the stack the run will install. An empty `--stack` reads as absent
39
52
  * rather than as a way to decline, so the flag carries a real name or nothing
@@ -98,7 +111,11 @@ export function planInit(flags: InitFlags): InitPlan {
98
111
  })
99
112
  }
100
113
 
101
- preview.push({ level: 'info', text: `snippets (${flags.snippets})` })
114
+ if (snippetsSkipReason(flags) !== undefined) {
115
+ preview.push({ level: 'warn', text: 'snippets (skipped)' })
116
+ } else {
117
+ preview.push({ level: 'info', text: `snippets (${flags.snippets})` })
118
+ }
102
119
 
103
120
  if (!flags.skip.skipped.has('wiki')) {
104
121
  preview.push({
package/src/init/steps.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type InitFlags, resolveStack } from '@/init/plan'
1
+ import { type InitFlags, resolveStack, snippetsSkipReason } from '@/init/plan'
2
2
  import type { DomainStep } from '@/init/run'
3
3
 
4
4
  /** Builds the child-process invocation for one domain. */
@@ -48,11 +48,25 @@ export function buildSteps(
48
48
  })
49
49
  }
50
50
 
51
- steps.push({
52
- kind: 'run',
53
- label: 'Snippets',
54
- run: child(['snippets', 'install', flags.snippets, resolved]),
55
- })
51
+ // `snippets === undefined` here can never fire on its own: `snippetsSkipReason`
52
+ // already returns a reason whenever `flags.snippets` is undefined. It is what
53
+ // narrows `snippets` to `string` for the child() call below, since TypeScript
54
+ // cannot narrow a property through a function call.
55
+ const snippets = flags.snippets
56
+ const skipReason = snippetsSkipReason(flags)
57
+ if (skipReason !== undefined || snippets === undefined) {
58
+ steps.push({
59
+ kind: 'skip',
60
+ label: 'Snippets',
61
+ notice: `Skipped: ${skipReason ?? 'no --snippets given'}. Run 'aitk snippets install essentials ${target}' to install snippets.`,
62
+ })
63
+ } else {
64
+ steps.push({
65
+ kind: 'run',
66
+ label: 'Snippets',
67
+ run: child(['snippets', 'install', snippets, resolved]),
68
+ })
69
+ }
56
70
 
57
71
  if (!flags.skip.skipped.has('wiki')) {
58
72
  steps.push({
@@ -3,6 +3,8 @@ import { join } from 'node:path'
3
3
  import { isDirectory } from '@/target'
4
4
 
5
5
  export const BASE_CATEGORY = 'base'
6
+ /** A real category resolving to zero entries, for a caller declining on purpose. */
7
+ export const NONE_CATEGORY = 'none'
6
8
 
7
9
  export function snippetsSourceDir(root: string): string {
8
10
  return join(root, 'snippets')
@@ -39,8 +41,13 @@ export function categoryDir(root: string, category: string): string {
39
41
  * Tests for a directory rather than mere existence. `snippets/` holds files
40
42
  * alongside its category folders, so an argument naming one of them resolves
41
43
  * to a real path that cannot be scanned as a category.
44
+ *
45
+ * An empty category refuses rather than resolving, since `join(source, '')`
46
+ * is `source` and would otherwise report the base directory as the category,
47
+ * widening an unset value to every base entry instead of naming none.
42
48
  */
43
49
  export function categoryExists(root: string, category: string): boolean {
50
+ if (category === '') return false
44
51
  return isDirectory(categoryDir(root, category))
45
52
  }
46
53
 
@@ -7,9 +7,11 @@ import {
7
7
  categoryExists,
8
8
  listCategories,
9
9
  listEntries,
10
+ NONE_CATEGORY,
10
11
  snippetsSourceDir,
11
12
  } from '@/snippets/categories'
12
13
  import { findPreset, loadPresets } from '@/snippets/presets'
14
+ import { isDirectory } from '@/target'
13
15
 
14
16
  export const ALL_CATEGORY = 'all'
15
17
 
@@ -72,6 +74,15 @@ export function resolveSnippets(
72
74
  return { ok: true, step: 'Resolving all categories', files, missing: [] }
73
75
  }
74
76
 
77
+ if (category === NONE_CATEGORY) {
78
+ return {
79
+ ok: true,
80
+ step: 'Resolving category: none',
81
+ files: [],
82
+ missing: [],
83
+ }
84
+ }
85
+
75
86
  const preset = findPreset(root, category)
76
87
  if (preset) {
77
88
  const files: SnippetFile[] = []
@@ -123,3 +134,39 @@ export async function installSnippets(
123
134
 
124
135
  return installed
125
136
  }
137
+
138
+ function snippetsRuleSourceDir(root: string): string {
139
+ return join(root, 'governance', 'rules', 'snippets')
140
+ }
141
+
142
+ function installedSnippetsRuleDir(target: string): string {
143
+ return join(target, '.claude', 'rules', 'snippets')
144
+ }
145
+
146
+ /**
147
+ * Installs the `@`-reference convention rule alongside the snippets a caller
148
+ * took. No stack names this folder, so a project that declined snippets never
149
+ * receives a rule describing a behavior it holds no snippet to exercise.
150
+ */
151
+ export async function installSnippetsRule(
152
+ root: string,
153
+ target: string,
154
+ ): Promise<string[]> {
155
+ const dir = snippetsRuleSourceDir(root)
156
+ if (!isDirectory(dir)) return []
157
+
158
+ const installed: string[] = []
159
+
160
+ for (const rel of new Bun.Glob('*.md').scanSync({
161
+ cwd: dir,
162
+ onlyFiles: true,
163
+ })) {
164
+ await copyPreservingMode(
165
+ join(dir, rel),
166
+ join(installedSnippetsRuleDir(target), rel),
167
+ )
168
+ installed.push(join('.claude', 'rules', 'snippets', rel))
169
+ }
170
+
171
+ return installed
172
+ }
@@ -65,10 +65,6 @@
65
65
  - When cspell flags a word, rewrite typos. Add real terms to the appropriate dictionary in `cspell.json`.
66
66
  - Keep dictionary files sorted alphabetically
67
67
 
68
- ## Snippets
69
-
70
- - When a snippet is referenced with `@`, execute its instructions immediately using available session context
71
-
72
68
  ## Tasks
73
69
 
74
70
  - `.claude/tasks/` is gitignored local session scratch, one file per task. Edit freely. No staging or revert before commits.
@@ -43,6 +43,7 @@ packages = [
43
43
  "test:ui" = "vitest --ui"
44
44
  "test:coverage" = "vitest run --coverage"
45
45
  "test:e2e" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && playwright test"
46
+ "test:e2e:changed" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && playwright test --only-changed"
46
47
  "test:e2e:ui" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && playwright test --ui"
47
48
  "test:e2e:report" = "playwright show-report"
48
49
  "check:full" = "./scripts/verify.sh && bun run test:e2e"
@@ -103,6 +103,7 @@ Append rows:
103
103
  | `bun run test:run` | Run Vitest once with verbose reporter. |
104
104
  | `bun run test:coverage` | Run Vitest with coverage. |
105
105
  | `bun run test:e2e` | Run Playwright E2E tests. |
106
+ | `bun run test:e2e:changed` | Run Playwright E2E tests for specs the import graph reaches from the current diff. |
106
107
  | `bun run screenshot` | Build, preview, then capture screenshots. |
107
108
 
108
109
  `aitk tooling verify <stack>` is the only automated caller of `bun run screenshot`, running it for any stack whose `package.json` declares the script and asserting that PNG files land under `screenshots/`. It counts them with a recursive find carrying no depth limit, so the section folders the seed writes satisfy the assertion without a change to it. Do not flatten the layout to protect that check. No ship chain captures a screenshot, so the output path the seed writes is a contract that one verifier reads rather than a default a ship step depends on.