@erclx/aitk 0.41.0 → 0.41.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "0.41.0",
4
+ "version": "0.41.1",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -7,13 +7,15 @@ description: What the post-plan pipeline is for, the gaps it closes, and why eve
7
7
 
8
8
  ## Gap
9
9
 
10
- Without this skill, the run from an approved plan to an open pull request is a conversation. A session implements, then asks what comes next, and the answer varies by session. Review gets skipped on a diff that needed one, or spent on prose already gated by a hook. A run that halts leaves no stated resume point, so the user reconstructs how far it got from the working tree.
10
+ Without this skill, the run from an approved plan to an open pull request is a conversation. A session implements, then asks what comes next, and the answer varies by session. A run that halts leaves no stated resume point, so the user reconstructs how far it got from the working tree.
11
+
12
+ Review is the step that varies most. It gets skipped on a diff that needed one, or spent on prose already gated by a hook. A file-extension test cannot separate the two, since a skill body and a documentation page are both markdown, so a branch changing what an agent does takes the skip a documentation branch earned.
11
13
 
12
14
  ## Must
13
15
 
14
16
  - Take the approved plan for the branch as the scope, and implement only what it describes
15
17
  - Give every step a stop condition, and leave the code on the branch and the receipts on disk at each one
16
- - Classify the changed-file list before review, so a prose-only diff skips a code review with no signal on it
18
+ - Classify the changed-file list by path as well as by extension, so informational prose skips a code review with no signal on it and executable prose still reaches one
17
19
  - Stop on any critical or should-fix finding rather than acting on it
18
20
  - Open the pull request as a draft, then watch continuous integration to a terminal state
19
21
  - Name the recovery for the stop it took, since the value of stopping is that the user knows where to resume
@@ -25,6 +27,7 @@ Without this skill, the run from an approved plan to an open pull request is a c
25
27
  - Fix a review finding or a failing check. Both stops are deliberate, since a green pull request reached by auto-fix hides what broke.
26
28
  - Run the memory Apply phase. Promoting an entry changes how the agent operates and ships as its own change.
27
29
  - Read an empty changed-file list as prose-only. It satisfies that test vacuously and would route the branch past review instead of through it.
30
+ - Read a markdown extension as evidence the change only informs. A skill body, a governance rule, and a standard are behavior written in prose.
28
31
 
29
32
  ## Guards
30
33
 
@@ -71,13 +71,26 @@ An empty list stops the chain: `❌ No changed files to classify. Re-run when th
71
71
 
72
72
  The two causes want different responses. A plan that has yet to produce its output is a re-run once it has. A plan whose output is gitignored by design, such as a read pass writing to `.claude/.tmp/`, is work the chain cannot carry at all, since `git-stage` finds nothing to commit six steps later. Never advise removing the output from `.gitignore`, which trades a stopped run for scratch committed into the repository.
73
73
 
74
- If every changed file matches `*.md` or `*.txt`, skip review entirely and continue to Step 7. Prose-only changes are already gated by `docs-sync`, `claude-standards-audit`, and pre-push hooks. Running a code-style review on them burns tokens with no signal.
74
+ The skip needs both tests to pass: every changed file matches `*.md` or `*.txt`, and no changed file sits under a behavior path. On a pass, skip review entirely and continue to Step 7. Otherwise invoke `aitk:claude-review`.
75
75
 
76
- Otherwise invoke `aitk:claude-review`.
76
+ Behavior paths carry two spellings, the one a surface authors at and the one it installs to, so the rule reads the same in a toolkit and in a project that consumed one:
77
+
78
+ - `claude/skills/` and `.claude/skills/`
79
+ - `governance/rules/` and `.claude/rules/`
80
+ - `standards/` and `.claude/standards/`
81
+ - `snippets/` and `.claude/snippets/`
82
+ - `internal/` and `tooling/`, which hold the stack references and the seed documents a target is handed
83
+ - `CLAUDE.md` at the repository root, named as a file because a path prefix reaches nothing that sits in no folder
84
+
85
+ Markdown under one of them states what an agent does, so a change there is a behavior change wearing a prose extension. Everything outside them is informational, which keeps `docs/`, `README.md`, and `CHANGELOG.md` skipping without naming them. One behavior file sends the whole branch to review, since documentation shipped beside a behavior change does not cancel it.
86
+
87
+ Informational prose is already gated by `docs-sync`, `claude-standards-audit`, and pre-push hooks. Running a code-style review on it burns tokens with no signal.
88
+
89
+ The list covers this toolkit's authoring layout and the layout it installs, which is not every layout. A project keeping executable prose where neither spelling reaches adds the path, and until it does every branch touching it skips review silently.
77
90
 
78
91
  ## Step 6: evaluate findings
79
92
 
80
- Skip this step if Step 5 was skipped (prose-only diff). Otherwise read `.claude/review/review-<slug>.md` at the main worktree root. Parse the summary line (`X critical, Y should-fix, Z minor`):
93
+ Skip this step when Step 5 skipped review. Otherwise read `.claude/review/review-<slug>.md` at the main worktree root. Parse the summary line (`X critical, Y should-fix, Z minor`):
81
94
 
82
95
  - Any critical or should-fix count greater than zero, stop: `❌ Review found non-minor issues. See .claude/review/review-<slug>.md. Fix and run /git-ship.`
83
96
  - Zero critical and zero should-fix, continue. The minor findings stay in the on-disk review receipt. Fold any a reviewer needs into the PR's `## Technical Context`. Do not add a separate review-notes section to the PR body.
