@erclx/aitk 0.57.0 → 0.59.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-autoship/SKILL.md +2 -0
- package/claude/skills/claude-docs/SKILL.md +1 -1
- package/claude/skills/claude-feature/SKILL.md +8 -55
- package/docs/agents/commands.md +1 -0
- package/docs/agents/index.md +1 -0
- package/docs/agents/records.md +54 -0
- package/docs/agents/scripting.md +21 -9
- package/docs/ai-workflow.md +4 -0
- package/governance/rules/claude/558-plan.md +22 -0
- package/governance/stacks/base.toml +3 -1
- package/package.json +1 -1
- package/scripts/core/verify.sh +55 -0
- package/src/cli.ts +4 -0
- package/src/commands/gov.ts +79 -4
- package/src/commands/records.ts +159 -0
- package/src/gov/install.ts +32 -15
- package/src/gov/list.ts +106 -0
- package/src/gov/stacks.ts +55 -6
- package/src/records/validate.ts +599 -0
- package/standards/groundwork.md +1 -0
- package/standards/index.md +1 -0
- package/standards/intake.md +1 -0
- package/standards/plan.md +145 -0
- package/standards/rule.md +9 -0
- package/standards/tasks.md +1 -0
- package/scripts/gov/list.sh +0 -234
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { $ } from 'bun'
|
|
2
|
+
import type { Command } from 'commander'
|
|
3
|
+
import {
|
|
4
|
+
type Finding,
|
|
5
|
+
isRecordKind,
|
|
6
|
+
RECORD_KINDS,
|
|
7
|
+
type ValidateOutcome,
|
|
8
|
+
validateRecords,
|
|
9
|
+
} from '@/records/validate'
|
|
10
|
+
import { intro, logError, logInfo, logStep, logWarn, outro } from '@/ui'
|
|
11
|
+
|
|
12
|
+
/** Returned when a record carries a finding, which is the gating result. */
|
|
13
|
+
const EXIT_FINDINGS = 2
|
|
14
|
+
|
|
15
|
+
interface ValidateCommandOptions {
|
|
16
|
+
readonly json?: boolean
|
|
17
|
+
readonly root?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The session-record folders are shared scratch at the main worktree root, and
|
|
22
|
+
* `git worktree list` puts that root first. Trusting the working directory would
|
|
23
|
+
* validate a linked worktree's empty folder and report it clean.
|
|
24
|
+
*/
|
|
25
|
+
async function mainWorktreeRoot(): Promise<string> {
|
|
26
|
+
const result = await $`git worktree list --porcelain`.quiet().nothrow()
|
|
27
|
+
if (result.exitCode !== 0) return process.cwd()
|
|
28
|
+
|
|
29
|
+
const line = result.stdout
|
|
30
|
+
.toString()
|
|
31
|
+
.split('\n')
|
|
32
|
+
.find((entry) => entry.startsWith('worktree '))
|
|
33
|
+
|
|
34
|
+
return line ? line.slice('worktree '.length).trim() : process.cwd()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function register(program: Command): void {
|
|
38
|
+
const records = program
|
|
39
|
+
.command('records')
|
|
40
|
+
.description('Check the gitignored session records under .claude/')
|
|
41
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
42
|
+
|
|
43
|
+
records
|
|
44
|
+
.command('validate')
|
|
45
|
+
.description('Report where a record and the standard governing it disagree')
|
|
46
|
+
.argument('<kind>', `Record folder: ${RECORD_KINDS.join(', ')}`)
|
|
47
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
48
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
49
|
+
.option('--root <path>', 'Project root, defaulting to the main worktree')
|
|
50
|
+
.addHelpText(
|
|
51
|
+
'after',
|
|
52
|
+
[
|
|
53
|
+
'',
|
|
54
|
+
'Checks:',
|
|
55
|
+
' plans filename, required sections, and the suggested-and-answer contract',
|
|
56
|
+
' groundwork README and current-state files, numbering, dating, and a half-closed track',
|
|
57
|
+
' intake overview file, numbering, dating, and the four bullets every item carries',
|
|
58
|
+
'',
|
|
59
|
+
'Exit codes:',
|
|
60
|
+
' 0 every check passed',
|
|
61
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
62
|
+
' 2 at least one record carries a finding',
|
|
63
|
+
'',
|
|
64
|
+
'It reports and never writes. Each folder is per-machine scratch with no',
|
|
65
|
+
'history behind it, so a session fixes the record the report names.',
|
|
66
|
+
'',
|
|
67
|
+
'Examples:',
|
|
68
|
+
' aitk records validate plans',
|
|
69
|
+
' aitk records validate intake --json',
|
|
70
|
+
'',
|
|
71
|
+
].join('\n'),
|
|
72
|
+
)
|
|
73
|
+
.action(async (kind: string, opts: ValidateCommandOptions) => {
|
|
74
|
+
process.exitCode = await runValidate(kind, opts)
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function runValidate(
|
|
79
|
+
kind: string,
|
|
80
|
+
opts: ValidateCommandOptions,
|
|
81
|
+
): Promise<number> {
|
|
82
|
+
const emitJson = opts.json ?? false
|
|
83
|
+
|
|
84
|
+
if (!isRecordKind(kind)) {
|
|
85
|
+
return report(
|
|
86
|
+
{
|
|
87
|
+
ok: false,
|
|
88
|
+
reason: 'unknown-kind',
|
|
89
|
+
message: `Not a record kind: ${kind}. Expected one of: ${RECORD_KINDS.join(', ')}.`,
|
|
90
|
+
},
|
|
91
|
+
emitJson,
|
|
92
|
+
process.cwd(),
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const root = opts.root ?? (await mainWorktreeRoot())
|
|
97
|
+
|
|
98
|
+
return report(await validateRecords(root, kind), emitJson, root)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function report(
|
|
102
|
+
outcome: ValidateOutcome,
|
|
103
|
+
emitJson: boolean,
|
|
104
|
+
root: string,
|
|
105
|
+
): number {
|
|
106
|
+
if (!outcome.ok) {
|
|
107
|
+
// The framed branch below already reaches stderr through logError, so the
|
|
108
|
+
// bare write is what keeps the JSON mode from reporting the reason on
|
|
109
|
+
// stdout alone.
|
|
110
|
+
if (emitJson) {
|
|
111
|
+
process.stderr.write(`${outcome.message}\n`)
|
|
112
|
+
process.stdout.write(
|
|
113
|
+
`${JSON.stringify({
|
|
114
|
+
ok: false,
|
|
115
|
+
reason: outcome.reason,
|
|
116
|
+
message: outcome.message,
|
|
117
|
+
})}\n`,
|
|
118
|
+
)
|
|
119
|
+
return 1
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
intro('aitk records validate')
|
|
123
|
+
logStep('Refused')
|
|
124
|
+
logError(outcome.message)
|
|
125
|
+
outro()
|
|
126
|
+
return 1
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (emitJson) {
|
|
130
|
+
process.stdout.write(
|
|
131
|
+
`${JSON.stringify({
|
|
132
|
+
ok: true,
|
|
133
|
+
root,
|
|
134
|
+
kind: outcome.kind,
|
|
135
|
+
records: outcome.records,
|
|
136
|
+
findings: outcome.findings,
|
|
137
|
+
})}\n`,
|
|
138
|
+
)
|
|
139
|
+
} else {
|
|
140
|
+
intro('aitk records validate')
|
|
141
|
+
logStep(outcome.kind)
|
|
142
|
+
logInfo(`${outcome.records} record(s) read`)
|
|
143
|
+
|
|
144
|
+
logStep(outcome.findings.length === 0 ? 'Clean' : 'Findings')
|
|
145
|
+
if (outcome.findings.length === 0) {
|
|
146
|
+
logInfo('every record matches the shape its standard fixes')
|
|
147
|
+
} else {
|
|
148
|
+
for (const found of outcome.findings) logWarn(describe(found))
|
|
149
|
+
}
|
|
150
|
+
outro()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return outcome.findings.length > 0 ? EXIT_FINDINGS : 0
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function describe(found: Finding): string {
|
|
157
|
+
const scope = found.record === found.subject ? '' : `${found.record}: `
|
|
158
|
+
return `${scope}${found.subject} ${found.message}`
|
|
159
|
+
}
|
package/src/gov/install.ts
CHANGED
|
@@ -31,6 +31,37 @@ export function ruleSubdir(src: string, rulesRoot: string): string {
|
|
|
31
31
|
return subdir === '.' ? '' : subdir
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Lists every rule source path under `governance/rules/`, relative to it and
|
|
36
|
+
* sorted, so a caller walking the tree and a caller resolving one name read the
|
|
37
|
+
* same order.
|
|
38
|
+
*/
|
|
39
|
+
export function listRuleSourcePaths(root: string): string[] {
|
|
40
|
+
const rulesRoot = rulesSourceDir(root)
|
|
41
|
+
if (!existsSync(rulesRoot)) return []
|
|
42
|
+
|
|
43
|
+
return [
|
|
44
|
+
...new Bun.Glob('**/*.md').scanSync({ cwd: rulesRoot, onlyFiles: true }),
|
|
45
|
+
].sort()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Maps each rule name to its source file. First path wins, so a name appearing
|
|
50
|
+
* in two subdirectories resolves deterministically rather than by whichever
|
|
51
|
+
* entry the filesystem yielded first.
|
|
52
|
+
*/
|
|
53
|
+
function indexRuleSources(root: string): Map<string, string> {
|
|
54
|
+
const rulesRoot = rulesSourceDir(root)
|
|
55
|
+
const byName = new Map<string, string>()
|
|
56
|
+
|
|
57
|
+
for (const rel of listRuleSourcePaths(root)) {
|
|
58
|
+
const name = rel.slice(rel.lastIndexOf('/') + 1, -'.md'.length)
|
|
59
|
+
if (!byName.has(name)) byName.set(name, join(rulesRoot, rel))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return byName
|
|
63
|
+
}
|
|
64
|
+
|
|
34
65
|
/**
|
|
35
66
|
* Finds each rule's source file by name across the `governance/rules/`
|
|
36
67
|
* subfolders. A rule with no source is reported rather than dropped, matching
|
|
@@ -41,21 +72,7 @@ export function lookupRules(
|
|
|
41
72
|
rules: readonly string[],
|
|
42
73
|
): RuleLookup {
|
|
43
74
|
const rulesRoot = rulesSourceDir(root)
|
|
44
|
-
const byName =
|
|
45
|
-
|
|
46
|
-
const relPaths = existsSync(rulesRoot)
|
|
47
|
-
? [
|
|
48
|
-
...new Bun.Glob('**/*.md').scanSync({
|
|
49
|
-
cwd: rulesRoot,
|
|
50
|
-
onlyFiles: true,
|
|
51
|
-
}),
|
|
52
|
-
].sort()
|
|
53
|
-
: []
|
|
54
|
-
|
|
55
|
-
for (const rel of relPaths) {
|
|
56
|
-
const name = rel.slice(rel.lastIndexOf('/') + 1, -'.md'.length)
|
|
57
|
-
if (!byName.has(name)) byName.set(name, join(rulesRoot, rel))
|
|
58
|
-
}
|
|
75
|
+
const byName = indexRuleSources(root)
|
|
59
76
|
|
|
60
77
|
const found: RuleSource[] = []
|
|
61
78
|
const missing: string[] = []
|
package/src/gov/list.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { basename, join } from 'node:path'
|
|
3
|
+
import { listRuleSourcePaths, rulesSourceDir } from '@/gov/install'
|
|
4
|
+
import {
|
|
5
|
+
expandStackEntry,
|
|
6
|
+
listGovStacks,
|
|
7
|
+
loadGovStack,
|
|
8
|
+
unreferencedRules,
|
|
9
|
+
} from '@/gov/stacks'
|
|
10
|
+
import { parseFrontmatter, readField } from '@/indexes/frontmatter'
|
|
11
|
+
|
|
12
|
+
export interface StackEntry {
|
|
13
|
+
readonly name: string
|
|
14
|
+
readonly extends: string | null
|
|
15
|
+
readonly rules: string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RuleEntry {
|
|
19
|
+
readonly name: string
|
|
20
|
+
readonly domain: string
|
|
21
|
+
readonly description: string
|
|
22
|
+
readonly paths: string[] | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface GovCatalog {
|
|
26
|
+
readonly stacks: StackEntry[]
|
|
27
|
+
readonly rules: RuleEntry[]
|
|
28
|
+
readonly unreferenced: string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Reports each stack's own entries expanded, so a folder entry reaches a
|
|
33
|
+
* consumer as the rules it stands for. `setup-gov` dedupes `--add` extras
|
|
34
|
+
* against this list, and a stack answering `core` there would re-add every
|
|
35
|
+
* rule that folder already carries.
|
|
36
|
+
*
|
|
37
|
+
* The `extends` chain is deliberately not resolved, which is what the bash
|
|
38
|
+
* did. The list shows what each stack contributes beside the parent it
|
|
39
|
+
* inherits from, rather than repeating the ancestors under every descendant.
|
|
40
|
+
*/
|
|
41
|
+
export function buildStackEntries(root: string): StackEntry[] {
|
|
42
|
+
const entries: StackEntry[] = []
|
|
43
|
+
|
|
44
|
+
for (const name of listGovStacks(root)) {
|
|
45
|
+
const stack = loadGovStack(root, name)
|
|
46
|
+
if (!stack) continue
|
|
47
|
+
|
|
48
|
+
const seen = new Set<string>()
|
|
49
|
+
const rules: string[] = []
|
|
50
|
+
|
|
51
|
+
for (const entry of stack.rules) {
|
|
52
|
+
for (const rule of expandStackEntry(root, entry)) {
|
|
53
|
+
if (seen.has(rule)) continue
|
|
54
|
+
seen.add(rule)
|
|
55
|
+
rules.push(rule)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
entries.push({ name, extends: stack.parent ?? null, rules })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return entries
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Reads the catalog straight off the source tree. A rule's domain is the
|
|
67
|
+
* subdirectory it sits in, which is the band grouping install preserves.
|
|
68
|
+
*/
|
|
69
|
+
export function buildRuleEntries(root: string): RuleEntry[] {
|
|
70
|
+
const rulesRoot = rulesSourceDir(root)
|
|
71
|
+
|
|
72
|
+
return listRuleSourcePaths(root).map((rel) => {
|
|
73
|
+
const frontmatter = parseFrontmatter(
|
|
74
|
+
readFileSync(join(rulesRoot, rel), 'utf8'),
|
|
75
|
+
)
|
|
76
|
+
const paths = frontmatter?.fields.paths
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
name: basename(rel, '.md'),
|
|
80
|
+
domain: rel.includes('/') ? rel.slice(0, rel.indexOf('/')) : '',
|
|
81
|
+
description: readField(frontmatter, 'description') ?? '',
|
|
82
|
+
paths: Array.isArray(paths)
|
|
83
|
+
? paths.filter((entry): entry is string => typeof entry === 'string')
|
|
84
|
+
: null,
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function buildGovCatalog(root: string): GovCatalog {
|
|
90
|
+
return {
|
|
91
|
+
stacks: buildStackEntries(root),
|
|
92
|
+
rules: buildRuleEntries(root),
|
|
93
|
+
unreferenced: unreferencedRules(root),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function describeStack(entry: StackEntry): string {
|
|
98
|
+
const count = `${entry.rules.length} rules`
|
|
99
|
+
return entry.extends === null
|
|
100
|
+
? `${entry.name} (${count})`
|
|
101
|
+
: `${entry.name} (extends: ${entry.extends}, ${count})`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function describeRule(entry: RuleEntry): string {
|
|
105
|
+
return `${entry.name} [${entry.domain}] ${entry.description}`
|
|
106
|
+
}
|
package/src/gov/stacks.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
-
import { join } from 'node:path'
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { basename, join } from 'node:path'
|
|
3
|
+
import { listRuleSourcePaths, rulesSourceDir } from '@/gov/install'
|
|
3
4
|
|
|
4
5
|
export interface GovStack {
|
|
5
6
|
readonly name: string
|
|
@@ -64,11 +65,33 @@ export function loadGovStack(
|
|
|
64
65
|
}
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Expands one stack entry. An entry naming a directory under
|
|
70
|
+
* `governance/rules/` resolves to every rule inside it, sorted, and any other
|
|
71
|
+
* entry resolves to itself, so a folder and a slug reach the caller as one
|
|
72
|
+
* shape rather than two the caller has to tell apart.
|
|
73
|
+
*
|
|
74
|
+
* The directory wins over a rule file of the same name. They cannot collide
|
|
75
|
+
* while `standards/rule.md` requires a numeric prefix on a rule slug, since a
|
|
76
|
+
* band folder carries none.
|
|
77
|
+
*/
|
|
78
|
+
export function expandStackEntry(root: string, entry: string): string[] {
|
|
79
|
+
const dir = join(rulesSourceDir(root), entry)
|
|
80
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) return [entry]
|
|
81
|
+
|
|
82
|
+
return [...new Bun.Glob('**/*.md').scanSync({ cwd: dir, onlyFiles: true })]
|
|
83
|
+
.sort()
|
|
84
|
+
.map((rel) => basename(rel, '.md'))
|
|
85
|
+
}
|
|
86
|
+
|
|
67
87
|
/**
|
|
68
88
|
* Walks `extends` ancestors first, then the stack's own rules, deduped by
|
|
69
89
|
* first appearance. Tooling's `resolveChain` returns full manifests nearest
|
|
70
90
|
* first and carries `skipStack` truncation, so the two walks stay separate
|
|
71
91
|
* rather than fitting one shape to both.
|
|
92
|
+
*
|
|
93
|
+
* Dedupe runs on expanded names rather than on the entries, so a stack naming
|
|
94
|
+
* a folder and an ancestor naming a rule inside it yield that rule once.
|
|
72
95
|
*/
|
|
73
96
|
export function resolveRules(root: string, stack: string): RuleResolution {
|
|
74
97
|
const rules: string[] = []
|
|
@@ -87,10 +110,12 @@ export function resolveRules(root: string, stack: string): RuleResolution {
|
|
|
87
110
|
if (missing !== undefined) return missing
|
|
88
111
|
}
|
|
89
112
|
|
|
90
|
-
for (const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
113
|
+
for (const entry of loaded.rules) {
|
|
114
|
+
for (const rule of expandStackEntry(root, entry)) {
|
|
115
|
+
if (seen.has(rule)) continue
|
|
116
|
+
seen.add(rule)
|
|
117
|
+
rules.push(rule)
|
|
118
|
+
}
|
|
94
119
|
}
|
|
95
120
|
|
|
96
121
|
return undefined
|
|
@@ -102,6 +127,30 @@ export function resolveRules(root: string, stack: string): RuleResolution {
|
|
|
102
127
|
return { ok: true, rules }
|
|
103
128
|
}
|
|
104
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Names every rule no stack reaches, sorted. A rule outside every stack still
|
|
132
|
+
* installs through `--add`, so this reports an opt-in library and an oversight
|
|
133
|
+
* alike and leaves telling them apart to the reader.
|
|
134
|
+
*
|
|
135
|
+
* A stack whose `extends` does not resolve contributes nothing rather than
|
|
136
|
+
* aborting the sweep, or one broken stack would report the whole catalog as
|
|
137
|
+
* unreferenced.
|
|
138
|
+
*/
|
|
139
|
+
export function unreferencedRules(root: string): string[] {
|
|
140
|
+
const reached = new Set<string>()
|
|
141
|
+
|
|
142
|
+
for (const stack of listGovStacks(root)) {
|
|
143
|
+
const resolution = resolveRules(root, stack)
|
|
144
|
+
if (!resolution.ok) continue
|
|
145
|
+
for (const rule of resolution.rules) reached.add(rule)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return listRuleSourcePaths(root)
|
|
149
|
+
.map((rel) => basename(rel, '.md'))
|
|
150
|
+
.filter((rule) => !reached.has(rule))
|
|
151
|
+
.sort()
|
|
152
|
+
}
|
|
153
|
+
|
|
105
154
|
/**
|
|
106
155
|
* Layers `--add` names on top of a resolved stack. The bash trimmed a single
|
|
107
156
|
* leading and trailing space per entry; trimming fully is the same result for
|