@erclx/aitk 0.108.0 → 0.110.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-orchestrate/REQUIREMENT.md +1 -3
- package/claude/skills/claude-orchestrate/SKILL.md +10 -18
- package/claude/skills/claude-pr-review/REQUIREMENT.md +1 -1
- package/claude/skills/claude-pr-review/SKILL.md +6 -6
- package/claude/skills/migration-superseded/REQUIREMENT.md +1 -1
- package/claude/skills/migration-superseded/SKILL.md +1 -1
- package/docs/agents/audits.md +88 -0
- package/docs/agents/commands.md +3 -0
- package/docs/agents/index.md +1 -0
- package/docs/agents/scripting.md +13 -9
- package/docs/ai-workflow.md +2 -3
- package/docs/operating-model.md +18 -19
- package/package.json +1 -1
- package/scripts/core/verify.sh +74 -6
- package/src/audits/baseline.ts +194 -0
- package/src/audits/catalog.ts +464 -0
- package/src/audits/run.ts +202 -0
- package/src/cli.ts +4 -0
- package/src/commands/audits.ts +342 -0
- package/standards/requirements.md +2 -2
- package/standards/tasks.md +4 -3
- package/claude/skills/claude-roadmap/REQUIREMENT.md +0 -41
- package/claude/skills/claude-roadmap/SKILL.md +0 -61
- package/claude/skills/claude-roadmap/references/roadmap.md +0 -62
- package/standards/bundled/roadmap.md +0 -62
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { execa } from 'execa'
|
|
3
|
+
import type { Delta } from '@/audits/baseline'
|
|
4
|
+
import { type AuditResult, type AuditSpec, classify } from '@/audits/catalog'
|
|
5
|
+
import { gitEnv } from '@/git-env'
|
|
6
|
+
import { PROJECT_ROOT } from '@/project-root'
|
|
7
|
+
|
|
8
|
+
/** At least one audit reported a finding that is a fact. */
|
|
9
|
+
export const EXIT_FINDING = 2
|
|
10
|
+
|
|
11
|
+
/** At least one audit did not report, so the run measured less than the set. */
|
|
12
|
+
export const EXIT_UNMEASURED = 3
|
|
13
|
+
|
|
14
|
+
export type Verdict =
|
|
15
|
+
/** Every audit reported and every count it produced was zero. */
|
|
16
|
+
| 'clean'
|
|
17
|
+
/** Every audit reported and at least one carries a judgment finding. */
|
|
18
|
+
| 'reported'
|
|
19
|
+
/** At least one audit carries a finding that is a fact. */
|
|
20
|
+
| 'findings'
|
|
21
|
+
/** At least one audit did not report, so this is not a pass. */
|
|
22
|
+
| 'incomplete'
|
|
23
|
+
|
|
24
|
+
export interface SpawnResult {
|
|
25
|
+
readonly exitCode: number
|
|
26
|
+
readonly stdout: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type Spawn = (spec: AuditSpec) => Promise<SpawnResult>
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Runs each verb out of the checkout this CLI is executing from.
|
|
33
|
+
*
|
|
34
|
+
* `process.execPath` and the resolved `cli.ts` rather than a bare `aitk`, for
|
|
35
|
+
* the reason `verify.sh` already names: a globally installed binary resolves to
|
|
36
|
+
* the main checkout no matter which worktree is running, so the aggregate would
|
|
37
|
+
* measure the wrong tree and report a pass over a branch it never read.
|
|
38
|
+
*
|
|
39
|
+
* `reject: false` because a findings exit is the ordinary outcome for half of
|
|
40
|
+
* these verbs, and a throw there would be an error path for a working check.
|
|
41
|
+
*/
|
|
42
|
+
export function spawnAudit(root: string): Spawn {
|
|
43
|
+
const cli = join(PROJECT_ROOT, 'src', 'cli.ts')
|
|
44
|
+
|
|
45
|
+
return async (spec) => {
|
|
46
|
+
const result = await execa(process.execPath, [cli, ...spec.argv], {
|
|
47
|
+
cwd: root,
|
|
48
|
+
reject: false,
|
|
49
|
+
// `gitEnv` already returns the whole ambient environment minus git's
|
|
50
|
+
// resolution variables, so it replaces the environment rather than
|
|
51
|
+
// extending it. Spreading `process.env` alongside would put back the very
|
|
52
|
+
// variables it strips, and a hook's `GIT_DIR` would then point every
|
|
53
|
+
// history-reading verb at a repository nobody asked about.
|
|
54
|
+
env: { ...gitEnv(), AITK_NON_INTERACTIVE: '1' },
|
|
55
|
+
extendEnv: false,
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
return { exitCode: result.exitCode ?? 1, stdout: result.stdout }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Runs every audit and reads each one's own record shape.
|
|
64
|
+
*
|
|
65
|
+
* The verbs walk separate trees and share no state, so they run together
|
|
66
|
+
* rather than in sequence. Serially, the aggregate would be the slowest thing
|
|
67
|
+
* in the verify pipeline for no reason beyond the order they were written in.
|
|
68
|
+
*
|
|
69
|
+
* A spawn that throws becomes an `unmeasured` result rather than a rejection.
|
|
70
|
+
* One absent binary would otherwise take the whole aggregate down and report
|
|
71
|
+
* nothing about the audits that did run.
|
|
72
|
+
*/
|
|
73
|
+
export async function runAudits(
|
|
74
|
+
specs: readonly AuditSpec[],
|
|
75
|
+
spawn: Spawn,
|
|
76
|
+
): Promise<AuditResult[]> {
|
|
77
|
+
return Promise.all(
|
|
78
|
+
specs.map(async (spec) => {
|
|
79
|
+
try {
|
|
80
|
+
const { exitCode, stdout } = await spawn(spec)
|
|
81
|
+
return classify(spec, exitCode, stdout)
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return {
|
|
84
|
+
id: spec.id,
|
|
85
|
+
label: spec.label,
|
|
86
|
+
status: 'unmeasured' as const,
|
|
87
|
+
tracked: spec.corpus === 'tracked',
|
|
88
|
+
exitCode: 1,
|
|
89
|
+
reason: `could not be started: ${error instanceof Error ? error.message : String(error)}`,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}),
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The single verdict over the set.
|
|
98
|
+
*
|
|
99
|
+
* `incomplete` outranks a quiet set on purpose. An aggregate reporting a pass
|
|
100
|
+
* over a tree it did not finish measuring is the failure this whole command
|
|
101
|
+
* exists against, and an empty set takes it for the same reason: nothing ran,
|
|
102
|
+
* so nothing passed.
|
|
103
|
+
*
|
|
104
|
+
* An `absent` per-machine corpus does not reach it. That folder is gitignored
|
|
105
|
+
* and missing on every fresh clone, so folding it in would pin the verdict at
|
|
106
|
+
* `incomplete` on every CI run and leave the word meaning nothing.
|
|
107
|
+
*/
|
|
108
|
+
export function verdictOf(results: readonly AuditResult[]): Verdict {
|
|
109
|
+
if (results.length === 0) return 'incomplete'
|
|
110
|
+
if (results.some((result) => result.status === 'finding')) return 'findings'
|
|
111
|
+
if (results.some((result) => result.status === 'unmeasured')) {
|
|
112
|
+
return 'incomplete'
|
|
113
|
+
}
|
|
114
|
+
if (results.some((result) => result.status === 'reported')) return 'reported'
|
|
115
|
+
return 'clean'
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Exits non-zero only on a fact, which is the split this aggregate inherits
|
|
120
|
+
* rather than moves. A growing judgment count reports as a delta and fails
|
|
121
|
+
* nothing, because the standards behind those measures set no hard cap.
|
|
122
|
+
*
|
|
123
|
+
* An audit that did not report takes its own code rather than the findings one.
|
|
124
|
+
* The two mean opposite things to whoever reads the exit: a fact is a defect in
|
|
125
|
+
* the tree, and an unmeasured audit is a defect in the run.
|
|
126
|
+
*/
|
|
127
|
+
export function exitCodeFor(results: readonly AuditResult[]): number {
|
|
128
|
+
const verdict = verdictOf(results)
|
|
129
|
+
if (verdict === 'findings') return EXIT_FINDING
|
|
130
|
+
if (verdict === 'incomplete') return EXIT_UNMEASURED
|
|
131
|
+
return 0
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface Summary {
|
|
135
|
+
readonly verdict: Verdict
|
|
136
|
+
/** Audits that reported, out of the whole set. */
|
|
137
|
+
readonly audited: number
|
|
138
|
+
/** Audits carrying a finding that is a fact. */
|
|
139
|
+
readonly facts: number
|
|
140
|
+
/** Audits that did not report at all. */
|
|
141
|
+
readonly unmeasured: number
|
|
142
|
+
/**
|
|
143
|
+
* Audits whose per-machine folder is not on this disk.
|
|
144
|
+
*
|
|
145
|
+
* Published rather than folded into `audited`, so a run stating twelve
|
|
146
|
+
* audits never implies twelve corpora were read.
|
|
147
|
+
*/
|
|
148
|
+
readonly absent: number
|
|
149
|
+
/** Tracked counts that rose against the recorded floor. */
|
|
150
|
+
readonly grown: number
|
|
151
|
+
/** Tracked counts that fell against the recorded floor. */
|
|
152
|
+
readonly shrunk: number
|
|
153
|
+
/** Tracked audits with no recorded floor to compare against. */
|
|
154
|
+
readonly unrecorded: number
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The flat reading a shell stage takes without parsing the nested record.
|
|
159
|
+
*
|
|
160
|
+
* Published rather than left to a consumer to derive, for the reason the
|
|
161
|
+
* context audit already gives about its own join: deriving it means restating
|
|
162
|
+
* which question each number answers, and one wrong restatement is growth
|
|
163
|
+
* reported against a measure that never moved. Every key here is unique across
|
|
164
|
+
* the whole record, so a grep for one reaches the top level alone.
|
|
165
|
+
*/
|
|
166
|
+
export function summarize(
|
|
167
|
+
results: readonly AuditResult[],
|
|
168
|
+
deltas: readonly Delta[],
|
|
169
|
+
): Summary {
|
|
170
|
+
let grown = 0
|
|
171
|
+
let shrunk = 0
|
|
172
|
+
let unrecorded = 0
|
|
173
|
+
|
|
174
|
+
for (const delta of deltas) {
|
|
175
|
+
if (delta.kind === 'unrecorded') {
|
|
176
|
+
unrecorded += 1
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
if (delta.kind !== 'compared') continue
|
|
180
|
+
|
|
181
|
+
for (const moved of delta.moved) {
|
|
182
|
+
if (moved.delta > 0) grown += 1
|
|
183
|
+
else shrunk += 1
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const counting = (status: AuditResult['status']) =>
|
|
188
|
+
results.filter((result) => result.status === status).length
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
verdict: verdictOf(results),
|
|
192
|
+
audited: results.filter(
|
|
193
|
+
(result) => result.status !== 'unmeasured' && result.status !== 'absent',
|
|
194
|
+
).length,
|
|
195
|
+
facts: counting('finding'),
|
|
196
|
+
unmeasured: counting('unmeasured'),
|
|
197
|
+
absent: counting('absent'),
|
|
198
|
+
grown,
|
|
199
|
+
shrunk,
|
|
200
|
+
unrecorded,
|
|
201
|
+
}
|
|
202
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { register as context } from '@/commands/context'
|
|
|
27
27
|
import { register as markdown } from '@/commands/markdown'
|
|
28
28
|
import { register as records } from '@/commands/records'
|
|
29
29
|
import { register as sessions } from '@/commands/sessions'
|
|
30
|
+
import { register as audits } from '@/commands/audits'
|
|
30
31
|
import { PROJECT_ROOT } from '@/project-root'
|
|
31
32
|
|
|
32
33
|
const GREY = '\x1b[0;90m'
|
|
@@ -63,6 +64,7 @@ function showHelp(): void {
|
|
|
63
64
|
`${GREY}│${NC} markdown [cmd] ${GREY}# Report markdown against the attribute standards (audit)${NC}`,
|
|
64
65
|
`${GREY}│${NC} records [cmd] ${GREY}# Session records under .claude/ (validate, push, pull)${NC}`,
|
|
65
66
|
`${GREY}│${NC} sessions [cmd] ${GREY}# Resolve live sessions to worktree and branch (list)${NC}`,
|
|
67
|
+
`${GREY}│${NC} audits [cmd] ${GREY}# Run every health check as one set (run, list)${NC}`,
|
|
66
68
|
`${GREY}│${NC}`,
|
|
67
69
|
`${GREY}│${NC} ${WHITE}Sandbox:${NC}`,
|
|
68
70
|
`${GREY}│${NC} aitk sandbox ${GREY}# Interactive scenario picker${NC}`,
|
|
@@ -101,6 +103,7 @@ function showHelp(): void {
|
|
|
101
103
|
`${GREY}│${NC} aitk records validate plans`,
|
|
102
104
|
`${GREY}│${NC} aitk records push --json`,
|
|
103
105
|
`${GREY}│${NC} aitk sessions list --json`,
|
|
106
|
+
`${GREY}│${NC} aitk audits run --json`,
|
|
104
107
|
`${GREY}└${NC}`,
|
|
105
108
|
]
|
|
106
109
|
console.log(lines.join('\n'))
|
|
@@ -159,5 +162,6 @@ context(program)
|
|
|
159
162
|
markdown(program)
|
|
160
163
|
records(program)
|
|
161
164
|
sessions(program)
|
|
165
|
+
audits(program)
|
|
162
166
|
|
|
163
167
|
program.parse()
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { execa } from 'execa'
|
|
2
|
+
import type { Command } from 'commander'
|
|
3
|
+
import {
|
|
4
|
+
BASELINE_REL,
|
|
5
|
+
type Baseline,
|
|
6
|
+
baselineFrom,
|
|
7
|
+
compareBaseline,
|
|
8
|
+
type Delta,
|
|
9
|
+
readBaseline,
|
|
10
|
+
writeBaseline,
|
|
11
|
+
} from '@/audits/baseline'
|
|
12
|
+
import { AUDITS, type AuditResult } from '@/audits/catalog'
|
|
13
|
+
import {
|
|
14
|
+
exitCodeFor,
|
|
15
|
+
runAudits,
|
|
16
|
+
spawnAudit,
|
|
17
|
+
type Summary,
|
|
18
|
+
summarize,
|
|
19
|
+
} from '@/audits/run'
|
|
20
|
+
import { gitEnv } from '@/git-env'
|
|
21
|
+
import { intro, logError, logInfo, logStep, logWarn, outro, plural } from '@/ui'
|
|
22
|
+
import { currentWorktreeRoot } from '@/worktree'
|
|
23
|
+
|
|
24
|
+
interface RunCommandOptions {
|
|
25
|
+
readonly json?: boolean
|
|
26
|
+
readonly record?: boolean
|
|
27
|
+
readonly root?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ListCommandOptions {
|
|
31
|
+
readonly json?: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function register(program: Command): void {
|
|
35
|
+
const audits = program
|
|
36
|
+
.command('audits')
|
|
37
|
+
.description('Run every health check this repository owns as one set')
|
|
38
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
39
|
+
|
|
40
|
+
audits
|
|
41
|
+
.command('run')
|
|
42
|
+
.description(
|
|
43
|
+
'Run every audit, report per check under one verdict, and compare each count to the recorded baseline',
|
|
44
|
+
)
|
|
45
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
46
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
47
|
+
.option(
|
|
48
|
+
'--root <path>',
|
|
49
|
+
'Tree to measure, defaulting to the current worktree',
|
|
50
|
+
)
|
|
51
|
+
.option(
|
|
52
|
+
'--record',
|
|
53
|
+
`Write this run's tracked counts to ${BASELINE_REL} as the new baseline`,
|
|
54
|
+
)
|
|
55
|
+
.addHelpText(
|
|
56
|
+
'after',
|
|
57
|
+
[
|
|
58
|
+
'',
|
|
59
|
+
'Exit codes:',
|
|
60
|
+
' 0 every audit reported and none carried a finding that is a fact',
|
|
61
|
+
' 1 refused, with the reason on stderr',
|
|
62
|
+
' 2 an audit carries a finding that is a fact',
|
|
63
|
+
' 3 an audit did not report, so the run measured less than the set',
|
|
64
|
+
'',
|
|
65
|
+
'It gates on exactly what already gates a push and on nothing new:',
|
|
66
|
+
'an unresolved context citation, a banned character, word, or spelling,',
|
|
67
|
+
'and a skill folder carrying no REQUIREMENT.md. Every other measure is a',
|
|
68
|
+
'judgment a reader settles, and failing a push on one teaches',
|
|
69
|
+
'contributors to route around the stage.',
|
|
70
|
+
'',
|
|
71
|
+
'Exit 3 is a defect in the run rather than in the tree. An aggregate that',
|
|
72
|
+
'reports a pass over a set it did not finish measuring is the failure this',
|
|
73
|
+
'command exists against, so a verb that did not report takes its own code.',
|
|
74
|
+
'',
|
|
75
|
+
`The baseline at ${BASELINE_REL} holds the counts from the last recorded`,
|
|
76
|
+
'run, so a measure that reports rather than gates still costs something',
|
|
77
|
+
'when it grows. Only a tracked corpus is retained: a gitignored record',
|
|
78
|
+
"folder holds one machine's scratch, and its counts answer nobody else.",
|
|
79
|
+
'',
|
|
80
|
+
'Examples:',
|
|
81
|
+
' aitk audits run',
|
|
82
|
+
' aitk audits run --json',
|
|
83
|
+
' aitk audits run --record',
|
|
84
|
+
'',
|
|
85
|
+
].join('\n'),
|
|
86
|
+
)
|
|
87
|
+
.action(async (opts: RunCommandOptions) => {
|
|
88
|
+
process.exitCode = await runAll(opts)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
audits
|
|
92
|
+
.command('list')
|
|
93
|
+
.description(
|
|
94
|
+
'List every audit this command runs and what each one gates on',
|
|
95
|
+
)
|
|
96
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
97
|
+
.option('--json', 'Emit JSON with the id, invocation, corpus, and gate')
|
|
98
|
+
.addHelpText(
|
|
99
|
+
'after',
|
|
100
|
+
[
|
|
101
|
+
'',
|
|
102
|
+
'Exit codes:',
|
|
103
|
+
' 0 the catalog was listed',
|
|
104
|
+
'',
|
|
105
|
+
'Examples:',
|
|
106
|
+
' aitk audits list',
|
|
107
|
+
' aitk audits list --json',
|
|
108
|
+
'',
|
|
109
|
+
].join('\n'),
|
|
110
|
+
)
|
|
111
|
+
.action((opts: ListCommandOptions) => {
|
|
112
|
+
process.exitCode = runList(opts)
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function runList(opts: ListCommandOptions): number {
|
|
117
|
+
if (opts.json) {
|
|
118
|
+
process.stdout.write(
|
|
119
|
+
`${JSON.stringify({
|
|
120
|
+
audits: AUDITS.map((audit) => ({
|
|
121
|
+
id: audit.id,
|
|
122
|
+
label: audit.label,
|
|
123
|
+
command: `aitk ${audit.argv.join(' ')}`,
|
|
124
|
+
corpus: audit.corpus,
|
|
125
|
+
gates: audit.gatingExits.length > 0,
|
|
126
|
+
})),
|
|
127
|
+
})}\n`,
|
|
128
|
+
)
|
|
129
|
+
return 0
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
intro('aitk audits list')
|
|
133
|
+
for (const audit of AUDITS) {
|
|
134
|
+
logStep(audit.label)
|
|
135
|
+
logInfo(`aitk ${audit.argv.join(' ')}`)
|
|
136
|
+
logInfo(
|
|
137
|
+
`${audit.corpus} corpus, ${audit.gatingExits.length > 0 ? 'gates on a fact' : 'reports only'}`,
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
outro()
|
|
141
|
+
return 0
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The day a record is stamped with, as `YYYY-MM-DD`.
|
|
146
|
+
*
|
|
147
|
+
* Local rather than UTC, because the stamp is read beside a commit date in a
|
|
148
|
+
* context entry and a run taken in the evening should not record tomorrow.
|
|
149
|
+
*/
|
|
150
|
+
function today(): string {
|
|
151
|
+
const now = new Date()
|
|
152
|
+
return [
|
|
153
|
+
now.getFullYear(),
|
|
154
|
+
String(now.getMonth() + 1).padStart(2, '0'),
|
|
155
|
+
String(now.getDate()).padStart(2, '0'),
|
|
156
|
+
].join('-')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The commit the counts were read at, or `unknown` outside a repository.
|
|
161
|
+
*
|
|
162
|
+
* A target project can install this CLI without a git history behind it, and
|
|
163
|
+
* refusing there would withhold the whole report over a field that only makes
|
|
164
|
+
* the record reproducible.
|
|
165
|
+
*/
|
|
166
|
+
async function headCommit(root: string): Promise<string> {
|
|
167
|
+
const result = await execa('git', ['-C', root, 'rev-parse', 'HEAD'], {
|
|
168
|
+
reject: false,
|
|
169
|
+
env: gitEnv(),
|
|
170
|
+
extendEnv: false,
|
|
171
|
+
})
|
|
172
|
+
return result.exitCode === 0 ? result.stdout.trim() : 'unknown'
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function runAll(opts: RunCommandOptions): Promise<number> {
|
|
176
|
+
const emitJson = opts.json ?? false
|
|
177
|
+
const root = opts.root ?? (await currentWorktreeRoot())
|
|
178
|
+
|
|
179
|
+
let baseline: Baseline | undefined
|
|
180
|
+
try {
|
|
181
|
+
baseline = await readBaseline(root)
|
|
182
|
+
} catch (error) {
|
|
183
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
184
|
+
if (emitJson) {
|
|
185
|
+
process.stderr.write(`${message}\n`)
|
|
186
|
+
process.stdout.write(
|
|
187
|
+
`${JSON.stringify({ ok: false, reason: 'bad-baseline', message })}\n`,
|
|
188
|
+
)
|
|
189
|
+
return 1
|
|
190
|
+
}
|
|
191
|
+
intro('aitk audits run')
|
|
192
|
+
logStep('Refused')
|
|
193
|
+
logError(message)
|
|
194
|
+
outro()
|
|
195
|
+
return 1
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const results = await runAudits(AUDITS, spawnAudit(root))
|
|
199
|
+
const deltas = compareBaseline(baseline, results)
|
|
200
|
+
const summary = summarize(results, deltas)
|
|
201
|
+
|
|
202
|
+
let recorded: string | undefined
|
|
203
|
+
if (opts.record) {
|
|
204
|
+
const next = baselineFrom(results, {
|
|
205
|
+
recordedAt: today(),
|
|
206
|
+
commit: await headCommit(root),
|
|
207
|
+
})
|
|
208
|
+
recorded = await writeBaseline(root, next)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (emitJson) {
|
|
212
|
+
process.stdout.write(
|
|
213
|
+
`${JSON.stringify({
|
|
214
|
+
ok: true,
|
|
215
|
+
root,
|
|
216
|
+
// Flat scalars, so a shell stage greps one out without a JSON parser.
|
|
217
|
+
// The nested arrays below carry the detail behind each number.
|
|
218
|
+
summary,
|
|
219
|
+
baseline: baseline
|
|
220
|
+
? { recordedAt: baseline.recordedAt, commit: baseline.commit }
|
|
221
|
+
: undefined,
|
|
222
|
+
recorded,
|
|
223
|
+
audits: results,
|
|
224
|
+
deltas,
|
|
225
|
+
})}\n`,
|
|
226
|
+
)
|
|
227
|
+
} else {
|
|
228
|
+
report(results, deltas, baseline, recorded, summary)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return exitCodeFor(results)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The counts of one result, rendered as `key n` pairs a reader can scan. */
|
|
235
|
+
function countLine(counts: Record<string, number>): string {
|
|
236
|
+
return Object.entries(counts)
|
|
237
|
+
.map(([key, value]) => `${key} ${value}`)
|
|
238
|
+
.join(', ')
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The delta line for one audit, or nothing when there is no comparison to
|
|
243
|
+
* report. A first run says so rather than showing a delta of zero, since zero
|
|
244
|
+
* against an absent baseline says the same as a corpus that did not move.
|
|
245
|
+
*/
|
|
246
|
+
function deltaLine(delta: Delta): string | undefined {
|
|
247
|
+
if (delta.kind === 'per-machine' || delta.kind === 'unmeasured') {
|
|
248
|
+
return undefined
|
|
249
|
+
}
|
|
250
|
+
if (delta.kind === 'unrecorded') return 'No recorded baseline to compare'
|
|
251
|
+
|
|
252
|
+
const parts = [
|
|
253
|
+
...delta.moved.map(
|
|
254
|
+
({ key, from, to, delta: moved }) =>
|
|
255
|
+
`${key} ${from} to ${to} (${moved > 0 ? '+' : ''}${moved})`,
|
|
256
|
+
),
|
|
257
|
+
...delta.added.map(({ key, to }) => `${key} ${to}, newly measured`),
|
|
258
|
+
...delta.dropped.map(({ key, from }) => `${key} was ${from}, not measured`),
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
return parts.length === 0 ? undefined : parts.join(', ')
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function report(
|
|
265
|
+
results: readonly AuditResult[],
|
|
266
|
+
deltas: readonly Delta[],
|
|
267
|
+
baseline: Baseline | undefined,
|
|
268
|
+
recorded: string | undefined,
|
|
269
|
+
summary: Summary,
|
|
270
|
+
): void {
|
|
271
|
+
const byId = new Map(deltas.map((delta) => [delta.id, delta]))
|
|
272
|
+
|
|
273
|
+
intro('aitk audits run')
|
|
274
|
+
|
|
275
|
+
logStep('Baseline')
|
|
276
|
+
if (baseline === undefined) {
|
|
277
|
+
logWarn(`None recorded. Take one with aitk audits run --record.`)
|
|
278
|
+
} else {
|
|
279
|
+
logInfo(`${baseline.recordedAt} at ${baseline.commit.slice(0, 8)}`)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
for (const result of results) {
|
|
283
|
+
logStep(result.label)
|
|
284
|
+
|
|
285
|
+
if (result.status === 'absent') {
|
|
286
|
+
logInfo(`No corpus on this machine: ${result.reason ?? 'not found'}`)
|
|
287
|
+
continue
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (result.status === 'unmeasured') {
|
|
291
|
+
logWarn(`Did not report: ${result.reason ?? 'no reason given'}`)
|
|
292
|
+
continue
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const counts = result.counts ?? {}
|
|
296
|
+
const line = countLine(counts)
|
|
297
|
+
if (result.status === 'finding') {
|
|
298
|
+
logError(line === '' ? 'A finding that is a fact' : line)
|
|
299
|
+
} else {
|
|
300
|
+
logInfo(line === '' ? 'Reported' : line)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (!result.tracked) {
|
|
304
|
+
logInfo('Per-machine corpus, so no baseline is kept')
|
|
305
|
+
continue
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const moved = deltaLine(
|
|
309
|
+
byId.get(result.id) ?? { id: result.id, kind: 'unrecorded' },
|
|
310
|
+
)
|
|
311
|
+
if (moved !== undefined) logInfo(moved)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (recorded !== undefined) {
|
|
315
|
+
logStep('Recorded')
|
|
316
|
+
logInfo(recorded)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
logStep('Verdict')
|
|
320
|
+
|
|
321
|
+
if (summary.verdict === 'findings') {
|
|
322
|
+
logError(
|
|
323
|
+
`${plural(summary.facts, 'audit')} carrying a finding that is a fact. Fix what each names.`,
|
|
324
|
+
)
|
|
325
|
+
} else if (summary.verdict === 'incomplete') {
|
|
326
|
+
logError(
|
|
327
|
+
`${plural(summary.unmeasured, 'audit')} did not report, so this run measured less than the set.`,
|
|
328
|
+
)
|
|
329
|
+
} else if (summary.verdict === 'reported') {
|
|
330
|
+
logInfo('No fact. Every other finding is a judgment a reader settles.')
|
|
331
|
+
} else {
|
|
332
|
+
logInfo('Every audit reported and every count is zero.')
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Stated on every run, including a clean one. A count of what passed reads as
|
|
336
|
+
// a verdict on the whole set unless the run also says what it never reached.
|
|
337
|
+
logInfo(
|
|
338
|
+
`${summary.audited} of ${results.length} corpora measured, ${summary.absent} absent on this machine`,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
outro()
|
|
342
|
+
}
|
|
@@ -14,7 +14,7 @@ Governs the product-scope document at `.claude/REQUIREMENTS.md`: problem, goals,
|
|
|
14
14
|
Does not govern:
|
|
15
15
|
|
|
16
16
|
- Rationale for a technical choice: `architecture.md`
|
|
17
|
-
-
|
|
17
|
+
- Execution order across the work the scope generates: `tasks.md`
|
|
18
18
|
- Per-domain structure and narrative: `context.md`
|
|
19
19
|
|
|
20
20
|
## What goes in
|
|
@@ -39,7 +39,7 @@ Use `## Problem`, `## Goals`, `## Non-goals`, `## MVP features`, `## Tech stack`
|
|
|
39
39
|
|
|
40
40
|
The MVP list is a historical record of the original scope. Once those features ship it stays as written. Do not renumber it, do not append to it, and do not annotate entries with status. A reader telling the original scope apart from what followed depends on the first one staying legible.
|
|
41
41
|
|
|
42
|
-
Later scope arrives as a new section rather than as an extension of the MVP list. Name the section for what it delivers and state its entries as outcomes, the same way the goals are stated.
|
|
42
|
+
Later scope arrives as a new section rather than as an extension of the MVP list. Name the section for what it delivers and state its entries as outcomes, the same way the goals are stated. Nothing sequences either list into versions. Work reaches the board as discrete tasks under `tasks.md`, which orders them by readiness, so a section here states what is wanted and never when it lands.
|
|
43
43
|
|
|
44
44
|
## Distribution
|
|
45
45
|
|
package/standards/tasks.md
CHANGED
|
@@ -19,7 +19,6 @@ Does not govern:
|
|
|
19
19
|
|
|
20
20
|
- The plan file a task cites, its sections, and its answer contract: `plan.md`
|
|
21
21
|
- Phase-label format and which surfaces a label may appear on: `versioning.md`
|
|
22
|
-
- Sequencing across versions and why the order is what it is: `roadmap.md`
|
|
23
22
|
- Architectural reasoning that outlives a task: `architecture.md`
|
|
24
23
|
- The pre-compaction handoff sitting in the folder, its filename and its sections: `session.md`
|
|
25
24
|
- When a project opens a task at all, which is project policy rather than a shape rule
|
|
@@ -52,7 +51,9 @@ The `claude-tasks` skill creates and archives task files. `claude-docs` marks ou
|
|
|
52
51
|
|
|
53
52
|
## Ordering
|
|
54
53
|
|
|
55
|
-
`priority.md` carries execution order and what each task is waiting on. The generated index sorts by filename and says nothing about order, so without this file board state gets reconstructed by hand every session. Why
|
|
54
|
+
`priority.md` carries execution order and what each task is waiting on. The generated index sorts by filename and says nothing about order, so without this file board state gets reconstructed by hand every session. Why a row sits where it does inside its group is stated on the row itself, in the column that already carries what the task is waiting on, one line per row.
|
|
55
|
+
|
|
56
|
+
That cell is the only home sequencing rationale has. Rationale spanning several rows, why one group of work runs before another, is carried by nothing and reaches a later session only through whoever remembers it. Naming the gap is deliberate: a second document holding it would be the version-sequencing surface this board replaced, and a row already states what it waits on, which is the part of the reasoning a reader acts on.
|
|
56
57
|
|
|
57
58
|
Group tasks by readiness rather than by status, one row per task, under the columns each group fixes below. Keep it to links and blockers: tables, plus at most one sentence per section. A paragraph in `priority.md` is a defect whatever it says. Stating the shape this way is what lets a single diff fail, since a size cap only trips after the fact and every addition looks defensible on its own.
|
|
58
59
|
|
|
@@ -240,7 +241,7 @@ The line is what lets a merge close its own task. Every merge on `main` is a squ
|
|
|
240
241
|
- Architectural reasoning that outlives the task. A finding explains why this task is shaped as it is. A decision the system keeps after the task closes belongs in `.claude/ARCHITECTURE.md`.
|
|
241
242
|
- Narrative of the session that produced the task. A finding states what constrains the task, so what was probed, what it cost, and who decided belongs in the groundwork folder the `Groundwork:` line names. A task with no groundwork folder cuts the narrative rather than relocating it, since the board is not the fallback destination for it.
|
|
242
243
|
- "In progress" or "Blocked" headings. Note status inline on the outcome instead.
|
|
243
|
-
- Sequencing rationale or which version is active. Why this task is planned before its neighbors goes on its row in `priority.md`, in the cell that already carries what it is waiting on.
|
|
244
|
+
- Sequencing rationale or which version is active. Why this task is planned before its neighbors goes on its row in `priority.md`, in the cell that already carries what it is waiting on. Rationale wider than one row has no home at all, so cut it rather than filing it here.
|
|
244
245
|
|
|
245
246
|
## Archiving
|
|
246
247
|
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: claude-roadmap
|
|
3
|
-
description: Why versions are sequenced from the MVP list alone, what makes a version a usable increment, and the lifecycle gate that stops a second pass
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Claude roadmap requirement
|
|
7
|
-
|
|
8
|
-
## Gap
|
|
9
|
-
|
|
10
|
-
Without this skill, versions are invented from a sense of what should come next rather than sequenced from the scope the requirements already fixed. Each one reads as a milestone nobody can use, because the ordering follows what feels foundational instead of what a user can then do, and a version that de-risks nothing sits ahead of the subsystem the whole plan rests on. The roadmap then drifts into task-level steps, which duplicates the plan folder and goes stale the first time a file moves.
|
|
11
|
-
|
|
12
|
-
An update is where the file quietly breaks. Rewriting rows that never changed hides the one line that moved, so a reader diffing the roadmap learns nothing from it. And a project whose MVP already shipped gets its later scope sequenced here, which puts a fresh requirements pass's work into a file that only ever sequenced the MVP.
|
|
13
|
-
|
|
14
|
-
## Must
|
|
15
|
-
|
|
16
|
-
- Sequence from the MVP list in the requirements file and name it as the source
|
|
17
|
-
- Make every version a usable increment, stated as what the user can then do
|
|
18
|
-
- Order by dependency and by de-risking, placing an unproven subsystem inside the version that first needs it
|
|
19
|
-
- Preserve rows that still hold when updating, resequencing or splitting only where scope shifted
|
|
20
|
-
- Follow the reference for the document shape and version format rather than inventing one
|
|
21
|
-
- Report which versions changed on an update
|
|
22
|
-
|
|
23
|
-
## Must not
|
|
24
|
-
|
|
25
|
-
- Break a version into task-level steps or a file list
|
|
26
|
-
- Rewrite a row that did not change
|
|
27
|
-
- Sequence a later scope section without an explicit override from the caller
|
|
28
|
-
- Stage or commit the file, which is tracked but belongs to the git skills
|
|
29
|
-
|
|
30
|
-
## Guards
|
|
31
|
-
|
|
32
|
-
- Requirements file absent or carrying no MVP features: stop, because there is nothing to sequence
|
|
33
|
-
- A later scope section present after the MVP list: stop and name the override, since that scope belongs to a fresh requirements pass
|
|
34
|
-
- Neither copy of the requirements standard resolves: draft without the lifecycle gate. Refusing on a rule that could not be read stops more than it protects.
|
|
35
|
-
|
|
36
|
-
## Out of scope
|
|
37
|
-
|
|
38
|
-
- Task-level steps and file lists, which `claude-feature` and the task board own
|
|
39
|
-
- Writing the requirements being sequenced
|
|
40
|
-
- Committing the file, which the git skills own
|
|
41
|
-
- Deciding what to build next once the MVP list has shipped
|