@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-pr-review/REQUIREMENT.md +2 -1
- package/claude/skills/claude-pr-review/SKILL.md +14 -0
- package/docs/agents/commands.md +1 -1
- package/docs/agents/markdown-audit.md +10 -2
- package/docs/operating-model.md +8 -1
- package/docs/target-projects.md +1 -1
- package/package.json +1 -1
- package/src/cli.ts +2 -2
- package/src/commands/claude.ts +2 -1
- package/src/commands/docs.ts +2 -1
- package/src/commands/feedback.ts +1 -1
- package/src/commands/gov.ts +1 -1
- package/src/commands/init.ts +1 -1
- package/src/commands/markdown.ts +5 -3
- package/src/commands/sandbox.ts +2 -1
- package/src/commands/snippets.ts +1 -1
- package/src/commands/standards.ts +59 -2
- package/src/commands/sync.ts +1 -1
- package/src/commands/tooling.ts +2 -1
- package/src/commands/wiki.ts +1 -1
- package/src/exec.ts +2 -3
- package/src/github.ts +1 -1
- package/src/markdown/bans.ts +22 -30
- package/src/project-root.ts +19 -0
- package/src/standards/read.ts +97 -0
|
@@ -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
|
|
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
|
package/docs/agents/commands.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
package/docs/operating-model.md
CHANGED
|
@@ -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.
|
package/docs/target-projects.md
CHANGED
|
@@ -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
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 '@/
|
|
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}`,
|
package/src/commands/claude.ts
CHANGED
|
@@ -29,7 +29,8 @@ import {
|
|
|
29
29
|
writeSettings,
|
|
30
30
|
} from '@/claude/settings'
|
|
31
31
|
import { copyPreservingMode } from '@/copy'
|
|
32
|
-
import { execScript
|
|
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 {
|
package/src/commands/docs.ts
CHANGED
|
@@ -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
|
|
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 {
|
package/src/commands/feedback.ts
CHANGED
|
@@ -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 '@/
|
|
5
|
+
import { PROJECT_ROOT } from '@/project-root'
|
|
6
6
|
import { createGithubIssue } from '@/github'
|
|
7
7
|
import { frameError, frameSuccess } from '@/ui'
|
|
8
8
|
|
package/src/commands/gov.ts
CHANGED
|
@@ -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 '@/
|
|
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'
|
package/src/commands/init.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Command } from 'commander'
|
|
2
2
|
import { cliPath, cliRun } from '@/cli-run'
|
|
3
|
-
import { PROJECT_ROOT } from '@/
|
|
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'
|
package/src/commands/markdown.ts
CHANGED
|
@@ -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
|
|
73
|
-
'
|
|
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
|
|
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
|
}
|
package/src/commands/sandbox.ts
CHANGED
|
@@ -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 {
|
|
5
|
+
import { execScript } from '@/exec'
|
|
6
|
+
import { PROJECT_ROOT } from '@/project-root'
|
|
6
7
|
import {
|
|
7
8
|
assertedPercent,
|
|
8
9
|
collectCensus,
|
package/src/commands/snippets.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Command } from 'commander'
|
|
2
2
|
import { registerPassThroughVerbs } from '@/commands/pass-through'
|
|
3
|
-
import { PROJECT_ROOT } from '@/
|
|
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 '@/
|
|
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
|
|
package/src/commands/sync.ts
CHANGED
|
@@ -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 '@/
|
|
5
|
+
import { PROJECT_ROOT } from '@/project-root'
|
|
6
6
|
import {
|
|
7
7
|
buildCheckReport,
|
|
8
8
|
type CheckReport,
|
package/src/commands/tooling.ts
CHANGED
package/src/commands/wiki.ts
CHANGED
package/src/exec.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { join
|
|
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
package/src/markdown/bans.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
42
|
-
* empty: a scan with no terms finds nothing, and reporting that as a
|
|
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
|
-
/**
|
|
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
|
|
168
|
-
* empty: a scan with no terms finds nothing, and reporting that as a
|
|
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
|
|
171
|
+
async function loadStandard(
|
|
175
172
|
root: string,
|
|
176
173
|
name: string,
|
|
177
174
|
): Promise<StandardText | undefined> {
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
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
|
-
|
|
203
|
-
|
|
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
|
+
}
|