@erclx/aitk 0.84.0 → 0.86.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.
@@ -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.84.0",
4
+ "version": "0.86.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -47,7 +47,7 @@ Each domain exposes a consistent shape where applicable: `list`, `install`, `syn
47
47
  | ----------- | --------------------------------------------------------------------------- |
48
48
  | `tooling` | `list`, `sync`, `ref`, `create`, `verify`, `inject`, `prune-gitignore` |
49
49
  | `snippets` | `list`, `install`, `sync`, `create` |
50
- | `standards` | `list`, `install`, `sync` |
50
+ | `standards` | `list`, `install`, `sync`, `<name>` |
51
51
  | `gov` | `list`, `install`, `sync`, `build` |
52
52
  | `claude` | `init`, `sync`, `seeds list`, `skills list`, `skills audit`, `setup [dest]` |
53
53
  | `wiki` | `init` |
@@ -27,7 +27,13 @@ A bare run measures every markdown file git lists, tracked plus untracked-and-no
27
27
 
28
28
  ## Where the rules come from
29
29
 
30
- Both ban sets and all five checkpoints are read out of the standards per run, resolved under `.claude/standards/` first and the authoring root second, so a target project measures against the copy it actually has. Holding the lists in code was the alternative and it puts each ban in two places, where an author adding one gets no enforcement until someone edits TypeScript.
30
+ Both ban sets and all five checkpoints are read out of the standards per run, resolved under `.claude/standards/` first, the authoring root second, and the corpus inside the `aitk` package last, so a target project measures against the copy it actually has and a project that installed none is measured all the same. Holding the lists in code was the alternative and it puts each ban in two places, where an author adding one gets no enforcement until someone edits TypeScript.
31
+
32
+ A project copy wins wherever one exists. An installed standard is a seed a project edits, so a package copy overriding it would discard that edit with nothing said, and the fallback answers absence rather than staleness. The package copy is reachable because `package.json` ships `standards/` beside the code doing the reading, which is what leaves the install optional for every machine consumer.
33
+
34
+ The report names the copy it read on every run, spelling a package copy under `<aitk>/` rather than as a path anything would join to a project root. A published package can lag the toolkit a session reads from, the same two-speed skew `.claude/ARCHITECTURE.md` records between the plugin cache and the CLI, and naming the source is what keeps that diagnosable instead of invisible.
35
+
36
+ `aitk standards <name>` resolves the same three roots in the same order and prints the standard, so the human catalog reads without a project copy on disk too.
31
37
 
32
38
  The trade is a reader of prose that a reformat can break. `src/markdown/bans.test.ts` answers it by asserting the parsed sets against the shipped standards, so a rewrite that narrows a set fails there rather than passing quietly. A checkpoint falls back per number rather than per file, and the depth legend names every checkpoint that fell back on the run that used one.
33
39
 
