@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.
@@ -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
- - Sequencing the scope into ordered versions: `roadmap.md`
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. A roadmap sequences the MVP list alone, so a later scope section is sequenced by a fresh pass rather than folded into the roadmap that already shipped. The roadmap standard sends a project here once its last version ships, and this is the pass it means.
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
 
@@ -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 one version sequences against another belongs in `.claude/ROADMAP.md`, which is committed because that rationale has no substitute record. 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.
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. Why one version sequences against another belongs in `.claude/ROADMAP.md`, which is committed because that reasoning has no substitute record.
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