@@ -42,6 +42,34 @@ headlessly, so a call that names its stack or category is unchanged.
42
42
  `aitk snippets install`. Both resolve the target before anything else, so a path
43
43
  that does not exist fails rather than being scaffolded.
44
44
 
45
+ ## Standards selection
46
+
47
+ `aitk standards install --only <names>` takes a comma-separated list and
48
+ defaults to `all`, so a call that omits it installs the whole corpus as before.
49
+ A name resolves with or without its `.md` extension, and one that matches no
50
+ standard fails the run with exit 1 rather than being dropped, because a typo
51
+ would otherwise omit a standard silently and compute the closure over the wrong
52
+ set. `aitk init --standards <selection>` passes the same value through.
53
+
54
+ The selection expands to the standards it depends on, so nothing lands with a
55
+ dangling reference. A citation is a backticked filename in a standard's body,
56
+ resolved case-exactly against the flat `standards/` root, which is what drops a
57
+ citation naming a target's own `.claude/ARCHITECTURE.md` or a bundled standard
58
+ install never copies. Whatever the expansion adds is listed under its own step
59
+ in the output.
60
+
61
+ A citation inside a standard's `Does not govern:` list is a handoff rather than
62
+ a dependency, and the closure stops at it. That entry names a concern a sibling
63
+ owns and this standard does not, so a caller who did not ask for that concern
64
+ does not need the file. Each one is reported under a `Scope handoffs not
65
+ installed` step, naming what to add to `--only` if the project wants it after
66
+ all.
67
+
68
+ That split is what keeps a selection to a slice. Nearly all the citation density
69
+ in the corpus sits inside those scope lists, so following them pulls the whole
70
+ corpus in behind any single name. Following dependencies alone, a single name
71
+ lands between one and three of the fifteen.
72
+
45
73
  ## Governance regen
46
74
 
47
75
  `aitk gov regen` is the one governance verb that runs against the toolkit root,
@@ -97,7 +125,9 @@ exits 1 naming the failures. Passing any flag skips the confirmation prompt,
97
125
  which is what makes it scriptable. `--stack` defaults to `base`, and the default
98
126
  does not read as a passed flag, so a bare `aitk init` installs governance and
99
127
  still prompts. `--skip` takes `wiki`, `standards`, and `governance`, and warns
100
- without aborting on any other value.
128
+ without aborting on any other value. `--standards` defaults to `all` and reaches
129
+ `aitk standards install` only when it names something narrower, so the default
130
+ run spawns the command it always did.
101
131
 
102
132
  ## Unguarded tooling primitives
103
133
 
@@ -101,8 +101,12 @@ AITK_NON_INTERACTIVE=1 aitk standards sync /path/to/project
101
101
  # Copy every standard into a target, overwriting what is there
102
102
  AITK_NON_INTERACTIVE=1 aitk standards install /path/to/project
103
103
 