@@ -116,7 +122,9 @@ Four surfaces read the ban sets and two of them go through this verb. `.claude/h
116
122
 
117
123
  The other two read the standards directly and neither is a consolidation left half done. `claude/skills/claude-standards-audit/SKILL.md` greps the banned tokens agent-side, which is a session reading prose rather than a process it can shell out to, and it ships to every target. The seed copy of the hook keeps its awk, because a scaffolded project may carry no `aitk` and `scripts/core/check-seed-independence.sh` exists to catch seed content depending on the toolkit CLI. Both are the likelier place for the next drift, since nothing compares either against the verb.
118
124
 
119
- The hook prefers a checkout's own `src/cli.ts` over a globally installed binary, so it and the push stage read one build. A published binary lags a branch by whatever has not been released, which would put a ban kind added on the branch into the push and not into the edit. It reads its findings out of the `--json` record rather than off the exit code, so an older binary still reports where the fallback applies. It reads `bans.missingStandards` out of the same record, so a standard the verb found under neither root reaches the author as a check narrowed to what it could read.
125
+ The hook prefers a checkout's own `src/cli.ts` over a globally installed binary, so it and the push stage read one build. A published binary lags a branch by whatever has not been released, which would put a ban kind added on the branch into the push and not into the edit. It reads its findings out of the `--json` record rather than off the exit code, so an older binary still reports where the fallback applies. It reads `bans.missingStandards` out of the same record, so a standard the verb found under none of the three roots reaches the author as a check narrowed to what it could read.
126
+
127
+ The package fallback leaves that message reachable in one case rather than none. A CLI running out of a package carrying the corpus always resolves a standard, so the narrowed-check path fires only for a checkout whose own source tree lacks the file. The message stays right for that case and the hook keeps reading the field, since a reader cannot tell a narrowed check from a clean one without it.
120
128
 
121
129
  A machine with neither runner still blocks no edit, and it says so rather than exiting clean. The push stage holds either way. An edit nobody checked and an edit carrying no violation are one silence to a reader, so the enforcement a machine lacks is reported rather than inferred.
122
130
 
@@ -83,7 +83,7 @@ Run `aitk tooling list --json` and `aitk gov list --json` to see the current cat
83
83
  `governance`, `standards`, and `wiki` are skippable:
84
84
 
85
85
  - `--skip governance`: leave `.claude/rules/` empty. Standards still install, so `.claude/standards/prose.md` lands with nothing pointing at it and 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.
86
- - `--skip standards`: leave standards out. The governance rules still reference `.claude/standards/`, so their authority lines resolve to nothing. Toolkit skills are unaffected, since each falls back to the copy in its own plugin root.
86
+ - `--skip standards`: leave standards out. The governance rules still reference `.claude/standards/`, so their authority lines resolve to nothing for a reader following the path. Toolkit skills are unaffected, since each falls back to the copy in its own plugin root, and so is the CLI, which searches the corpus inside its own package behind both project roots. `aitk markdown audit` and `aitk standards <name>` therefore answer in a project that skipped the install, and what the skip costs is the editable copy rather than the enforcement.
87
87
  - `--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.
88
88
 
89
89
  That standards fallback 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.
@@ -6,7 +6,15 @@ paths:
6
6
 
7
7
  # Wireframe standards
8
8
 
9
+ ## Layout and intent
10
+
11
+ - Draw each surface as an ASCII block inside a `plaintext` fence, one fence per distinct layout.
12
+ - Label a region with its role. Never label one with a class name, a token name, or a computed value.
13
+ - Carry on-screen copy verbatim, and mark copy the surface templates.
14
+ - State interaction intent, never the mechanism behind it. Send algorithms, handlers, and thresholds to a `.claude/context/` entry.
15
+ - Update a surface's wireframe in the same pull request that changes its layout or its interaction.
16
+
9
17
  ## Authority
10
18
 
11
19
  - Follow `.claude/standards/wireframes.md` for layout and interaction intent: ASCII layout, region labels, variants, copy, and what moves to `.claude/context/`. It is the single source.
12
- - A wireframe carries layout and intent, not implementation. Read the standard before adding or revising a surface.
20
+ - Read it before adding or revising a surface.
@@ -6,6 +6,14 @@ paths:
6
6
 
7
7
  # Requirements standards
8
8
 
9
+ ## Scope and non-goals
10
+
11
+ - State a goal as a user-facing outcome, never as the implementation delivering it.
12
+ - Record explicit non-goals. Mark a deferred item `(deferred)`.
13
+ - Leave the MVP list as written once its features ship. Do not renumber it, extend it, or annotate an entry with status.
14
+ - Add later scope as a new section rather than as an extension of the MVP list.
15
+ - Drop a section rather than pad it. Include `## Distribution` only when the project ships outside its own repository.
16
+
9
17
  ## Authority
10
18
 
11
19
  - Follow `.claude/standards/requirements.md` for what belongs in the problem, goals, non-goals, MVP features, tech stack, and constraints. It is the single source.
@@ -6,6 +6,16 @@ paths:
6
6
 
7
7
  # Architecture standards
8
8
 
9
+ ## Decisions
10
+
11
+ - Give each decision its own H3 under `## Key technical decisions`, naming what it chose and the alternative it passed over.
12
+ - Keep line-by-line function behavior and full type definitions out.
13
+
14
+ ## Verification anchors
15
+
16
+ - Close a decision citing a measured number with `Measured at <short-sha> on <YYYY-MM-DD>.` Leave a decision citing none unanchored.
17
+ - Refresh that anchor whenever the number is re-read, whether or not it moved.
18
+
9
19
  ## Authority
