@erclx/aitk 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -13
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-address-review/SKILL.md +3 -4
- package/claude/skills/claude-autoship/SKILL.md +1 -1
- package/claude/skills/claude-diagram/SKILL.md +1 -1
- package/claude/skills/claude-docs/SKILL.md +1 -1
- package/claude/skills/claude-memory-review/SKILL.md +1 -1
- package/claude/skills/claude-pr-review/SKILL.md +1 -1
- package/claude/skills/claude-review/SKILL.md +1 -1
- package/claude/skills/claude-seed-sync/SKILL.md +1 -1
- package/claude/skills/claude-ui-test/SKILL.md +1 -1
- package/claude/skills/claude-ux-audit/SKILL.md +1 -1
- package/claude/skills/claude-worktree/SKILL.md +2 -2
- package/claude/skills/git-followup/SKILL.md +1 -1
- package/claude/skills/git-issue/SKILL.md +1 -6
- package/claude/skills/git-pr/SKILL.md +1 -6
- package/claude/skills/git-split/REQUIREMENT.md +2 -1
- package/claude/skills/git-split/SKILL.md +2 -0
- package/docs/agents.md +36 -0
- package/docs/target-projects.md +1 -1
- package/package.json +1 -1
- package/scripts/core/regen-claude-copies.sh +10 -1
- package/scripts/core/verify.sh +1 -1
- package/src/cli.ts +4 -0
- package/src/commands/comments.ts +234 -0
- package/src/commands/gov.ts +40 -0
- package/src/comments/scan.ts +338 -0
- package/src/comments/trend.ts +207 -0
- package/src/comments/vocabulary.ts +85 -0
- package/src/git-env.ts +36 -0
- package/src/git-ignore.ts +46 -0
- package/src/gov/consumed.ts +129 -0
- package/src/indexes/walk.ts +1 -29
- package/standards/index.md +1 -1
- package/standards/prose.md +14 -1
- package/standards/readme.md +15 -1
- package/standards/skill.md +14 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { $ } from 'bun'
|
|
2
|
+
import { gitEnv } from '@/git-env'
|
|
3
|
+
import {
|
|
4
|
+
countFiles,
|
|
5
|
+
isPruned,
|
|
6
|
+
type Language,
|
|
7
|
+
LANGUAGES,
|
|
8
|
+
type LanguageCount,
|
|
9
|
+
languageFor,
|
|
10
|
+
type ScanOptions,
|
|
11
|
+
type SourceFile,
|
|
12
|
+
} from '@/comments/scan'
|
|
13
|
+
|
|
14
|
+
export interface TrendPoint {
|
|
15
|
+
readonly rev: string
|
|
16
|
+
readonly date: string
|
|
17
|
+
readonly languages: readonly LanguageCount[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_POINTS = 6
|
|
21
|
+
|
|
22
|
+
interface Commit {
|
|
23
|
+
readonly rev: string
|
|
24
|
+
readonly date: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseCommits(text: string): Commit[] {
|
|
28
|
+
return text
|
|
29
|
+
.split('\n')
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.map((line) => {
|
|
32
|
+
const [rev, date] = line.split('\t')
|
|
33
|
+
return { rev, date }
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Lists the commits from `since` to HEAD, oldest first and inclusive of
|
|
39
|
+
* `since` itself.
|
|
40
|
+
*
|
|
41
|
+
* A `since..HEAD` range excludes its own boundary, which drops the reading the
|
|
42
|
+
* caller is measuring against. The whole point of a trend is the before, so
|
|
43
|
+
* the boundary commit is prepended rather than left to the range operator.
|
|
44
|
+
*
|
|
45
|
+
* `--first-parent` keeps a merged branch's own commits out of the sample, so
|
|
46
|
+
* evenly spaced points land on the trunk rather than clustering inside
|
|
47
|
+
* whichever feature happened to carry the most commits.
|
|
48
|
+
*/
|
|
49
|
+
export async function listCommits(
|
|
50
|
+
root: string,
|
|
51
|
+
since: string,
|
|
52
|
+
): Promise<Commit[]> {
|
|
53
|
+
const boundary =
|
|
54
|
+
await $`git -C ${root} log -1 --format=%H%x09%ad --date=short ${since}`
|
|
55
|
+
.env(gitEnv())
|
|
56
|
+
.quiet()
|
|
57
|
+
.nothrow()
|
|
58
|
+
|
|
59
|
+
if (boundary.exitCode !== 0) return []
|
|
60
|
+
|
|
61
|
+
const range =
|
|
62
|
+
await $`git -C ${root} log --first-parent --reverse --format=%H%x09%ad --date=short ${`${since}..HEAD`}`
|
|
63
|
+
.env(gitEnv())
|
|
64
|
+
.quiet()
|
|
65
|
+
.nothrow()
|
|
66
|
+
|
|
67
|
+
const commits = parseCommits(boundary.text())
|
|
68
|
+
if (range.exitCode === 0) commits.push(...parseCommits(range.text()))
|
|
69
|
+
|
|
70
|
+
return commits
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Picks evenly spaced commits from `commits`, always keeping the newest.
|
|
75
|
+
*
|
|
76
|
+
* Sample selection matters more than sample size here. The reading the
|
|
77
|
+
* comment-discipline track needed came from four points spanning six months,
|
|
78
|
+
* so spacing across the window is what the arm optimizes for rather than
|
|
79
|
+
* density of coverage.
|
|
80
|
+
*/
|
|
81
|
+
export function spaceEvenly(
|
|
82
|
+
commits: readonly Commit[],
|
|
83
|
+
points: number,
|
|
84
|
+
): Commit[] {
|
|
85
|
+
if (commits.length <= points) return [...commits]
|
|
86
|
+
if (points <= 1) return [commits[commits.length - 1]]
|
|
87
|
+
|
|
88
|
+
const picked: Commit[] = []
|
|
89
|
+
const step = (commits.length - 1) / (points - 1)
|
|
90
|
+
|
|
91
|
+
for (let index = 0; index < points; index++) {
|
|
92
|
+
picked.push(commits[Math.round(index * step)])
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return picked
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Reads every scannable blob at `rev` without checking anything out.
|
|
100
|
+
*
|
|
101
|
+
* `ls-tree` names the blobs and `cat-file --batch` streams their contents in
|
|
102
|
+
* one process, so a six-point trend costs six subprocesses rather than one per
|
|
103
|
+
* file per commit. Contents arrive as bytes, so the batch stream is walked by
|
|
104
|
+
* byte offset rather than split as text.
|
|
105
|
+
*/
|
|
106
|
+
export async function readRevision(
|
|
107
|
+
root: string,
|
|
108
|
+
rev: string,
|
|
109
|
+
languages: readonly Language[] = LANGUAGES,
|
|
110
|
+
): Promise<SourceFile[]> {
|
|
111
|
+
const listed = await $`git -C ${root} ls-tree -r -z ${rev}`
|
|
112
|
+
.env(gitEnv())
|
|
113
|
+
.quiet()
|
|
114
|
+
.nothrow()
|
|
115
|
+
if (listed.exitCode !== 0) return []
|
|
116
|
+
|
|
117
|
+
const wanted: { oid: string; path: string }[] = []
|
|
118
|
+
|
|
119
|
+
for (const entry of listed.text().split('\0')) {
|
|
120
|
+
if (!entry) continue
|
|
121
|
+
const [meta, path] = entry.split('\t')
|
|
122
|
+
if (!path) continue
|
|
123
|
+
|
|
124
|
+
const [, type, oid] = meta.split(/\s+/)
|
|
125
|
+
if (type !== 'blob') continue
|
|
126
|
+
if (isPruned(path)) continue
|
|
127
|
+
|
|
128
|
+
const language = languageFor(path)
|
|
129
|
+
if (!language || !languages.includes(language)) continue
|
|
130
|
+
|
|
131
|
+
wanted.push({ oid, path })
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (wanted.length === 0) return []
|
|
135
|
+
|
|
136
|
+
const stdin = Buffer.from(`${wanted.map(({ oid }) => oid).join('\n')}\n`)
|
|
137
|
+
const batch = await $`git -C ${root} cat-file --batch < ${stdin}`
|
|
138
|
+
.env(gitEnv())
|
|
139
|
+
.quiet()
|
|
140
|
+
.nothrow()
|
|
141
|
+
|
|
142
|
+
if (batch.exitCode !== 0) return []
|
|
143
|
+
|
|
144
|
+
return parseBatch(Buffer.from(batch.arrayBuffer()), wanted)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Walks `git cat-file --batch` output, which frames each object as
|
|
149
|
+
* `<oid> SP <type> SP <size> LF <contents> LF`. The size header is the only
|
|
150
|
+
* safe way to find the next record, since contents may hold anything.
|
|
151
|
+
*/
|
|
152
|
+
function parseBatch(
|
|
153
|
+
buffer: Buffer,
|
|
154
|
+
wanted: readonly { oid: string; path: string }[],
|
|
155
|
+
): SourceFile[] {
|
|
156
|
+
const files: SourceFile[] = []
|
|
157
|
+
let offset = 0
|
|
158
|
+
|
|
159
|
+
for (const { path } of wanted) {
|
|
160
|
+
const headerEnd = buffer.indexOf(0x0a, offset)
|
|
161
|
+
if (headerEnd === -1) break
|
|
162
|
+
|
|
163
|
+
const header = buffer.toString('utf8', offset, headerEnd)
|
|
164
|
+
const size = Number(header.split(' ')[2])
|
|
165
|
+
if (!Number.isFinite(size)) break
|
|
166
|
+
|
|
167
|
+
const start = headerEnd + 1
|
|
168
|
+
files.push({ path, text: buffer.toString('utf8', start, start + size) })
|
|
169
|
+
offset = start + size + 1
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return files
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Counts one revision's tree, reusing the same pass the snapshot arm runs. */
|
|
176
|
+
export async function scanRevision(
|
|
177
|
+
root: string,
|
|
178
|
+
rev: string,
|
|
179
|
+
opts: ScanOptions = {},
|
|
180
|
+
): Promise<LanguageCount[]> {
|
|
181
|
+
const files = await readRevision(root, rev, opts.languages ?? LANGUAGES)
|
|
182
|
+
return countFiles(files, opts)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface TrendOptions extends ScanOptions {
|
|
186
|
+
readonly since: string
|
|
187
|
+
readonly points?: number
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Recomputes the series from git rather than reading a stored ledger. */
|
|
191
|
+
export async function trend(
|
|
192
|
+
root: string,
|
|
193
|
+
opts: TrendOptions,
|
|
194
|
+
): Promise<TrendPoint[]> {
|
|
195
|
+
const commits = await listCommits(root, opts.since)
|
|
196
|
+
const sampled = spaceEvenly(commits, opts.points ?? DEFAULT_POINTS)
|
|
197
|
+
|
|
198
|
+
// Each point is an independent pair of git reads, and the sample is bounded
|
|
199
|
+
// by `points`, so the whole series costs one revision's wall clock.
|
|
200
|
+
return Promise.all(
|
|
201
|
+
sampled.map(async ({ rev, date }) => ({
|
|
202
|
+
rev,
|
|
203
|
+
date,
|
|
204
|
+
languages: await scanRevision(root, rev, opts),
|
|
205
|
+
})),
|
|
206
|
+
)
|
|
207
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The heading a rule carries to publish its degradation list.
|
|
7
|
+
*
|
|
8
|
+
* Discovery is anchored on this rather than on a filename, because governance
|
|
9
|
+
* rules are numbered and a renumber would silently empty the vocabulary while
|
|
10
|
+
* the sweep kept reporting clean. A heading survives the rename.
|
|
11
|
+
*/
|
|
12
|
+
export const VOCABULARY_HEADING = '## Degradation vocabulary'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Roots searched in order. The installed copy wins over the toolkit source, so
|
|
16
|
+
* a target project measures against the rule it actually has rather than one
|
|
17
|
+
* only the toolkit carries.
|
|
18
|
+
*/
|
|
19
|
+
const RULE_ROOTS = ['.claude/rules', 'governance/rules']
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Absent is a distinct state from empty.
|
|
23
|
+
*
|
|
24
|
+
* A sweep with no vocabulary finds nothing, and reporting that as zero hits
|
|
25
|
+
* claims the codebase is clean when nothing was actually looked for. The
|
|
26
|
+
* command reports it as skipped instead.
|
|
27
|
+
*/
|
|
28
|
+
export type Vocabulary =
|
|
29
|
+
| {
|
|
30
|
+
readonly kind: 'loaded'
|
|
31
|
+
readonly source: string
|
|
32
|
+
readonly terms: string[]
|
|
33
|
+
}
|
|
34
|
+
| { readonly kind: 'absent' }
|
|
35
|
+
|
|
36
|
+
/** Pulls the backticked terms out of the bullets under the vocabulary heading. */
|
|
37
|
+
export function parseVocabulary(markdown: string): string[] | undefined {
|
|
38
|
+
const lines = markdown.split('\n')
|
|
39
|
+
const start = lines.findIndex((line) => line.trim() === VOCABULARY_HEADING)
|
|
40
|
+
if (start === -1) return undefined
|
|
41
|
+
|
|
42
|
+
const terms: string[] = []
|
|
43
|
+
|
|
44
|
+
for (const line of lines.slice(start + 1)) {
|
|
45
|
+
if (line.startsWith('## ')) break
|
|
46
|
+
for (const match of line.matchAll(/`([^`]+)`/g)) {
|
|
47
|
+
const term = match[1].trim()
|
|
48
|
+
if (term && !terms.includes(term)) terms.push(term)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return terms
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Finds the rule publishing the vocabulary under `root`.
|
|
57
|
+
*
|
|
58
|
+
* Reading the list out of the rule rather than hardcoding it is what keeps one
|
|
59
|
+
* definition when the rule installs into a target, the same way
|
|
60
|
+
* `.claude/hooks/standards-audit.sh` reads its bans out of `prose.md`.
|
|
61
|
+
*/
|
|
62
|
+
export async function loadVocabulary(root: string): Promise<Vocabulary> {
|
|
63
|
+
for (const ruleRoot of RULE_ROOTS) {
|
|
64
|
+
const dir = resolve(root, ruleRoot)
|
|
65
|
+
if (!existsSync(dir)) continue
|
|
66
|
+
|
|
67
|
+
const paths: string[] = []
|
|
68
|
+
for await (const rel of new Bun.Glob('**/*.md').scan({
|
|
69
|
+
cwd: dir,
|
|
70
|
+
onlyFiles: true,
|
|
71
|
+
})) {
|
|
72
|
+
paths.push(rel)
|
|
73
|
+
}
|
|
74
|
+
paths.sort()
|
|
75
|
+
|
|
76
|
+
for (const rel of paths) {
|
|
77
|
+
const parsed = parseVocabulary(await readFile(resolve(dir, rel), 'utf8'))
|
|
78
|
+
if (parsed && parsed.length > 0) {
|
|
79
|
+
return { kind: 'loaded', source: `${ruleRoot}/${rel}`, terms: parsed }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { kind: 'absent' }
|
|
85
|
+
}
|
package/src/git-env.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repository-resolution variables git reads from the environment.
|
|
3
|
+
*
|
|
4
|
+
* A git hook exports these into every process it runs, and they take
|
|
5
|
+
* precedence over `-C`. A command scoped to a subtree then silently reads
|
|
6
|
+
* whatever the hook's repository points at and reports figures for a tree
|
|
7
|
+
* nobody asked about, which is worse than failing because the output looks
|
|
8
|
+
* ordinary. `GIT_PREFIX` is here for the same reason: it re-anchors a relative
|
|
9
|
+
* pathspec to the directory the hook was invoked from.
|
|
10
|
+
*/
|
|
11
|
+
const RESOLUTION_VARS = [
|
|
12
|
+
'GIT_DIR',
|
|
13
|
+
'GIT_WORK_TREE',
|
|
14
|
+
'GIT_COMMON_DIR',
|
|
15
|
+
'GIT_INDEX_FILE',
|
|
16
|
+
'GIT_OBJECT_DIRECTORY',
|
|
17
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
18
|
+
'GIT_NAMESPACE',
|
|
19
|
+
'GIT_PREFIX',
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Returns the ambient environment with git's repository-resolution variables
|
|
24
|
+
* removed, so `git -C <path>` resolves against `<path>` and nothing else.
|
|
25
|
+
*
|
|
26
|
+
* Read per call rather than captured at module load. A long-lived process can
|
|
27
|
+
* have these set after import, and a snapshot would then hand git the very
|
|
28
|
+
* variables this exists to strip.
|
|
29
|
+
*/
|
|
30
|
+
export function gitEnv(): Record<string, string> {
|
|
31
|
+
return Object.fromEntries(
|
|
32
|
+
Object.entries(process.env).filter(
|
|
33
|
+
([key, value]) => value !== undefined && !RESOLUTION_VARS.includes(key),
|
|
34
|
+
),
|
|
35
|
+
) as Record<string, string>
|
|
36
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import { $ } from 'bun'
|
|
3
|
+
import { gitEnv } from '@/git-env'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reports which of `candidates` git ignores under `root`.
|
|
7
|
+
*
|
|
8
|
+
* Batched through one `check-ignore --stdin` rather than a call per path,
|
|
9
|
+
* since a whole-repo walk hands this thousands of candidates. Outside a git
|
|
10
|
+
* repo nothing is ignored, so a caller's segment prune becomes the only filter
|
|
11
|
+
* that applies.
|
|
12
|
+
*/
|
|
13
|
+
export async function listIgnored(
|
|
14
|
+
root: string,
|
|
15
|
+
candidates: string[],
|
|
16
|
+
): Promise<Set<string>> {
|
|
17
|
+
if (candidates.length === 0) return new Set()
|
|
18
|
+
|
|
19
|
+
const isRepo = await $`git -C ${root} rev-parse --git-dir`
|
|
20
|
+
.env(gitEnv())
|
|
21
|
+
.quiet()
|
|
22
|
+
.nothrow()
|
|
23
|
+
.then((result) => result.exitCode === 0)
|
|
24
|
+
|
|
25
|
+
if (!isRepo) return new Set()
|
|
26
|
+
|
|
27
|
+
const stdin = Buffer.from(`${candidates.join('\n')}\n`)
|
|
28
|
+
|
|
29
|
+
const result = await $`git -C ${root} check-ignore --stdin < ${stdin}`
|
|
30
|
+
.env(gitEnv())
|
|
31
|
+
.quiet()
|
|
32
|
+
.nothrow()
|
|
33
|
+
|
|
34
|
+
// Exit 1 means nothing matched, which is a clean result rather than a
|
|
35
|
+
// failure. Anything above that is a real error and degrades to "ignores
|
|
36
|
+
// nothing" so a broken git never silently shrinks the scanned set.
|
|
37
|
+
if (result.exitCode > 1) return new Set()
|
|
38
|
+
|
|
39
|
+
return new Set(
|
|
40
|
+
result
|
|
41
|
+
.text()
|
|
42
|
+
.split('\n')
|
|
43
|
+
.filter(Boolean)
|
|
44
|
+
.map((path) => resolve(root, path)),
|
|
45
|
+
)
|
|
46
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { rm } from 'node:fs/promises'
|
|
3
|
+
import { basename, join } from 'node:path'
|
|
4
|
+
import {
|
|
5
|
+
installRules,
|
|
6
|
+
installedRulesDir,
|
|
7
|
+
lookupRules,
|
|
8
|
+
type RuleSource,
|
|
9
|
+
ruleSubdir,
|
|
10
|
+
} from '@/gov/install'
|
|
11
|
+
import { mergeExtraRules, resolveRules } from '@/gov/stacks'
|
|
12
|
+
|
|
13
|
+
export const RECORD_REL = join('internal', 'governance.toml')
|
|
14
|
+
|
|
15
|
+
const INTERNAL_RULES_REL = join('internal', 'rules')
|
|
16
|
+
|
|
17
|
+
/** The stack a repository installs into its own `.claude/rules/`. */
|
|
18
|
+
export interface ConsumedRecord {
|
|
19
|
+
readonly stack: string
|
|
20
|
+
readonly add: readonly string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ConsumedResult =
|
|
24
|
+
| { readonly ok: true; readonly installed: readonly string[] }
|
|
25
|
+
| { readonly ok: false; readonly reason: string }
|
|
26
|
+
|
|
27
|
+
export function consumedRecordPath(root: string): string {
|
|
28
|
+
return join(root, RECORD_REL)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function internalRulesDir(root: string): string {
|
|
32
|
+
return join(root, INTERNAL_RULES_REL)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Reads the record naming the consumed stack. A file with no `stack` reads the
|
|
37
|
+
* same as no file at all, since neither tells the producer what to install and
|
|
38
|
+
* the caller's message covers both.
|
|
39
|
+
*/
|
|
40
|
+
export function readConsumedRecord(root: string): ConsumedRecord | undefined {
|
|
41
|
+
const path = consumedRecordPath(root)
|
|
42
|
+
if (!existsSync(path)) return undefined
|
|
43
|
+
|
|
44
|
+
const parsed = Bun.TOML.parse(readFileSync(path, 'utf8')) as Record<
|
|
45
|
+
string,
|
|
46
|
+
unknown
|
|
47
|
+
>
|
|
48
|
+
const stack = typeof parsed.stack === 'string' ? parsed.stack : ''
|
|
49
|
+
if (stack === '') return undefined
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
stack,
|
|
53
|
+
add: Array.isArray(parsed.add)
|
|
54
|
+
? parsed.add.filter(
|
|
55
|
+
(rule): rule is string => typeof rule === 'string' && rule !== '',
|
|
56
|
+
)
|
|
57
|
+
: [],
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Lists rules authored under `internal/rules/`. These govern toolkit authoring
|
|
63
|
+
* against paths only this repository has, so they install into the consumed
|
|
64
|
+
* copy without ever entering `governance/rules/`, which ships to targets.
|
|
65
|
+
*/
|
|
66
|
+
export function listInternalRules(root: string): RuleSource[] {
|
|
67
|
+
const dir = internalRulesDir(root)
|
|
68
|
+
if (!existsSync(dir)) return []
|
|
69
|
+
|
|
70
|
+
return [...new Bun.Glob('**/*.md').scanSync({ cwd: dir, onlyFiles: true })]
|
|
71
|
+
.sort()
|
|
72
|
+
.map((rel) => {
|
|
73
|
+
const src = join(dir, rel)
|
|
74
|
+
return { rule: basename(rel, '.md'), src, subdir: ruleSubdir(src, dir) }
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Rebuilds a repository's own `.claude/rules/` from its record. Unlike
|
|
80
|
+
* `gov install` and `gov sync`, this runs against the toolkit root on purpose:
|
|
81
|
+
* those two refuse it because a target's rules are the operator's to edit,
|
|
82
|
+
* while this destination is produced output that happens to live beside its
|
|
83
|
+
* source.
|
|
84
|
+
*/
|
|
85
|
+
export async function regenConsumedRules(
|
|
86
|
+
root: string,
|
|
87
|
+
): Promise<ConsumedResult> {
|
|
88
|
+
const record = readConsumedRecord(root)
|
|
89
|
+
if (record === undefined) {
|
|
90
|
+
return { ok: false, reason: `No stack recorded at ${RECORD_REL}` }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const resolution = resolveRules(root, record.stack)
|
|
94
|
+
if (!resolution.ok) {
|
|
95
|
+
return { ok: false, reason: `Stack not found: ${resolution.missingStack}` }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const { found, missing } = lookupRules(
|
|
99
|
+
root,
|
|
100
|
+
mergeExtraRules(resolution.rules, record.add.join(',')),
|
|
101
|
+
)
|
|
102
|
+
if (missing.length > 0) {
|
|
103
|
+
return { ok: false, reason: `No source for: ${missing.join(', ')}` }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const internal = listInternalRules(root)
|
|
107
|
+
const stackRules = new Set(found.map((entry) => entry.rule))
|
|
108
|
+
const shadowed = internal
|
|
109
|
+
.filter((entry) => stackRules.has(entry.rule))
|
|
110
|
+
.map((entry) => entry.rule)
|
|
111
|
+
if (shadowed.length > 0) {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
reason: `Internal rules shadow stack rules: ${shadowed.join(', ')}`,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Clearing first is what makes a rule the record stopped naming disappear.
|
|
119
|
+
// Copying over the destination would leave it behind as an unsourced file,
|
|
120
|
+
// which is the state this producer exists to end.
|
|
121
|
+
await rm(installedRulesDir(root), { recursive: true, force: true })
|
|
122
|
+
|
|
123
|
+
const installed = [
|
|
124
|
+
...(await installRules(found, root)),
|
|
125
|
+
...(await installRules(internal, root)),
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
return { ok: true, installed: installed.sort() }
|
|
129
|
+
}
|
package/src/indexes/walk.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, statSync } from 'node:fs'
|
|
2
2
|
import { dirname, resolve } from 'node:path'
|
|
3
|
-
import {
|
|
3
|
+
import { listIgnored } from '@/git-ignore'
|
|
4
4
|
|
|
5
5
|
const INDEX_FILE = 'index.md'
|
|
6
6
|
|
|
@@ -42,34 +42,6 @@ export async function listIndexes(root: string): Promise<string[]> {
|
|
|
42
42
|
return candidates.filter((path) => !ignored.has(path))
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
async function listIgnored(
|
|
46
|
-
root: string,
|
|
47
|
-
candidates: string[],
|
|
48
|
-
): Promise<Set<string>> {
|
|
49
|
-
const isRepo = await $`git -C ${root} rev-parse --git-dir`
|
|
50
|
-
.quiet()
|
|
51
|
-
.nothrow()
|
|
52
|
-
.then((result) => result.exitCode === 0)
|
|
53
|
-
|
|
54
|
-
if (!isRepo) return new Set()
|
|
55
|
-
|
|
56
|
-
const stdin = Buffer.from(`${candidates.join('\n')}\n`)
|
|
57
|
-
|
|
58
|
-
const result = await $`git -C ${root} check-ignore --stdin < ${stdin}`
|
|
59
|
-
.quiet()
|
|
60
|
-
.nothrow()
|
|
61
|
-
|
|
62
|
-
if (result.exitCode > 1) return new Set()
|
|
63
|
-
|
|
64
|
-
return new Set(
|
|
65
|
-
result
|
|
66
|
-
.text()
|
|
67
|
-
.split('\n')
|
|
68
|
-
.filter(Boolean)
|
|
69
|
-
.map((path) => resolve(root, path)),
|
|
70
|
-
)
|
|
71
|
-
}
|
|
72
|
-
|
|
73
45
|
/**
|
|
74
46
|
* Reports whether git ignores `path`.
|
|
75
47
|
*
|
package/standards/index.md
CHANGED
|
@@ -12,7 +12,7 @@ Reference docs for consistent authoring across the toolkit and target projects.
|
|
|
12
12
|
- [Design reference](design.md): Shape and content rules for .claude/DESIGN.md
|
|
13
13
|
- [Diagram reference](diagrams.md): Shape and content rules for .claude/diagrams/<kind>.md files
|
|
14
14
|
- [Prose reference](prose.md): Voice, structure, formatting, and language rules for reference markdown
|
|
15
|
-
- [Readme reference](readme.md): Readme structure and content conventions
|
|
15
|
+
- [Readme reference](readme.md): Readme voice, structure, and content conventions
|
|
16
16
|
- [Requirements reference](requirements.md): Shape and content rules for .claude/REQUIREMENTS.md
|
|
17
17
|
- [Governance rule reference](rule.md): Rule frontmatter, body shape, and voice for .claude/rules files
|
|
18
18
|
- [Claude skill reference](skill.md): Claude skill structure and authoring rules
|
package/standards/prose.md
CHANGED
|
@@ -5,7 +5,7 @@ description: Voice, structure, formatting, and language rules for reference mark
|
|
|
5
5
|
|
|
6
6
|
# Prose reference
|
|
7
7
|
|
|
8
|
-
Applies to markdown reference docs, READMEs, and inline documentation in repos. It is the default voice for `.md` files and yields to any surface with its own voice, such as blogs, emails, changelogs, or commit messages. It also yields wherever another standard states the voice for the surface it governs, which is how a surface claims the exemption without this file having to name it. The yield covers voice alone. Punctuation, formatting, and language rules below stay in force on every surface,
|
|
8
|
+
Applies to markdown reference docs, READMEs, and inline documentation in repos. It is the default voice for `.md` files and yields to any surface with its own voice, such as blogs, emails, changelogs, or commit messages. It also yields wherever another standard states the voice for the surface it governs, which is how a surface claims the exemption without this file having to name it. The yield covers voice alone. Punctuation, formatting, and language rules below stay in force on every surface, including the surfaces no automated check reaches, which is what the scan below is for.
|
|
9
9
|
|
|
10
10
|
## Voice
|
|
11
11
|
|
|
@@ -74,6 +74,19 @@ Applies to markdown reference docs, READMEs, and inline documentation in repos.
|
|
|
74
74
|
- Do not address the reader as a participant (`Let's`, `Here's`, `Here are`). State the content directly.
|
|
75
75
|
- Commit to a position. Do not hedge in clusters (`It might be worth considering`) or use false balance (`While X is true, Y is also important`). Recommend, or state the tradeoff.
|
|
76
76
|
|
|
77
|
+
## Banned-character scan
|
|
78
|
+
|
|
79
|
+
Wherever text leaves through a channel no automated check covers, the author is the only gate and runs this scan. Text sent to another service, written to a path the project's checks exclude, and text inside a fenced block are the usual cases. The surface that publishes the text is what knows which gap applies, so it names its own rather than reading one here.
|
|
80
|
+
|
|
81
|
+
Scan the drafted text and rewrite each occurrence:
|
|
82
|
+
|
|
83
|
+
- `—` (em dash): split into two sentences, or use a comma
|
|
84
|
+
- `;` (semicolon): split into two sentences
|
|
85
|
+
|
|
86
|
+
Restructure the sentence rather than substituting the character. A semicolon swapped for a period leaves both clauses in the order the semicolon chose, which is the shape the ban exists to remove.
|
|
87
|
+
|
|
88
|
+
Run the scan as an explicit step against the finished text. Having read this file before drafting does not cover it, because the check has to happen after the text exists.
|
|
89
|
+
|
|
77
90
|
## Frontmatter descriptions
|
|
78
91
|
|
|
79
92
|
When frontmatter carries a short `title` or `description` used for catalog display:
|
package/standards/readme.md
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Readme reference
|
|
3
|
-
description: Readme structure and content conventions
|
|
3
|
+
description: Readme voice, structure, and content conventions
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Readme reference
|
|
7
7
|
|
|
8
|
+
Applies to every `README.md`. The `## Voice` section states the voice for a repository's root README, so `prose.md` yields to it there. The yield covers voice alone. The punctuation bans, spelling rules, banned words, and formatting rules in `prose.md` stay in force, so the warmer register ships with the same hygiene: no em dashes, no semicolons, no buzzwords.
|
|
9
|
+
|
|
10
|
+
The reader is what changes. Reference prose serves someone who already committed to the project and is scanning for a fact. A root README meets someone deciding whether to commit at all, and it is often the only file they read.
|
|
11
|
+
|
|
12
|
+
## Voice
|
|
13
|
+
|
|
14
|
+
Scoped to the README at a repository root. A nested README documenting a folder, a harness, or an internal tool keeps the reference voice in `prose.md`, since its reader has already committed and arrived looking for a fact.
|
|
15
|
+
|
|
16
|
+
- Address the reader in second person. First-person plural needs an authoring organization as its antecedent, so a single-maintainer project has none to use.
|
|
17
|
+
- Use contractions wherever the sentence reads better for one. Do not force them in.
|
|
18
|
+
- Write with a point of view. State what the project chose and why, not a neutral survey of the options it passed over.
|
|
19
|
+
- Ground a claim in something concrete rather than an adjective. A command, a number, or a named constraint carries more than a description of quality.
|
|
20
|
+
- Be honest about limits. Naming what the project does not do reads as more credible, not less.
|
|
21
|
+
|
|
8
22
|
## Structure
|
|
9
23
|
|
|
10
24
|
- H1 title, H2 major sections, H3 subsections. Maintain proper hierarchy for GitHub's auto-generated table of contents.
|
package/standards/skill.md
CHANGED
|
@@ -173,6 +173,18 @@ Without this skill, a session <observed failure>, <observed failure>.
|
|
|
173
173
|
- When a skill gathers user input or pre-seeds a template, attach a concrete proposed default to every question, derived from project context. Accept "use defaults" as a bulk-confirm.
|
|
174
174
|
- Separate correctness axes (routing, sourcing, escalation, decline) from shape axes (line count, formatting, variant sprawl) when tuning a skill. Tighten only on correctness regressions. Do not convert soft caps to hard caps for aesthetic drift when correctness passes.
|
|
175
175
|
|
|
176
|
+
### Deriving the branch slug
|
|
177
|
+
|
|
178
|
+
Run `git branch --show-current` and replace every `/` with `-`. The result is `<slug>`. Anything reading a branch-derived name uses this transform, so two skills cannot spell it differently.
|
|
179
|
+
|
|
180
|
+
A skill that persists output under `.claude/` carries the slug in the filename, which is what keeps parallel worktrees from overwriting each other's output.
|
|
181
|
+
|
|
182
|
+
The empty result is a detached HEAD, and the skill picks one of three responses rather than inheriting a default. State the choice in the body, since the transform is shared and this is not.
|
|
183
|
+
|
|
184
|
+
- Fall back to `latest`, so a read-only pass still writes somewhere predictable
|
|
185
|
+
- Stop, when the skill commits or opens a pull request. There is no branch to put the work on, so `latest` would bury the problem instead of reporting it. State the stop in the skill's guards.
|
|
186
|
+
- Fall through to the next source, when the slug is one candidate among several rather than the name of an output file
|
|
187
|
+
|
|
176
188
|
## Scripts
|
|
177
189
|
|
|
178
190
|
- Use `scripts/` for operations that must be deterministic or repetitive
|
|
@@ -199,6 +211,8 @@ A standard reaches a skill by two routes, and a body that names only the first b
|
|
|
199
211
|
- State the fallback once per body, at the site that reads the standard. A later mention of a standard the body already read stays bare, since repeating the fallback at every mention is noise rather than instruction.
|
|
200
212
|
- A guard on a standard's presence names the file and tests both paths before it stops. A guard that tests only `.claude/standards/` refuses to run in a plugin-only project that has the file, and a guard that tests the directory passes in the partial-install case it exists to catch.
|
|
201
213
|
- Use `${CLAUDE_SKILL_DIR}`, never a bare `../../` and never `${CLAUDE_PLUGIN_ROOT}`. Only `${CLAUDE_SKILL_DIR}` is expanded before the body reaches the model. The other two leave the model to infer a base path, which it may resolve against the session cwd instead.
|
|
214
|
+
- Cite a shared procedure, never restate it. A procedure two or more skills execute gets one definition in a standard and a citation in each body. Nothing catches a restatement that drifts, because the drift assertion covers generated copies and a hand-written one is not generated, so the guarantee is only that a single definition exists to correct.
|
|
215
|
+
- Keep the trigger in the body and the procedure in the standard. The citing skill states when the procedure runs and what it runs against, since that varies per skill and the standard cannot know it.
|
|
202
216
|
|
|
203
217
|
## Invocation
|
|
204
218
|
|