@erclx/aitk 0.85.0 → 0.86.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.85.0",
4
+ "version": "0.86.1",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -17,7 +17,7 @@ Without this skill, a pull request is reviewed only by the session that wrote it
17
17
  - Scope a later pass to the commits added since the prior one, once that commit is confirmed to still reach the head
18
18
  - Apply the integration, contract, and consumer lenses a self-review structurally cannot
19
19
  - Post the closing pass even with nothing to report, since a review left unanswered reads as one nobody closed
20
- - Key the body file on both the pull request number and the head commit, so no two passes overwrite each other
20
+ - Key the body file on the pull request number and the head commit, and on the response it answers once the head repeats, so no two passes overwrite each other
21
21
  - Scan the comment for banned characters and internal phase labels before posting, since a finding phrased against a phase label reaches a reader with no task board
22
22
 
23
23
  ## Must not
@@ -32,6 +32,7 @@ Without this skill, a pull request is reviewed only by the session that wrote it
32
32
 
33
33
  - No open pull request for the target branch: stop and route to the local review skill
34
34
  - The fetch of the pull request head fails: stop rather than falling through to a full pass
35
+ - The head repeats with no response since the prior pass: stop rather than posting a body restating one the folder holds
35
36
 
36
37
  ## Out of scope
37
38
 
@@ -95,6 +95,20 @@ Write the comment to `.claude/.tmp/pr-review/body-<number>-<short-sha>.md`. The
95
95
 
96
96
  Derive both segments from Step 1. Never pick a suffix by hand, and never reuse a name the folder already holds.
97
97
 
98
+ When `<prior-oid>` from Step 2 equals `headRefOid`, the head repeats and the folder already holds `body-<number>-<short-sha>.md`. Add a third segment taking the id of the `## Review response` comment this pass answers, giving `body-<number>-<short-sha>-r<comment-id>.md`. That satisfies both prohibitions above rather than carving an exception into either.
99
+
100
+ ```bash
101
+ gh pr view <number> --json reviews,comments --jq '([.reviews[] | select(.body // "" | split("\n")[0] | rtrimstr("\r") | . == "## Review" or . == "## Review closed")] | last | .submittedAt) as $prior | [.comments[] | select(.body // "" | split("\n")[0] | rtrimstr("\r") | . == "## Review response") | select(.createdAt > $prior)] | last | .url // empty | split("-") | last'
102
+ ```
103
+
104
+ Scope the responses to those newer than the prior pass, never to every response the thread carries. A pass answering the newest response and a pass answering an older one derive the same third segment, so an unscoped read hands a re-run after a close-out the name its own prior pass already wrote. That is the collision this case exists to prevent, reached without a rebase or an error.
105
+
106
+ Read the number off `.url`. The `id` field carries a GraphQL node id, which the thread never displays. Keep the `// empty` guard, since `split` aborts jq on the null an empty selection returns, and an aborted command reaches the session as an error rather than as the empty result the stop below reads.
107
+
108
+ An empty result means no response arrived since the prior pass, so this pass would restate a body the folder already holds. Stop: `❌ No response since the prior pass on <short-sha>. Nothing new to review.`
109
+
110
+ The response is also the whole read on a repeated head. Step 2 resolves an empty range, because a commit is its own ancestor and `<prior-oid>..<headRefOid>` spans nothing, so the delta cannot answer whether a prior finding landed. Read that comment for what the worker changed or accepted, and treat an accepted finding as closed rather than restating it.
111
+
98
112
  The comment is a rendered-for-human GitHub surface, so follow `.claude/standards/prose.md` for voice, or `${CLAUDE_SKILL_DIR}/../../standards/prose.md` when the project does not have it: cut editorializing, and keep every sentence load-bearing. Match this shape on a first pass:
99
113
 
100
114
  ```markdown
@@ -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
 
@@ -41,7 +41,7 @@ One feature travels this path end to end.
41
41
  3. The human opens a worker worktree with `claude-worktree` and runs `claude-autoship` against the plan. The worker builds, self-checks, opens a PR, and stops at the PR boundary.
42
42
  4. Orchestrator reviews the PR with `claude-pr-review` and posts findings to it.
43
43
  5. Worker addresses the findings with `claude-address-review`, rebases onto `origin/main` when a sibling landed first and left the branch unable to merge, then pushes a follow-up.
44
- 6. Orchestrator closes the review out with `claude-pr-review` again. The second pass reads only the commits the follow-up added, and posts under `## Review closed` when it finds nothing open or under `## Review` when it does, so a reader learns the state from the heading. Repeat from step 5 until the review closes.
44
+ 6. Orchestrator closes the review out with `claude-pr-review` again. The second pass reads only the commits the follow-up added, or the worker's response alone when the follow-up added none, and posts under `## Review closed` when it finds nothing open or under `## Review` when it does, so a reader learns the state from the heading. Repeat from step 5 until the review closes.
45
45
  7. The human reads the result and merges. The orchestrator tells any trailing worker whose branch shares a seam with the merged one to run `claude-address-review`, which rebases whether or not the review left anything open.
46
46
 
47
47
  There is no loop construct here. Each worker is a single build that halts at the
@@ -68,6 +68,13 @@ the PR. `claude-address-review` reads them back, fixes each, replies or resolves
68
68
  the threads, and pushes a follow-up. `claude-pr-review` then runs again, reading
69
69
  only what the follow-up added.
70
70
 
71
+ A finding answered without a commit leaves the head where the first pass read it,
72
+ which a gitignored record and a finding accepted as recorded both produce. The
73
+ close-out is still owed there, since the newest heading is what tells an operator
74
+ whether the branch is blocked, so the pass reads the worker's response rather than
75
+ a delta and names its body from that response instead of from a tree that did not
76
+ change.
77
+
71
78
  A branch that stopped merging while the review was open is the worker's problem
72
79
  to close. `claude-address-review` rebases onto `origin/main` between the fixes
73
80
  and the push, so one force-push carries both and the reviewer reads one delta.
@@ -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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "0.85.0",
4
+ "version": "0.86.1",
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
+ }