10
20
 
11
21
  - Follow `.claude/standards/architecture.md` for the overview, named decision entries, and risks. It is the single source.
@@ -6,6 +6,14 @@ paths:
6
6
 
7
7
  # Design standards
8
8
 
9
+ ## Tokens
10
+
11
+ - Describe a token as intent rather than as a computed value.
12
+ - Keep CSS classes, component filenames, and prop names out.
13
+ - Carry a token system as a table, one row per token, and a component rule as a short bullet.
14
+ - Keep the token table headers verbatim.
15
+ - Record a non-obvious omission, such as no motion or no custom icons.
16
+
9
17
  ## Authority
10
18
 
11
19
  - Follow `.claude/standards/design.md` for visual intent, the token tables, and the render contract. It is the single source.
@@ -6,6 +6,14 @@ paths:
6
6
 
7
7
  # Tasks standards
8
8
 
9
+ ## What a task carries
10
+
11
+ - Name where the task came from, through a `Plan:`, `Groundwork:`, `Intake:`, or `Issue:` line under the title.
12
+ - Size the outcomes so one pull request closes all of them. Split the task before handing it off.
13
+ - Use `## Outcomes` and `## Findings` and add no third heading. Note status inline on an outcome.
14
+ - Keep class names, file paths, function names, and prop names out of every entry and title.
15
+ - Never delete a task file. A shipped task moves to the archive under its own name.
16
+
9
17
  ## Authority
10
18
 
11
19
  - Follow `.claude/standards/tasks.md` for filenames, frontmatter, what belongs, and the task file format. It is the single source.
@@ -6,6 +6,14 @@ paths:
6
6
 
7
7
  # README standards
8
8
 
9
+ ## Audience and voice
10
+
11
+ - Write a repository-root README for someone deciding whether to commit to the project. A nested README keeps the reference voice.
12
+ - Address the reader in second person.
13
+ - Ground a claim in a command, a number, or a named constraint rather than an adjective.
14
+ - Name what the project does not do.
15
+ - Link out to full API documentation, license text, contribution guidelines, and troubleshooting rather than carrying them.
16
+
9
17
  ## Authority
10
18
 
11
19
  - Follow `.claude/standards/readme.md` for voice, structure, required and optional sections, heading hierarchy, badge classes, per-project-type content, and what to link out rather than inline. It is the single source.
@@ -6,6 +6,17 @@ paths:
6
6
 
7
7
  # Rule authoring standards
8
8
 
9
+ ## Numbering
10
+
11
+ - Name a rule `.claude/rules/<subdirectory>/<n>-<slug>.md`, taking a number that collides with neither the project's rules nor any installed shared set.
12
+ - Give every rule a numeric prefix and keep the slug to one to three kebab words.
13
+
14
+ ## Body
15
+
16
+ - State one directive per bullet in imperative voice. Do not explain the reasoning behind a rule.
17
+ - Cut a rule that resists crisp one-line phrasing.
18
+ - Do not restate a rule a sibling rule or `CLAUDE.md` already owns. Point once.
19
+
9
20
  ## Authority
10
21
 
11
22
  - Follow `.claude/standards/rule.md` for rule frontmatter, body shape, and voice. It is the single source.
@@ -7,6 +7,14 @@ paths:
7
7
 
8
8
  # Standard authoring standards
9
9
 
10
+ ## The scope statement
11
+
12
+ - Govern one document type per standard, or one attribute across every document.
13
+ - Open with a `## Scope` section naming what the standard governs and a `Does not govern:` list, placed above the shape rules.
14
+ - Put the governed path in backticks in the first sentence, anchored deep enough to resolve from a project root. Keep an attribute standard's first sentence free of backticks and say in it that it governs an attribute.
15
+ - Give each `Does not govern:` entry an excluded concern and the owner it goes to. Cut an entry naming no owner.
16
+ - Declare a yield, an exemption, or a handoff from both sides of the boundary.
17
+
10
18
  ## Authority
11
19
 
12
20
  - Follow `.claude/standards/standard.md` for a standard's frontmatter, shape, scope, and success criterion. It is the single source.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "0.84.0",