104
+ # Install a named subset, expanded to the standards it cites
105
+ AITK_NON_INTERACTIVE=1 aitk standards install --only slug /path/to/project
106
+
104
107
  # Bootstrap a project. Any flag suppresses the confirmation prompt
105
108
  AITK_NON_INTERACTIVE=1 aitk init --stack astro --skip wiki /path/to/project
109
+ AITK_NON_INTERACTIVE=1 aitk init --standards design,wireframes /path/to/project
106
110
 
107
111
  # Run every domain sync. The git workflow is refused headlessly, so nothing is pushed
108
112
  AITK_NON_INTERACTIVE=1 aitk sync /path/to/project
@@ -100,7 +100,7 @@ For features on a mature stack, chain the post-plan pipeline in one session. App
100
100
 
101
101
  - Use when the plan is tight and the stack has real verify commands and test coverage
102
102
  - Autoship stops on: verify failure after one fix attempt, UI manual checklist non-empty, any review finding above minor, no diff baseline resolving against `main`, an empty changed-file list, or hook failure
103
- - Review is skipped entirely when the diff is prose-only (every changed file matches `*.md` or `*.txt`). Prose changes are gated by `docs-sync`, `claude-standards-audit`, and pre-push hooks.
103
+ - Review is skipped when the diff is prose that only informs: every changed file matches `*.md` or `*.txt`, and none sits under a behavior path. Behavior paths cover skills, rules, standards, snippets, and `tooling/` in both the authoring and the installed spelling, plus root `CLAUDE.md`, so the list matches whether a repository authors those surfaces or consumed them from the toolkit. Markdown under one states what an agent does, so a branch touching it reaches review while `docs/` and `wiki/` still skip and stay gated by `docs-sync`, `claude-standards-audit`, and pre-push hooks.
104
104
  - An empty changed-file list stops the chain rather than counting as prose-only. The filename test passes vacuously on an empty set, which routed a branch past review instead of through it.
105
105
  - Every stop leaves recoverable state. Fix and resume with `/git-ship`
106
106
  - Skip autoship for auth, migrations, security-sensitive changes, or work where the plan itself is uncertain
@@ -88,7 +88,7 @@ When a new need appears after scaffold, install the one domain without re-runnin
88
88
  - Project-specific rule the toolkit does not ship: invoke `aitk:create-rule`. It scaffolds a rule into `.claude/rules/` with a non-colliding number, and `aitk gov sync` leaves it untouched.
89
89
  - Index.md system for a markdown-heavy folder that emerged: invoke `aitk:setup-indexes`
90
90
  - A snippet preset or category: `aitk snippets install <preset|category|all> <path>`. The argument is required, since the picker refuses headlessly rather than choosing for the caller
91
- - A single standard: `aitk standards install <name> <path>`
91
+ - A single standard: `aitk standards install --only <names> <path>`. The selection expands to the standards it depends on, and stops at a `Does not govern:` handoff, which it reports instead. Omitting the flag installs all of them
92
92
 
93
93
  Per-domain mechanics live in the corresponding `docs/<domain>.md`. The skill body in `claude/skills/<skill>/SKILL.md` covers detection and preview.
94
94
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "0.41.0",
4
+ "version": "0.41.1",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -17,6 +17,8 @@ interface InitOptions {
17
17
  readonly stack: string
18
18
  readonly add?: string
19
19
  readonly snippets: string
20
+ /** Always present: the option falls back to `all`. */
21
+ readonly standards: string
20
22
  readonly skip?: string
21
23
  }
22
24
 
@@ -39,6 +41,7 @@ export function register(program: Command): void {
39
41
  ' aitk init ../my-app',
40
42
  ' aitk init --stack astro --add 260-shadcn ../my-app',
41
43
  ' aitk init --skip governance ../my-app',
44
+ ' aitk init --standards design,wireframes ../my-app',
42
45
  '',
43
46
  ].join('\n'),
44
47
  )
@@ -66,6 +69,7 @@ async function runInit(
66
69
  stack: options.stack,
67
70
  add: options.add,
68
71
  snippets: options.snippets,
72
+ standards: options.standards,
69
73
  skip,
70
74
  }
71
75
 
@@ -3,6 +3,7 @@ import type { Command } from 'commander'
3
3
  import { registerPassThroughVerbs } from '@/commands/pass-through'
4
4
  import { PROJECT_ROOT } from '@/exec'
5
5
  import { createStandardsAdapter, standardsSourceDir } from '@/standards/adapter'
6
+ import { ALL_SELECTION, selectStandards } from '@/standards/closure'
6
7
  import {
7
8
  refreshIndex,
8
9
  STANDARDS_REL,
@@ -11,12 +12,26 @@ import {
11
12
  import { applyInstall, planInstall } from '@/standards/install'
12
13
  import { recordStamp, runDomainSync } from '@/sync/engine'
13
14
  import { resolveTarget } from '@/target'
14
- import { intro, logAdd, logInfo, logStep, logWarn, outro, select } from '@/ui'
15
+ import {
16
+ intro,
17
+ logAdd,
18
+ logError,
19
+ logInfo,
20
+ logStep,
21
+ logWarn,
22
+ outro,
23
+ select,
24
+ } from '@/ui'
15
25
 
16
26
  const GREEN = '\x1b[0;32m'
17
27
  const GREY = '\x1b[0;90m'
18
28
  const NC = '\x1b[0m'
19
29
 
30
+ interface InstallOptions {
31
+ /** Always present: the option falls back to `ALL_SELECTION`. */
32
+ readonly only: string
33
+ }
34
+
20
35
  export function register(program: Command): void {
21
36
  const standards = program
22
37
  .command('standards')
@@ -38,17 +53,36 @@ export function register(program: Command): void {
38
53
 
39
54
  standards
40
55
  .command('install')
41
- .description('Copy all standards into a project (overwrites)')
56
+ .description('Copy standards into a project (overwrites)')
42
57
  .argument('[target]', 'Target directory', '.')
58
+ .option(
59
+ '--only <names>',
60
+ "Comma-separated standard names, or 'all'",
61
+ ALL_SELECTION,
62
+ )
43
63
  .helpOption('-h, --help', 'Show this help message')
44
- .action(async (target: string) => {
45
- process.exitCode = await runInstall(target)
64
+ .addHelpText(
65
+ 'after',
66
+ [
67
+ '',
68
+ 'A selection expands to the standards it cites, so nothing lands with',
69
+ 'a dangling reference.',
70
+ '',
71
+ 'Examples:',
72
+ ' aitk standards install',
73
+ ' aitk standards install --only slug ../my-app',
74
+ ' aitk standards install --only design,wireframes ../my-app',
75
+ '',
76
+ ].join('\n'),
77
+ )
78
+ .action(async (target: string, options: InstallOptions) => {
79
+ process.exitCode = await runInstall(target, options.only)
46
80
  })
47
81
 
48
82
  registerPassThroughVerbs(standards, 'standards', ['list'])
49
83
  }
50
84
 
51
- async function runInstall(target: string): Promise<number> {
85
+ async function runInstall(target: string, selection: string): Promise<number> {
52
86
  intro('aitk standards')
53
87
 
54
88
  const resolved = resolveTarget(target, PROJECT_ROOT)
@@ -58,8 +92,31 @@ async function runInstall(target: string): Promise<number> {
58
92
  const destDir = standardsInstallDir(resolved)
59
93
 
60
94
  logStep('Scanning standards')
61
- const files = planInstall(sourceDir)
62
- for (const file of files) logInfo(join(STANDARDS_REL, file.name))
95
+ const available = planInstall(sourceDir)
96
+ const result = selectStandards(available, selection)
97
+
98
+ if (!result.ok) {
99
+ logError(
100
+ `Standard not found: ${result.unknown.join(', ')}. Run 'aitk standards list' for the catalog.`,
101
+ )
102
+ outro()
103
+ return 1
104
+ }
105
+
106
+ const { files, requested, added, unresolved } = result.selection
107
+ for (const name of requested) logInfo(join(STANDARDS_REL, name))
108
+
109
+ if (added.length > 0) {
110
+ logStep(`Added by citation (${added.length})`)
111
+ for (const name of added) logInfo(join(STANDARDS_REL, name))
112
+ }
113
+
114
+ if (unresolved.length > 0) {
115
+ logStep(`Scope handoffs not installed (${unresolved.length})`)
116
+ for (const name of unresolved) logWarn(name)
117
+ logInfo('Each names a concern these standards do not govern. Add a name')
118
+ logInfo('to --only if the project needs that standard as well.')
119
+ }
63
120
 
64
121
  const shouldInstall = await select({
65
122
  message: `Install ${files.length} standards to ${destDir}?`,
package/src/init/flags.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Command } from 'commander'
2
2
  import { DEFAULT_STACK, SKIPPABLE_DOMAINS } from '@/init/plan'
3
+ import { ALL_SELECTION } from '@/standards/closure'
3
4
 
4
5
  interface InitOptionSpec {
5
6
  /** The option value key commander stores the parsed value under. */
@@ -32,6 +33,12 @@ export const INIT_OPTIONS: readonly InitOptionSpec[] = [
32
33
  description: "Snippets preset, category, or 'all'",
33
34
  defaultValue: 'essentials',
34
35
  },
36
+ {
37
+ key: 'standards',
38
+ flags: '--standards <selection>',
39
+ description: "Comma-separated standard names, or 'all'",
40
+ defaultValue: ALL_SELECTION,
41
+ },
35
42
  {
36
43
  key: 'skip',
37
44
  flags: '--skip <list>',
@@ -55,9 +62,9 @@ export function applyInitOptions(command: Command): Command {
55
62
 
56
63
  /**
57
64
  * Whether the operator passed any flag, which is what makes the command
58
- * scriptable by suppressing the confirmation prompt. `--snippets` and `--stack`
59
- * both carry defaults, so presence has to be read from where the value came
60
- * from rather than from the value itself.
65
+ * scriptable by suppressing the confirmation prompt. `--stack`, `--snippets`,
66
+ * and `--standards` all carry defaults, so presence has to be read from where
67
+ * the value came from rather than from the value itself.
61
68
  */
62
69
  export function flagsProvided(cmd: Command): boolean {
63
70
  return INIT_OPTIONS.some(
package/src/init/plan.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { ALL_SELECTION } from '@/standards/closure'
2
+
1
3
  export const SKIPPABLE_DOMAINS = ['wiki', 'standards', 'governance'] as const
2
4
 
3
5
  export type SkippableDomain = (typeof SKIPPABLE_DOMAINS)[number]
@@ -31,6 +33,7 @@ export interface InitFlags {
31
33
  readonly stack?: string
32
34
  readonly add?: string
33
35
  readonly snippets: string
36
+ readonly standards: string
34
37
  readonly skip: SkipPlan
35
38
  }
36
39
 
@@ -106,7 +109,11 @@ export function planInit(flags: InitFlags): InitPlan {
106
109
  }
107
110
 
108
111
  if (!flags.skip.skipped.has('standards')) {
109
- preview.push({ level: 'info', text: 'standards (authoring conventions)' })
112
+ const detail =
113
+ flags.standards === ALL_SELECTION
114
+ ? 'authoring conventions'
115
+ : `${flags.standards}, plus what they cite`
116
+ preview.push({ level: 'info', text: `standards (${detail})` })
110
117
  }
111
118
 
112
119
  preview.push({ level: 'info', text: `snippets (${flags.snippets})` })
package/src/init/steps.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type InitFlags, resolveStack } from '@/init/plan'
2
2
  import type { DomainStep } from '@/init/run'
3
+ import { ALL_SELECTION } from '@/standards/closure'
3
4
 
4
5
  /** Builds the child-process invocation for one domain. */
5
6
  export type RunFactory = (args: readonly string[]) => () => Promise<boolean>
@@ -52,7 +53,7 @@ export function buildSteps(
52
53
  steps.push({
53
54
  kind: 'run',
54
55
  label: 'Standards',
55
- run: child(['standards', 'install', resolved]),
56
+ run: child(standardsArgs(flags.standards, resolved)),
56
57
  })
57
58
  }
58
59
 
@@ -73,6 +74,21 @@ export function buildSteps(
73
74
  return steps
74
75
  }
75
76
 
77
+ /**
78
+ * Builds the `standards install` argv. `all` is left off rather than spelled
79
+ * out, so the default init runs the same command it ran before the flag
80
+ * existed.
81
+ */
82
+ function standardsArgs(selection: string, path: string): string[] {
83
+ const args = ['standards', 'install']
84
+ if (selection !== '' && selection !== ALL_SELECTION) {
85
+ args.push('--only', selection)
86
+ }
87
+ args.push(path)
88
+
89
+ return args
90
+ }
91
+
76
92
  /**
77
93
  * Builds the `gov install` argv. The run and the recovery command a skip prints
78
94
  * come from here both, so the command a caller is told to paste installs what
@@ -0,0 +1,200 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { basename } from 'node:path'
3
+ import type { StandardsSource } from '@/standards/install'
4
+
5
+ export const ALL_SELECTION = 'all'
6
+
7
+ export interface Citations {
8
+ /** Siblings the body depends on, which the closure follows. */
9
+ readonly cited: readonly string[]
10
+ /** Siblings a `Does not govern:` entry hands off to, which it does not. */
11
+ readonly delegated: readonly string[]
12
+ }
13
+
14
+ export interface StandardsSelection {
15
+ readonly files: readonly StandardsSource[]
16
+ /** What the caller named, in the order the flat root lists it. */
17
+ readonly requested: readonly string[]
18
+ /** What a requested standard cites and the caller did not name. */
19
+ readonly added: readonly string[]
20
+ /** Handoff targets that did not land, so their pointers will not resolve. */
21
+ readonly unresolved: readonly string[]
22
+ }
23
+
24
+ export type SelectionResult =
25
+ | { readonly ok: true; readonly selection: StandardsSelection }
26
+ | { readonly ok: false; readonly unknown: readonly string[] }
27
+
28
+ const CITATION = /`([^`\n]+?\.md)`/g
29
+ const DELEGATION_START = /^Does not govern:/
30
+ const HEADING = /^#{1,6}\s/
31
+
32
+ /** Accepts `prose` and `prose.md` alike, since the catalog lists both spellings. */
33
+ export function normalizeName(raw: string): string {
34
+ const name = raw.trim()
35
+ return name.endsWith('.md') ? name : `${name}.md`
36
+ }
37
+
38
+ export function parseSelection(csv: string): string[] {
39
+ return csv
40
+ .split(',')
41
+ .map((raw) => raw.trim())
42
+ .filter((raw) => raw !== '')
43
+ .map(normalizeName)
44
+ }
45
+
46
+ /**
47
+ * Splits a body at the `Does not govern:` list, which runs to the next heading.
48
+ * Every standard in the flat root carries exactly one, directly under `## Scope`.
49
+ */
50
+ function splitDelegatedScope(body: string): {
51
+ governing: string
52
+ delegated: string
53
+ } {
54
+ const governing: string[] = []
55
+ const delegated: string[] = []
56
+ let inDelegation = false
57
+
58
+ for (const line of body.split('\n')) {
59
+ if (DELEGATION_START.test(line)) inDelegation = true
60
+ else if (inDelegation && HEADING.test(line)) inDelegation = false
61
+
62
+ if (inDelegation) delegated.push(line)
63
+ else governing.push(line)
64
+ }
65
+
66
+ return { governing: governing.join('\n'), delegated: delegated.join('\n') }
67
+ }
68
+
69
+ function matchNames(text: string, available: ReadonlySet<string>): string[] {
70
+ const found = new Set<string>()
71
+
72
+ for (const match of text.matchAll(CITATION)) {
73
+ const token = match[1]
74
+ if (token === undefined) continue
75
+
76
+ const name = basename(token)
77
+ if (available.has(name)) found.add(name)
78
+ }
79
+
80
+ return [...found]
81
+ }
82
+
83
+ /**
84
+ * Reads the sibling standards a body cites, split by whether the citation is a
85
+ * dependency or a handoff. A citation is a backticked token ending in `.md`,
86
+ * which is the only place either relationship is written, so the parse is a
87
+ * heuristic and every candidate is resolved against `available` before it
88
+ * counts. That resolution is what drops a fenced example, a target project's
89
+ * `.claude/ARCHITECTURE.md`, and a bundled standard the flat root does not
90
+ * install, none of which a selection should pull in.
91
+ *
92
+ * A citation inside the `Does not govern:` list is `delegated` rather than
93
+ * `cited`, because that entry says the sibling owns a concern this standard
94
+ * does not. Expanding on it pulls in a file the caller declined by not naming
95
+ * it, and nearly all the corpus density sits in those lists, which is what
96
+ * collapsed every selection into the whole corpus. A name appearing in the list
97
+ * and also outside it stays `cited`, since a real dependency outranks a handoff.
98
+ *
99
+ * Matching is case-exact against the listing rather than a filesystem probe,
100
+ * because a case-insensitive volume would otherwise resolve `SKILL.md` onto
101
+ * `skill.md` and expand a selection on a citation that names a target's own
102
+ * file. The basename is what resolves, so `standards/versioning.md` and a bare
103
+ * `versioning.md` read as the same dependency.
104
+ */
105
+ export function citedStandards(
106
+ body: string,
107
+ available: ReadonlySet<string>,
108
+ ): Citations {
109
+ const { governing, delegated } = splitDelegatedScope(body)
110
+ const cited = matchNames(governing, available)
111
+ const citedSet = new Set(cited)
112
+
113
+ return {
114
+ cited,
115
+ delegated: matchNames(delegated, available).filter(
116
+ (name) => !citedSet.has(name),
117
+ ),
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Expands a selection to the transitive closure of what it cites, so an install
123
+ * cannot land a standard whose citations dangle. `all` and an empty selection
124
+ * both mean every standard, which is what keeps the existing callers unchanged.
125
+ *
126
+ * The closure follows dependencies alone. A `Does not govern:` handoff names a
127
+ * concern the caller declined by not selecting it, so the target stays out and
128
+ * is reported through `unresolved` instead. Following those too pulls the whole
129
+ * corpus in behind any single name.
130
+ *
131
+ * An unrecognized name fails the whole selection rather than being dropped with
132
+ * a warning, unlike `--skip` on `aitk init`. A typo here silently omits a
133
+ * standard the caller asked for, and the closure would then be computed over
134
+ * the wrong set.
135
+ */
136
+ export function selectStandards(
137
+ available: readonly StandardsSource[],
138
+ selection: string,
139
+ ): SelectionResult {
140
+ const byName = new Map(available.map((file) => [file.name, file]))
141
+
142
+ if (selection.trim() === '' || selection.trim() === ALL_SELECTION) {
143
+ return {
144
+ ok: true,
145
+ selection: {
146
+ files: available,
147
+ requested: available.map((file) => file.name),
148
+ added: [],
149
+ unresolved: [],
150
+ },
151
+ }
152
+ }
153
+
154
+ const requested = parseSelection(selection)
155
+ const unknown = requested.filter((name) => !byName.has(name))
156
+ if (unknown.length > 0) return { ok: false, unknown }
157
+
158
+ const names = new Set(byName.keys())
159
+ const resolved = new Set(requested)
160
+ const handoffs = new Set<string>()
161
+ const queue = [...requested]
162
+
163
+ for (let index = 0; index < queue.length; index += 1) {
164
+ const name = queue[index]
165
+ if (name === undefined) continue
166
+
167
+ const file = byName.get(name)
168
+ if (file === undefined) continue
169
+
170
+ const citations = citedStandards(readFileSync(file.path, 'utf8'), names)
171
+ for (const handoff of citations.delegated) handoffs.add(handoff)
172
+
173
+ for (const cited of citations.cited) {
174
+ if (resolved.has(cited)) continue
175
+
176
+ resolved.add(cited)
177
+ queue.push(cited)
178
+ }
179
+ }
180
+
181
+ const requestedSet = new Set(requested)
182
+
183
+ return {
184
+ ok: true,
185
+ selection: {
186
+ files: available.filter((file) => resolved.has(file.name)),
187
+ requested: available
188
+ .filter((file) => requestedSet.has(file.name))
189
+ .map((file) => file.name),
190
+ added: available
191
+ .filter(
192
+ (file) => resolved.has(file.name) && !requestedSet.has(file.name),
193
+ )
194
+ .map((file) => file.name),
195
+ unresolved: available
196
+ .filter((file) => handoffs.has(file.name) && !resolved.has(file.name))
197
+ .map((file) => file.name),
198
+ },
199
+ }
200
+ }