4
+ "version": "0.86.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
package/src/cli.ts CHANGED
@@ -24,7 +24,7 @@ import { register as comments } from '@/commands/comments'
24
24
  import { register as context } from '@/commands/context'
25
25
  import { register as markdown } from '@/commands/markdown'
26
26
  import { register as records } from '@/commands/records'
27
- import { PROJECT_ROOT } from '@/exec'
27
+ import { PROJECT_ROOT } from '@/project-root'
28
28
 
29
29
  const GREY = '\x1b[0;90m'
30
30
  const WHITE = '\x1b[1;37m'
@@ -40,7 +40,7 @@ function showHelp(): void {
40
40
  `${GREY}│${NC} sync [path] ${GREY}# Sync all installed domains in a project${NC}`,
41
41
  `${GREY}│${NC} sandbox [cat:cmd] ${GREY}# Provision and run sandbox scenarios${NC}`,
42
42
  `${GREY}│${NC} gov [command] ${GREY}# Governance commands (install, sync)${NC}`,
43
- `${GREY}│${NC} standards [cmd] ${GREY}# Standards commands (install, sync)${NC}`,
43
+ `${GREY}│${NC} standards [cmd] ${GREY}# Standards commands (install, sync, list, <name>)${NC}`,
44
44
  `${GREY}│${NC} snippets [cmd] ${GREY}# Snippets commands (install, sync)${NC}`,
45
45
  `${GREY}│${NC} tooling [cmd] ${GREY}# Manage tooling stacks (sync, ref, create)${NC}`,
46
46
  `${GREY}│${NC} claude [cmd] ${GREY}# Claude workflow (init, sync, setup)${NC}`,
@@ -29,7 +29,8 @@ import {
29
29
  writeSettings,
30
30
  } from '@/claude/settings'
31
31
  import { copyPreservingMode } from '@/copy'
32
- import { execScript, PROJECT_ROOT } from '@/exec'
32
+ import { execScript } from '@/exec'
33
+ import { PROJECT_ROOT } from '@/project-root'
33
34
  import { isDirectory, resolveTarget } from '@/target'
34
35
  import { injectGitignore, pruneGitignore } from '@/tooling/inject'
35
36
  import {
@@ -1,7 +1,8 @@
1
1
  import type { Command } from 'commander'
2
2
  import { registerPassThroughVerbs } from '@/commands/pass-through'
3
3
  import { listTopics, readTopic, resolveTopic } from '@/docs/read'
4
- import { execScript, PROJECT_ROOT } from '@/exec'
4
+ import { execScript } from '@/exec'
5
+ import { PROJECT_ROOT } from '@/project-root'
5
6
  import { intro, logError, logInfo, logStep, logWarn, outro } from '@/ui'
6
7
 
7
8
  export function register(program: Command): void {
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import type { Command } from 'commander'
4
4
  import { deriveSlug, deriveTitle } from '@/commands/feedback-format'
5
- import { PROJECT_ROOT } from '@/exec'
5
+ import { PROJECT_ROOT } from '@/project-root'
6
6
  import { createGithubIssue } from '@/github'
7
7
  import { frameError, frameSuccess } from '@/ui'
8
8
 
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'
2
2
  import { mkdir, writeFile } from 'node:fs/promises'
3
3
  import { basename, dirname, join, resolve } from 'node:path'
4
4
  import type { Command } from 'commander'
5
- import { PROJECT_ROOT } from '@/exec'
5
+ import { PROJECT_ROOT } from '@/project-root'
6
6
  import { createGovAdapter } from '@/gov/adapter'
7
7
  import { regenConsumedRules } from '@/gov/consumed'
8
8
  import { hasStandards, installRules, lookupRules } from '@/gov/install'
@@ -1,6 +1,6 @@
1
1
  import type { Command } from 'commander'
2
2
  import { cliPath, cliRun } from '@/cli-run'
3
- import { PROJECT_ROOT } from '@/exec'
3
+ import { PROJECT_ROOT } from '@/project-root'
4
4
  import { applyInitOptions, flagsProvided } from '@/init/flags'
5
5
  import { type InitFlags, parseSkip, planInit } from '@/init/plan'
6
6
  import { runDomains } from '@/init/run'
@@ -69,8 +69,10 @@ export function register(program: Command): void {
69
69
  'what markdown.md reserves the span for.',
70
70
  '',
71
71
  'Bans and checkpoints are read from markdown.md and prose.md, resolved',
72
- 'under .claude/standards/ then standards/. No folder has to resolve and',
73
- 'no index.md has to exist, so .claude/rules/, governance/, and snippets/',
72
+ 'under .claude/standards/, then standards/, then the corpus inside the',
73
+ 'aitk package, so a project that installed neither is still measured.',
74
+ 'The report names the copy it read. No folder has to resolve and no',
75
+ 'index.md has to exist, so .claude/rules/, governance/, and snippets/',
74
76
  'are in reach.',
75
77
  '',
76
78
  'Examples:',
@@ -206,7 +208,7 @@ function reportBans(reports: readonly FileReport[], bans: BanReport): void {
206
208
 
207
209
  if (bans.missing.length > 0) {
208
210
  logWarn(
209
- `Not measured. Found neither copy of: ${bans.missing.join(', ')}. Looked under .claude/standards/ then standards/.`,
211
+ `Not measured. Found no copy of: ${bans.missing.join(', ')}. Looked under .claude/standards/, then standards/, then the aitk package.`,
210
212
  )
211
213
  if (bans.sources.length === 0) return
212
214
  }
@@ -2,7 +2,8 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'
2
2
  import { homedir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
  import type { Command } from 'commander'
5
- import { PROJECT_ROOT, execScript } from '@/exec'
5
+ import { execScript } from '@/exec'
6
+ import { PROJECT_ROOT } from '@/project-root'
6
7
  import {
7
8
  assertedPercent,
8
9
  collectCensus,
@@ -1,6 +1,6 @@
1
1
  import type { Command } from 'commander'
2
2
  import { registerPassThroughVerbs } from '@/commands/pass-through'
3
- import { PROJECT_ROOT } from '@/exec'
3
+ import { PROJECT_ROOT } from '@/project-root'
4
4
  import { createSnippetsAdapter } from '@/snippets/adapter'
5
5
  import { BASE_CATEGORY } from '@/snippets/categories'
6
6
  import {
@@ -1,7 +1,7 @@
1
1
  import { join } from 'node:path'
2
2
  import type { Command } from 'commander'
3
3
  import { registerPassThroughVerbs } from '@/commands/pass-through'
4
- import { PROJECT_ROOT } from '@/exec'
4
+ import { PROJECT_ROOT } from '@/project-root'
5
5
  import { createStandardsAdapter, standardsSourceDir } from '@/standards/adapter'
6
6
  import { ALL_SELECTION, selectStandards } from '@/standards/closure'
7
7
  import {
@@ -10,6 +10,7 @@ import {
10
10
  standardsInstallDir,
11
11
  } from '@/standards/index-refresh'
12
12
  import { applyInstall, planInstall } from '@/standards/install'
13
+ import { listStandards, readStandard, resolveStandard } from '@/standards/read'
13
14
  import { recordStamp, runDomainSync } from '@/sync/engine'
14
15
  import { resolveTarget } from '@/target'
15
16
  import {
@@ -35,8 +36,35 @@ interface InstallOptions {
35
36
  export function register(program: Command): void {
36
37
  const standards = program
37
38
  .command('standards')
38
- .description('Standards commands (install, sync, list)')
39
+ .description('Standards commands (install, sync, list, <name>)')
40
+ .argument('[name]', 'Standard to print, by name with or without .md')
39
41
  .helpOption('-h, --help', 'Show this help message')
42
+ .addHelpText(
43
+ 'after',
44
+ [
45
+ '',
46
+ 'A name resolves under .claude/standards/, then standards/, then the',
47
+ 'corpus inside the aitk package, so a standard prints without a project',
48
+ 'copy on disk. The frame names the copy it read.',
49
+ '',
50
+ 'Examples:',
51
+ ' aitk standards prose',
52
+ ' aitk standards markdown.md',
53
+ '',
54
+ ].join('\n'),
55
+ )
56
+ .action((name: string | undefined, _options: unknown, cmd: Command) => {
57
+ if (name === undefined) {
58
+ // Registering an action replaces commander's own no-action fallback,
59
+ // which writes this help to stderr. Help is UI rather than data, and
60
+ // the default here is stdout.
61
+ cmd.outputHelp({ error: true })
62
+ process.exitCode = 1
63
+ return
64
+ }
65
+
66
+ process.exitCode = print(name)
67
+ })
40
68
 
41
69
  standards
42
70
  .command('sync')
@@ -82,6 +110,35 @@ export function register(program: Command): void {
82
110
  registerPassThroughVerbs(standards, 'standards', ['list'])
83
111
  }
84
112
 
113
+ /**
114
+ * Writes the standard to stdout and every frame line to stderr, so a caller
115
+ * capturing the output with `$(...)` receives the document alone.
116
+ *
117
+ * The root is the caller's directory rather than the toolkit's, since a project
118
+ * copy is what governs wherever one exists and the package copy answers only
119
+ * its absence.
120
+ */
121
+ function print(name: string): number {
122
+ intro('aitk standards')
123
+
124
+ const root = process.cwd()
125
+ const resolved = resolveStandard(root, name)
126
+
127
+ if (!resolved) {
128
+ logWarn(`Unknown standard: ${name}`)
129
+ logStep('Available standards')
130
+ for (const each of listStandards(root)) logInfo(each)
131
+ logError("Run 'aitk standards list' for descriptions.")
132
+ outro()
133
+ return 1
134
+ }
135
+
136
+ logStep(resolved.source)
137
+ process.stdout.write(readStandard(resolved))
138
+ outro()
139
+ return 0
140
+ }
141
+
85
142
  async function runInstall(target: string, selection: string): Promise<number> {
86
143
  intro('aitk standards')
87
144
 
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import type { Command } from 'commander'
4
4
  import { cliPath, cliRun } from '@/cli-run'
5
- import { PROJECT_ROOT } from '@/exec'
5
+ import { PROJECT_ROOT } from '@/project-root'
6
6
  import {
7
7
  buildCheckReport,
8
8
  type CheckReport,
@@ -1,6 +1,7 @@
1
1
  import { resolve } from 'node:path'
2
2
  import type { Command } from 'commander'
3
- import { execScript, PROJECT_ROOT } from '@/exec'
3
+ import { execScript } from '@/exec'
4
+ import { PROJECT_ROOT } from '@/project-root'
4
5
  import {
5
6
  applyReferences,
6
7
  injectConfigs,
@@ -1,6 +1,6 @@
1
1
  import { resolve } from 'node:path'
2
2
  import type { Command } from 'commander'
3
- import { PROJECT_ROOT } from '@/exec'
3
+ import { PROJECT_ROOT } from '@/project-root'
4
4
  import {
5
5
  intro,
6
6
  logAdd,
package/src/exec.ts CHANGED
@@ -1,7 +1,6 @@
1
- import { join, resolve } from 'node:path'
1
+ import { join } from 'node:path'
2
2
  import { execa } from 'execa'
3
-
4
- export const PROJECT_ROOT = resolve(import.meta.dir, '..')
3
+ import { PROJECT_ROOT } from '@/project-root'
5
4
 
6
5
  export async function execScript(
7
6
  script: string,
package/src/github.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execa } from 'execa'
2
- import { PROJECT_ROOT } from '@/exec'
2
+ import { PROJECT_ROOT } from '@/project-root'
3
3
  import { buildIssueArgs, type CreateIssueOptions } from '@/github-format'
4
4
 
5
5
  const GH_TIMEOUT_MS = 30_000
@@ -1,6 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises'
2
- import { resolve } from 'node:path'
3
2
  import { linesOutsideFences } from '@/markdown/scan'
3
+ import { resolveStandard } from '@/standards/read'
4
4
 
5
5
  /**
6
6
  * Headings the two standards state their closed sets under.
@@ -17,13 +17,6 @@ export const CHARACTER_BAN_HEADING = '## Punctuation'
17
17
  const BAN_LEAD = '- Do not use '
18
18
  const SPELLING_LEAD = '- Use American English spelling'
19
19
 
20
- /**
21
- * Roots searched in order. The installed copy wins over the authoring root, so
22
- * a target project measures against the standard it actually has rather than
23
- * one only the toolkit carries.
24
- */
25
- const STANDARD_ROOTS = ['.claude/standards', 'standards']
26
-
27
20
  const BACKTICKED = /`([^`]+)`/g
28
21
  const WORD = /^[a-z]+$/
29
22
  const SUFFIX = /^-[a-z]+$/
@@ -35,12 +28,16 @@ export interface BanReport {
35
28
  readonly words: readonly string[]
36
29
  /** Spellings derived from the prose standard's own examples. */
37
30
  readonly spellings: readonly string[]
38
- /** Repo-relative paths of the standards read, in the order read. */
31
+ /**
32
+ * Paths of the standards read, in the order read. Repo-relative for a project
33
+ * copy, and spelled under `<aitk>/` for the package copy, so a reader can
34
+ * tell which root answered without joining a path that resolves nowhere.
35
+ */
39
36
  readonly sources: readonly string[]
40
37
  /**
41
- * Standards that resolved under neither root. Absent is a distinct state from
42
- * empty: a scan with no terms finds nothing, and reporting that as a clean
43
- * file claims the prose passed when nothing was looked for.
38
+ * Standards that resolved under none of the roots. Absent is a distinct state
39
+ * from empty: a scan with no terms finds nothing, and reporting that as a
40
+ * clean file claims the prose passed when nothing was looked for.
44
41
  */
45
42
  readonly missing: readonly string[]
46
43
  }
@@ -155,7 +152,7 @@ export function parseSpellingBans(markdown: string): string[] {
155
152
  }
156
153
 
157
154
  export interface StandardText {
158
- /** Repo-relative path of the copy read, so a report says which root won. */
155
+ /** Path of the copy read, so a report says which root won. */
159
156
  readonly source: string
160
157
  readonly text: string
161
158
  }
@@ -164,29 +161,24 @@ export interface Standards {
164
161
  readonly markdown: StandardText | undefined
165
162
  readonly prose: StandardText | undefined
166
163
  /**
167
- * Standards that resolved under neither root. Absent is a distinct state from
168
- * empty: a scan with no terms finds nothing, and reporting that as a clean
169
- * file claims the prose passed when nothing was looked for.
164
+ * Standards that resolved under none of the roots. Absent is a distinct state
165
+ * from empty: a scan with no terms finds nothing, and reporting that as a
166
+ * clean file claims the prose passed when nothing was looked for.
170
167
  */
171
168
  readonly missing: readonly string[]
172
169
  }
173
170
 
174
- async function readStandard(
171
+ async function loadStandard(
175
172
  root: string,
176
173
  name: string,
177
174
  ): Promise<StandardText | undefined> {
178
- for (const standardRoot of STANDARD_ROOTS) {
179
- const path = resolve(root, standardRoot, name)
180
- const file = Bun.file(path)
181
- if (!(await file.exists())) continue
182
-
183
- return {
184
- source: `${standardRoot}/${name}`,
185
- text: await readFile(path, 'utf8'),
186
- }
187
- }
175
+ const resolved = resolveStandard(root, name)
176
+ if (!resolved) return undefined
188
177
 
189
- return undefined
178
+ return {
179
+ source: resolved.source,
180
+ text: await readFile(resolved.path, 'utf8'),
181
+ }
190
182
  }
191
183
 
192
184
  /**
@@ -199,8 +191,8 @@ async function readStandard(
199
191
  */
200
192
  export async function loadStandards(root: string): Promise<Standards> {
201
193
  const [markdown, prose] = await Promise.all([
202
- readStandard(root, 'markdown.md'),
203
- readStandard(root, 'prose.md'),
194
+ loadStandard(root, 'markdown.md'),
195
+ loadStandard(root, 'prose.md'),
204
196
  ])
205
197
 
206
198
  return {
@@ -0,0 +1,19 @@
1
+ import { dirname, resolve } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+
4
+ /**
5
+ * The root of the package this CLI is running out of, which is the repository
6
+ * root in a checkout and the installed package directory in a target.
7
+ *
8
+ * Derived from `import.meta.url` rather than Bun's `import.meta.dir`, since the
9
+ * test runner resolves the first and leaves the second undefined, which puts
10
+ * every module reading this root out of reach of a test.
11
+ *
12
+ * It sits in a module of its own rather than beside `execScript`, because a
13
+ * path constant and a process spawn share no reason to change and an importer
14
+ * of the first would otherwise load `execa` to read one string.
15
+ */
16
+ export const PROJECT_ROOT = resolve(
17
+ dirname(fileURLToPath(import.meta.url)),
18
+ '..',
19
+ )
@@ -0,0 +1,97 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { basename, join } from 'node:path'
3
+ import { stripFrontmatter } from '@/frontmatter'
4
+ import { PROJECT_ROOT } from '@/project-root'
5
+ import { standardsSourceDir } from '@/standards/adapter'
6
+ import { INDEX_FILE } from '@/standards/index-refresh'
7
+
8
+ /**
9
+ * Spells the package root in a report where every other root spells a
10
+ * project-relative path. The `source` field promises a path inside the project
11
+ * and a package copy is the one source that promise cannot cover, so the
12
+ * spelling has to be one nothing will join to a project root.
13
+ */
14
+ const PACKAGE_LABEL = '<aitk>'
15
+
16
+ export interface StandardRoot {
17
+ /** Absolute directory to search. */
18
+ readonly dir: string
19
+ /** How a report spells a copy found under this root. */
20
+ readonly label: string
21
+ }
22
+
23
+ export interface ResolvedStandard {
24
+ readonly path: string
25
+ /** The label of the root that won, joined to the filename. */
26
+ readonly source: string
27
+ }
28
+
29
+ /**
30
+ * The three roots a standard resolves against, in precedence order.
31
+ *
32
+ * A project copy wins wherever one exists. An installed standard is a seed a
33
+ * project edits, so a package copy overriding it would discard that edit with
34
+ * nothing said, and the fallback answers absence rather than staleness. The
35
+ * package root is what lets a project that installed no standards still be
36
+ * measured against one, since `package.json` ships the corpus beside the code
37
+ * doing the reading.
38
+ */
39
+ export function standardRoots(root: string): StandardRoot[] {
40
+ return [
41
+ {
42
+ dir: join(root, '.claude', 'standards'),
43
+ label: join('.claude', 'standards'),
44
+ },
45
+ { dir: join(root, 'standards'), label: 'standards' },
46
+ {
47
+ dir: standardsSourceDir(PROJECT_ROOT),
48
+ label: join(PACKAGE_LABEL, 'standards'),
49
+ },
50
+ ]
51
+ }
52
+
53
+ function filename(name: string): string {
54
+ return name.endsWith('.md') ? name : `${name}.md`
55
+ }
56
+
57
+ /** Resolves a standard by name, with or without the extension. */
58
+ export function resolveStandard(
59
+ root: string,
60
+ name: string,
61
+ ): ResolvedStandard | undefined {
62
+ const file = filename(name)
63
+
64
+ for (const { dir, label } of standardRoots(root)) {
65
+ const path = join(dir, file)
66
+ if (existsSync(path)) return { path, source: join(label, file) }
67
+ }
68
+
69
+ return undefined
70
+ }
71
+
72
+ /**
73
+ * Names every standard a resolve could reach, deduplicated across the roots and
74
+ * sorted. Shown when a name misses, so it doubles as the answer to what the
75
+ * caller should have typed.
76
+ */
77
+ export function listStandards(root: string): string[] {
78
+ const names = new Set<string>()
79
+
80
+ for (const { dir } of standardRoots(root)) {
81
+ if (!existsSync(dir)) continue
82
+
83
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
84
+ if (!entry.isFile()) continue
85
+ if (!entry.name.endsWith('.md')) continue
86
+ if (entry.name === INDEX_FILE) continue
87
+
88
+ names.add(basename(entry.name, '.md'))
89
+ }
90
+ }
91
+
92
+ return [...names].sort()
93
+ }
94
+
95
+ export function readStandard(standard: ResolvedStandard): string {
96
+ return stripFrontmatter(readFileSync(standard.path, 'utf8'))
97
+ }