@erclx/aitk 3.2.0 → 3.4.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "3.2.0",
4
+ "version": "3.4.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -48,6 +48,7 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
48
48
  | `aitk claude skills drift` | Name the shipped skill bodies rewritten between a given ref and `HEAD`, and the installed version against the newest published (`--json`) |
49
49
  | `aitk claude skills reach` | Report the shipped bodies citing a toolkit path no target project receives, exiting 2 on an unqualified one |
50
50
  | `aitk gov test-order` | Report where an implementation reached history ahead of the test covering it (`--json`) |
51
+ | `aitk gov superseded` | Report where the tree still asserts a value a changed convention no longer produces, keyed on the value (`--json`) |
51
52
  | `aitk secrets scan` | Report credential-shaped values in the tree the package ships, keyed on issued values rather than on words (`--json`) |
52
53
  | `aitk deps audit` | Report published advisories against the resolved dependency set, refusing rather than reporting clean when the index is unreachable (`--json`) |
53
54
  | `aitk labels audit` | Report the labels a changed set earns from the pull request label map and the paths no row reaches (`--json`) |
@@ -65,7 +66,7 @@ Each domain exposes a consistent shape where applicable: `list`, `install`, `syn
65
66
  | `tooling` | `list`, `sync`, `ref`, `create`, `verify`, `inject`, `prune-gitignore` |
66
67
  | `snippets` | `list`, `install`, `sync`, `create` |
67
68
  | `standards` | `list`, `<name>` |
68
- | `gov` | `list`, `install`, `sync`, `build`, `regen`, `test-order` |
69
+ | `gov` | `list`, `install`, `sync`, `build`, `regen`, `test-order`, `superseded` |
69
70
  | `claude` | `init`, `sync`, `seeds list`, `skills list`, `skills audit`, `skills drift`, `skills reach`, `setup [dest]` |
70
71
  | `wiki` | `init` |
71
72
  | `design` | `render` |
@@ -29,6 +29,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
29
29
  - [Skill audit](skills-audit.md): Measuring both skill corpora against standards/skill.md, the checks it reads, the requirement gate that is the only failing one, and the drift verb that names bodies rewritten since a ref
30
30
  - [Citation reach](skills-reach.md): Reporting the shipped skill bodies that cite a path no target project receives, the ownership key that decides what counts, the one-word qualifier that marks a citation as decided, and why the verb reports instead of gating
31
31
  - [State-scoped risk](state-scoped-risk.md): Reading committed state rather than an arriving change, the shipped-tree corpus the secret scan reads, what it keys on and how a deliberate value is exempted, the advisory check and its network failure mode, and why one gates while the other reports
32
+ - [Superseded values](superseded.md): Reading where the tree still asserts a value a changed convention no longer produces, why the sweep keys on the value rather than the file, the exemption marker, the blind spot it cannot reach, and why it reports rather than gates
32
33
  - [Tasks](tasks.md): Selecting a shipped task by stem or pull request, recording a number and closing an outcome, the refusal reasons, the board and backlog checks validate runs, and why the board root defaults to the main worktree
33
34
  - [Teach](teach.md): Listing learning workspaces and the ordinal a new one takes, opening one with its required files, recording sources and glossary terms, resolving what the next lesson needs before it is written, the refusal reasons, and why every write here runs through a verb
34
35
  - [Test order](test-order.md): Reading where an implementation reached history ahead of its test, how a pair is decided, the three verdicts, the coverage the pairing cannot reach, and why the check reports rather than gates
@@ -0,0 +1,57 @@
1
+ ---
2
+ title: Superseded values
3
+ description: Reading where the tree still asserts a value a changed convention no longer produces, why the sweep keys on the value rather than the file, the exemption marker, the blind spot it cannot reach, and why it reports rather than gates
4
+ ---
5
+
6
+ # Superseded values
7
+
8
+ `aitk gov superseded <superseded> <replacement>` reports where the tree still asserts a value the convention behind it no longer produces. It answers the case a diff cannot: a convention changes, and the declarations testing it keep asserting the old form with nothing pointing at them.
9
+
10
+ ```bash
11
+ aitk gov superseded feature-feat- feature-
12
+ aitk gov superseded feature-feat- feature- --json
13
+ aitk gov superseded old-name new-name --root ../my-app
14
+ ```
15
+
16
+ | Option | Behavior |
17
+ | --------------- | ---------------------------------------------------------- |
18
+ | `--root <path>` | Tree to read, defaulting to the current directory |
19
+ | `--json` | Add a machine-readable record on stdout, keeping the frame |
20
+
21
+ Under `--json` the record holds stdout alone and the frame still renders on stderr, refusals included, which is the split `output-shape.md` fixes for every mode.
22
+
23
+ ## Why the value is the key
24
+
25
+ A file-scoped map from the changed rule to what cites it reaches nothing here. A fixture asserting an old output names neither the rule nor the standard behind it, so the change that superseded the value touches no file the fixture can be found from. The value both sides carry is the only key there is.
26
+
27
+ The slug transform is what proved it. Dropping the type segment left four fixtures and three scenario scripts asserting the type-carrying spelling. One surfaced as a red sandbox arm, which named a failing scenario rather than a stale declaration, and the other six were found by deriving from the rule rather than by anything reporting them.
28
+
29
+ ## What it reads
30
+
31
+ The whole corpus git lists: tracked files plus untracked ones git does not ignore. The untracked half keeps a file added on this branch in scope rather than one push later.
32
+
33
+ Matching is a literal substring, so a value carrying regular-expression characters means what it says. Every occurrence on a line reports under its own column, since a line carrying the value twice is two edits.
34
+
35
+ Binary files are skipped on a NUL byte rather than on an extension list, and so is any listed path that will not open. Both are counted. A run states the files it opened against the files git listed, because a count of what passed reads as a verdict on the repository unless it also says how much it left out.
36
+
37
+ Pass an empty replacement to retire a value outright, as in `aitk gov superseded feature-feat- ""`. Findings report the same way and none is annotated, since a line cannot carry a replacement that does not exist.
38
+
39
+ ## The exemption marker
40
+
41
+ A declaration disagreeing with a convention for a stated reason carries `aitk-allow-superseded: <reason>` on its own line or the one directly above. The marker moves the line into the report's `Exempt` section, which is named rather than counted, so a reader weighing the report can reach the reason.
42
+
43
+ Only a marker naming a reason counts. A bare token is a line that meant to say something and did not, and honoring it would let a typo mute a finding. This is the `aitk-allow-secret` shape, and both read the same placement rule through one helper.
44
+
45
+ ## The blind spot
46
+
47
+ The sweep sees the value and nothing else. A prose reference that went stale without carrying it is invisible here, which is not hypothetical: alongside the mechanical instances, one declaration had gone stale by citing the wrong standard for the transform, and it matches no string this sweep could have been given.
48
+
49
+ A value sweep closes most of this class and no part of that one. The help text and the frame say so on every run, so a clean report is not read as a clean tree.
50
+
51
+ ## Exit codes
52
+
53
+ Exit codes are `0` when nothing asserts the superseded value, `1` for a refusal, and `2` for at least one finding. It refuses an empty superseded value, which would match every line rather than a convention, a superseded value equal to its replacement, which means no convention changed, and a tree git cannot list, since an empty list passes each of its zero files. An empty replacement is not among them, because retiring a value is an ordinary reason to run this.
54
+
55
+ Nothing wires this into `bun run check` or into a hook. A value sweep over-reports by construction, and gating a measure carrying a known false-positive class is what teaches contributors to route around the stage. `aitk gov test-order` is the sibling precedent.
56
+
57
+ An exit code says nothing about a call made from a session, since a shell profile may wrap the binary in a function taking its status from a later command. Read the JSON record's `findings` array rather than the exit when a skill consumes this.
@@ -0,0 +1,19 @@
1
+ ---
2
+ description: Enforce the CLAUDE.md edit protocol and the test routing a fact into it rather than a rule
3
+ paths:
4
+ - 'CLAUDE.md'
5
+ ---
6
+
7
+ # CLAUDE.md standards
8
+
9
+ ## Editing
10
+
11
+ - Show the proposed change as a fenced `diff` block in chat first, then wait for approval before calling the file-editing tool.
12
+
13
+ ## Routing
14
+
15
+ - Keep a rule here when it applies every session regardless of what is being edited.
16
+ - Move a rule into `.claude/rules/` when it fires only on a specific path being edited and violating it ships silently.
17
+ - Route a rule failing either half to a context entry or a skill body rather than here.
18
+ - Name a rule's tier rather than a rule file. A path-scoped rule reaches the session on its own glob and needs no pointer from here.
19
+ - Point at a skill by name where one applies. A skill loads only when invoked, so an unreferenced skill is one no session finds.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "3.2.0",
4
+ "version": "3.4.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
package/src/binary.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Whether the bytes are something a line scanner should not read.
3
+ *
4
+ * A NUL byte rather than an extension list, since a shipped tree carries fonts
5
+ * and images under names no scanner has reason to enumerate, and a list would
6
+ * go stale the first time a format was added. Decoded text holds no NUL, so the
7
+ * test costs one scan and never rejects source.
8
+ *
9
+ * Neutral rather than owned by either sweep that calls it. The secret scan
10
+ * shaped it and the superseded-value sweep reads the same corpus, so leaving it
11
+ * in `src/secrets/` would put a `src/gov/` dependency on the secret scanner for
12
+ * a predicate about bytes.
13
+ */
14
+ export function isBinary(text: string): boolean {
15
+ return text.includes('\0')
16
+ }
@@ -14,6 +14,12 @@ import {
14
14
  mergeExtraRules,
15
15
  resolveRules,
16
16
  } from '@/gov/stacks'
17
+ import {
18
+ readSuperseded,
19
+ SUPERSEDED_MARKER,
20
+ type SupersededHit,
21
+ type SupersededReport,
22
+ } from '@/gov/superseded'
17
23
  import {
18
24
  type PairRecord,
19
25
  readTestOrder,
@@ -57,6 +63,11 @@ interface TestOrderOptions {
57
63
  readonly json?: boolean
58
64
  }
59
65
 
66
+ interface SupersededOptions {
67
+ readonly root?: string
68
+ readonly json?: boolean
69
+ }
70
+
60
71
  export function register(program: Command): void {
61
72
  const gov = program
62
73
  .command('gov')
@@ -180,6 +191,141 @@ export function register(program: Command): void {
180
191
  .action((opts: TestOrderOptions) => {
181
192
  process.exitCode = runTestOrder(opts)
182
193
  })
194
+
195
+ gov
196
+ .command('superseded')
197
+ .description(
198
+ 'Report where the tree still asserts a value a changed convention no longer produces',
199
+ )
200
+ .argument('<superseded>', 'The value the convention used to produce')
201
+ .argument('<replacement>', 'What it produces now')
202
+ .helpOption('-h, --help', 'Show this help message')
203
+ .option('--root <path>', 'Tree to read, defaulting to the cwd')
204
+ .option('--json', 'Add a machine-readable record on stdout')
205
+ .addHelpText(
206
+ 'after',
207
+ [
208
+ '',
209
+ 'Keyed on the value rather than on the file stating the rule. A fixture',
210
+ 'asserting an old output names neither the rule nor the standard behind',
211
+ 'it, so a file-scoped map from the changed rule reaches none of them and',
212
+ 'the value both sides carry is the only key there is.',
213
+ '',
214
+ 'It reports and never gates. A string appears for reasons unrelated to',
215
+ 'the convention, so the output is a reading rather than a verdict, and a',
216
+ 'declaration disagreeing for a stated reason carries a',
217
+ `\`${SUPERSEDED_MARKER}: <reason>\` marker on its own line or the one above.`,
218
+ '',
219
+ 'Pass an empty replacement to retire a value outright. Findings report',
220
+ 'the same way and none is annotated, since a line cannot carry a',
221
+ 'replacement that does not exist.',
222
+ '',
223
+ 'Blind spot:',
224
+ ' a prose reference that went stale without carrying the value, such as',
225
+ ' a declaration citing the wrong standard for the transform, matches',
226
+ ' nothing here and is reached by reading rather than by this sweep',
227
+ '',
228
+ 'Exit codes:',
229
+ ' 0 nothing in the corpus asserts the superseded value',
230
+ ' 1 refused, with the reason on stderr or in the JSON record',
231
+ ' 2 at least one declaration still asserts it',
232
+ '',
233
+ 'Examples:',
234
+ ' aitk gov superseded feature-feat- feature-',
235
+ ' aitk gov superseded feature-feat- feature- --json',
236
+ '',
237
+ ].join('\n'),
238
+ )
239
+ .action(
240
+ async (
241
+ superseded: string,
242
+ replacement: string,
243
+ opts: SupersededOptions,
244
+ ) => {
245
+ process.exitCode = await runSuperseded(superseded, replacement, opts)
246
+ },
247
+ )
248
+ }
249
+
250
+ /**
251
+ * Reports and never gates, matching `test-order` above. The finding count moves
252
+ * the exit code with nothing wiring it into a push, since a value sweep
253
+ * over-reports by construction and gating a measure carrying a known
254
+ * false-positive class is what teaches contributors to route around a stage.
255
+ */
256
+ async function runSuperseded(
257
+ superseded: string,
258
+ replacement: string,
259
+ opts: SupersededOptions,
260
+ ): Promise<number> {
261
+ const root = resolve(opts.root ?? process.cwd())
262
+ const report = await readSuperseded(root, { superseded, replacement })
263
+ const emitJson = opts.json ?? false
264
+
265
+ if (report.kind === 'unreadable') {
266
+ intro('aitk gov superseded')
267
+ logStep('Refused')
268
+ logError(report.reason)
269
+ outro()
270
+
271
+ if (emitJson) {
272
+ process.stdout.write(
273
+ `${JSON.stringify({ ok: false, reason: report.reason })}\n`,
274
+ )
275
+ }
276
+
277
+ return 1
278
+ }
279
+
280
+ reportSuperseded(report, root)
281
+
282
+ if (emitJson) {
283
+ process.stdout.write(`${JSON.stringify({ ok: true, root, ...report })}\n`)
284
+ }
285
+
286
+ return report.findings.length > 0 ? 2 : 0
287
+ }
288
+
289
+ function describeHit(hit: SupersededHit): string {
290
+ const note = hit.carriesReplacement
291
+ ? ' (the replacement is on this line)'
292
+ : ''
293
+ return `${hit.file}:${hit.line}:${hit.column}${note}: ${hit.preview}`
294
+ }
295
+
296
+ function reportSuperseded(
297
+ report: Extract<SupersededReport, { kind: 'measured' }>,
298
+ root: string,
299
+ ): void {
300
+ intro('aitk gov superseded')
301
+
302
+ logStep('Sweep')
303
+ logInfo(`${report.superseded} → ${report.replacement} in ${root}`)
304
+
305
+ logStep(report.findings.length === 0 ? 'Clean' : 'Findings')
306
+ if (report.findings.length === 0) {
307
+ logInfo('nothing in the corpus asserts the superseded value')
308
+ } else {
309
+ for (const finding of report.findings) logWarn(describeHit(finding))
310
+ }
311
+
312
+ // Named rather than counted. A muted line is a judgment someone recorded,
313
+ // and a reader weighing this report has to be able to reach the reason.
314
+ logStep('Exempt')
315
+ if (report.exempt.length === 0) {
316
+ logInfo('no line carries a marker')
317
+ } else {
318
+ for (const hit of report.exempt) logInfo(describeHit(hit))
319
+ }
320
+
321
+ // A count of what passed reads as a verdict on the repository unless the run
322
+ // also says how much of it the corpus left out.
323
+ logStep('Corpus')
324
+ logInfo(
325
+ `${report.files} file(s) opened of ${report.listed} listed, ${report.skipped} skipped as binary or unreadable`,
326
+ )
327
+
328
+ outro()
183
329
  }
184
330
 
185
331
  /**
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Escapes every character a regular expression would read as syntax.
3
+ *
4
+ * The token became a parameter when this was extracted, and the two callers
5
+ * pass letters and hyphens alone. That is what makes escaping cheap here rather
6
+ * than a fix for a live defect: an unescaped token holding a dot matches the
7
+ * wrong lines and one holding a parenthesis throws, and neither failure is the
8
+ * caller's to anticipate.
9
+ */
10
+ function escapeForPattern(token: string): string {
11
+ return token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
12
+ }
13
+
14
+ /**
15
+ * Whether the line at `index` carries `token` with a reason after it, reading
16
+ * itself and the line above.
17
+ *
18
+ * Extracted from `src/secrets/marker.ts`, which shaped it, so the two sweeps
19
+ * that mute a line share one answer to where a marker may sit. Two lines rather
20
+ * than one, because a value is as often introduced by a preceding comment as
21
+ * annotated inline, and a format taking no trailing comment has nowhere else to
22
+ * put the marker. Nothing further up counts, so a marker cannot silence a block
23
+ * it does not sit on.
24
+ *
25
+ * Only a marker naming a reason counts. A bare token is read as a line that
26
+ * meant to say something and did not, and honoring it would let a typo mute a
27
+ * finding, where the reason is the whole value of an exemption a later reader
28
+ * has to weigh.
29
+ */
30
+ export function isMarked(
31
+ lines: readonly string[],
32
+ index: number,
33
+ token: string,
34
+ ): boolean {
35
+ const pattern = new RegExp(`${escapeForPattern(token)}:[ \\t]*\\S`)
36
+ const own = lines[index]
37
+ const above = index > 0 ? lines[index - 1] : undefined
38
+
39
+ return (
40
+ (own !== undefined && pattern.test(own)) ||
41
+ (above !== undefined && pattern.test(above))
42
+ )
43
+ }
@@ -0,0 +1,207 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { isBinary } from '@/binary'
4
+ import { isMarked } from '@/exempt-marker'
5
+ import { listRepositoryFiles } from '@/git-files'
6
+
7
+ /**
8
+ * The inline token exempting one line from this sweep, shaped on the
9
+ * `aitk-allow-secret` precedent and read by the same two-line rule.
10
+ *
11
+ * A declaration disagreeing with a convention for a stated reason is the case
12
+ * question 3 of the plan settled against gating on, and the marker is where
13
+ * that reason goes. A bare token names nothing a later reader can weigh, so it
14
+ * mutes nothing.
15
+ */
16
+ export const SUPERSEDED_MARKER = 'aitk-allow-superseded'
17
+
18
+ export interface SupersededHit {
19
+ readonly file: string
20
+ /** One-based, matching the `file:line` form a reader clicks. */
21
+ readonly line: number
22
+ readonly column: number
23
+ /**
24
+ * Whether the replacement sits on the same line, outside the superseded
25
+ * occurrences themselves.
26
+ *
27
+ * Read outside them because the replacement is routinely a substring of the
28
+ * value it replaced, which is what a convention dropping a segment produces.
29
+ * A plain containment test answers true for every line under that shape and
30
+ * separates nothing.
31
+ *
32
+ * A reading aid rather than a filter. The line stating the change carries
33
+ * both values and so does a fixture halfway through a repair, and nothing
34
+ * here can tell those apart. Suppressing on it would hide the second case,
35
+ * which is the whole class this sweep exists to reach.
36
+ */
37
+ readonly carriesReplacement: boolean
38
+ readonly preview: string
39
+ }
40
+
41
+ export interface SupersededOptions {
42
+ /** The value the convention used to produce, matched as a literal substring. */
43
+ readonly superseded: string
44
+ /** What it produces now, reported so a finding names what to write instead. */
45
+ readonly replacement: string
46
+ }
47
+
48
+ export type SupersededReport =
49
+ | {
50
+ readonly kind: 'measured'
51
+ readonly superseded: string
52
+ readonly replacement: string
53
+ /** Everything git listed, so the report states its own bound. */
54
+ readonly listed: number
55
+ /** Files opened, which is what the verdict actually covers. */
56
+ readonly files: number
57
+ /** Binary or unreadable, counted so a pass is not claimed over them. */
58
+ readonly skipped: number
59
+ readonly findings: readonly SupersededHit[]
60
+ readonly exempt: readonly SupersededHit[]
61
+ }
62
+ | { readonly kind: 'unreadable'; readonly reason: string }
63
+
64
+ /**
65
+ * The longest preview a hit carries. A generated or minified line is one line
66
+ * to git and a screen of noise to a reader, and the report prints one hit per
67
+ * line found.
68
+ */
69
+ const PREVIEW_LIMIT = 200
70
+
71
+ /**
72
+ * Whether `replacement` appears on the line somewhere other than inside the
73
+ * superseded occurrences, which is what makes the flag mean anything when one
74
+ * value contains the other.
75
+ *
76
+ * An empty replacement carries nothing, so it answers false rather than the
77
+ * true every line returns from a containment test against the empty string.
78
+ * Retiring a value outright is what passes one, and reporting every finding as
79
+ * carrying its replacement there says the opposite of what happened.
80
+ */
81
+ function carriesReplacement(line: string, options: SupersededOptions): boolean {
82
+ if (options.replacement === '') return false
83
+ return line.split(options.superseded).join('').includes(options.replacement)
84
+ }
85
+
86
+ /** Every occurrence of `superseded` in one file's text, exemptions separated. */
87
+ export function sweepText(
88
+ file: string,
89
+ text: string,
90
+ options: SupersededOptions,
91
+ ): { findings: SupersededHit[]; exempt: SupersededHit[] } {
92
+ const lines = text.split('\n')
93
+ const findings: SupersededHit[] = []
94
+ const exempt: SupersededHit[] = []
95
+
96
+ for (const [index, line] of lines.entries()) {
97
+ let column = line.indexOf(options.superseded)
98
+ if (column === -1) continue
99
+
100
+ const muted = isMarked(lines, index, SUPERSEDED_MARKER)
101
+ const bucket = muted ? exempt : findings
102
+ const trimmed = line.trim()
103
+ const preview =
104
+ trimmed.length > PREVIEW_LIMIT
105
+ ? `${trimmed.slice(0, PREVIEW_LIMIT)}…`
106
+ : trimmed
107
+ const alsoReplacement = carriesReplacement(line, options)
108
+
109
+ while (column !== -1) {
110
+ bucket.push({
111
+ file,
112
+ line: index + 1,
113
+ column: column + 1,
114
+ carriesReplacement: alsoReplacement,
115
+ preview,
116
+ })
117
+ column = line.indexOf(options.superseded, column + 1)
118
+ }
119
+ }
120
+
121
+ return { findings, exempt }
122
+ }
123
+
124
+ /**
125
+ * Where the tree still asserts a value the convention behind it no longer
126
+ * produces, keyed on the value rather than on the file stating the rule.
127
+ *
128
+ * The file-scoped map is what this exists against. A fixture asserting an old
129
+ * output names neither the rule nor the standard, so nothing reaches it from
130
+ * the change that superseded it, and the value it carries is the only key both
131
+ * sides share.
132
+ *
133
+ * This reports and never gates. A string appears for reasons unrelated to the
134
+ * convention, so the output is a reading a person settles, and the counts of
135
+ * what was listed, opened, and skipped are what keep it from reading as a
136
+ * verdict over the whole tree.
137
+ *
138
+ * What it cannot see is a prose reference that went stale without carrying the
139
+ * value, such as a declaration citing the wrong standard for the transform. A
140
+ * value sweep closes most of this class and no part of that one.
141
+ */
142
+ export async function readSuperseded(
143
+ root: string,
144
+ options: SupersededOptions,
145
+ ): Promise<SupersededReport> {
146
+ if (options.superseded === '') {
147
+ return {
148
+ kind: 'unreadable',
149
+ reason:
150
+ 'The superseded value is empty, which matches every line in the tree rather than a convention.',
151
+ }
152
+ }
153
+
154
+ if (options.superseded === options.replacement) {
155
+ return {
156
+ kind: 'unreadable',
157
+ reason: `The superseded value and its replacement are both ${options.superseded}, so no convention changed and there is nothing to sweep for.`,
158
+ }
159
+ }
160
+
161
+ const listed = await listRepositoryFiles(root)
162
+ if (listed === undefined) {
163
+ return {
164
+ kind: 'unreadable',
165
+ reason: `Git listed no corpus under ${root}. An empty list passes each of its zero files, so a tree git cannot read refuses rather than reporting clean.`,
166
+ }
167
+ }
168
+
169
+ const findings: SupersededHit[] = []
170
+ const exempt: SupersededHit[] = []
171
+ let files = 0
172
+ let skipped = 0
173
+
174
+ for (const path of listed) {
175
+ let text: string
176
+ try {
177
+ text = await readFile(join(root, path), 'utf8')
178
+ } catch {
179
+ // A listed path that will not open is a symlink leaving the tree or a
180
+ // file removed since git answered. Counted rather than reported, so the
181
+ // run still states that it measured less than it listed.
182
+ skipped += 1
183
+ continue
184
+ }
185
+
186
+ if (isBinary(text)) {
187
+ skipped += 1
188
+ continue
189
+ }
190
+
191
+ files += 1
192
+ const swept = sweepText(path, text, options)
193
+ findings.push(...swept.findings)
194
+ exempt.push(...swept.exempt)
195
+ }
196
+
197
+ return {
198
+ kind: 'measured',
199
+ superseded: options.superseded,
200
+ replacement: options.replacement,
201
+ listed: listed.length,
202
+ files,
203
+ skipped,
204
+ findings,
205
+ exempt,
206
+ }
207
+ }
@@ -1,3 +1,5 @@
1
+ import { isMarked } from '@/exempt-marker'
2
+
1
3
  /**
2
4
  * The inline token exempting one line from the secret scan.
3
5
  *
@@ -15,30 +17,14 @@
15
17
  */
16
18
  export const SECRET_MARKER = 'aitk-allow-secret'
17
19
 
18
- /**
19
- * Only a marker naming a reason counts.
20
- *
21
- * A bare token is read as a line that meant to say something and did not,
22
- * which is the rule `isStubSeed` already applies to a field set to anything
23
- * but `true`. Honoring it would let a typo mute a finding, and the reason is
24
- * the whole value of an exemption a later reader has to weigh.
25
- */
26
- const MARKER_LINE = new RegExp(`${SECRET_MARKER}:[ \\t]*\\S`)
27
-
28
20
  /**
29
21
  * Whether the line at `index` is exempt, reading itself and the line above it.
30
22
  *
31
- * Two lines rather than one, because a credential-shaped literal is as often
32
- * introduced by a preceding comment as annotated inline, and a format that
33
- * takes no trailing comment at all has nowhere else to put the marker. Nothing
34
- * further up counts, so a marker cannot silence a block it does not sit on.
23
+ * Where a marker may sit and what makes one count are `isMarked`, which the
24
+ * superseded-value sweep reads through the same call. Only the token differs
25
+ * between the two, so the placement rule has one answer rather than two that
26
+ * can drift.
35
27
  */
36
28
  export function isExempt(lines: readonly string[], index: number): boolean {
37
- const own = lines[index]
38
- const above = index > 0 ? lines[index - 1] : undefined
39
-
40
- return (
41
- (own !== undefined && MARKER_LINE.test(own)) ||
42
- (above !== undefined && MARKER_LINE.test(above))
43
- )
29
+ return isMarked(lines, index, SECRET_MARKER)
44
30
  }
@@ -1,5 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
+ import { isBinary } from '@/binary'
3
4
  import { listRepositoryFiles } from '@/git-files'
4
5
  import { isExempt } from '@/secrets/marker'
5
6
  import { matchLine } from '@/secrets/patterns'
@@ -48,18 +49,6 @@ export type SecretScan =
48
49
  }
49
50
  | { readonly kind: 'refused'; readonly reason: ScanRefusal }
50
51
 
51
- /**
52
- * Whether the bytes are something a line scanner should not read.
53
- *
54
- * A NUL byte rather than an extension list, since the shipped tree carries
55
- * fonts and images under names this check has no reason to enumerate, and a
56
- * list would go stale the first time a format was added. Decoded text holds no
57
- * NUL, so the test costs one scan and never rejects source.
58
- */
59
- export function isBinary(text: string): boolean {
60
- return text.includes('\0')
61
- }
62
-
63
52
  /** Every finding in one file's text, with the marker already applied. */
64
53
  export function scanText(file: string, text: string): SecretFinding[] {
65
54
  const lines = text.split('\n')
@@ -43,6 +43,16 @@ A decision's reasoning stays correct while the numbers it cites move. The anchor
43
43
  - Do not edit a claim in the pass that first anchors it. The anchor states what the claim was measured against, so changing both at once leaves nothing to check the anchor against.
44
44
  - Refresh the anchor whenever the number is re-read, whether or not it moved. A confirmed number and an unread one are the same text without the date.
45
45
 
46
+ ## Length
47
+
48
+ Every session pays for this file before any work starts, so it carries a budget. The budget counts decisions rather than lines, because a bare line total is satisfied by merging paragraph pairs and the merged paragraphs then fail the weight checkpoint in `markdown.md`. A file over budget is carrying too many decisions, not decisions written too long.
49
+
50
+ - Budget six lines per decision entry, being the H3, two paragraphs, and the blank lines separating them.
51
+ - Budget the frame outside `## Key technical decisions` separately, covering the H1, the overview, and the risks. State the number the project takes where it records the budget, since a frame carries no fixed structure to derive one from.
52
+ - Read the ceiling as the frame plus six lines against the decision count, rather than as the total the two multiply out to.
53
+ - Bring an over-budget file back by merging two decisions or retiring one, never by compressing a decision's prose.
54
+ - Yield the budget to the paragraph weight checkpoint when the two disagree. A paragraph past the checkpoint is a defect no budget licenses.
55
+
46
56
  ## Template
47
57
 
48
58
  The anchor sentence closes a decision whose reasoning cites a measured number and is absent from one that cites none.
package/standards/slug.md CHANGED
@@ -29,3 +29,11 @@ The empty result is a detached HEAD, and the surface picks one of three response
29
29
  - Fall back to `latest`, so a read-only pass still writes somewhere predictable
30
30
  - Stop, when the surface 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 surface's guards.
31
31
  - Fall through to the next source, when the slug is one candidate among several rather than the name of an output file
32
+
33
+ ## Changing the transform
34
+
35
+ A change here moves what the transform produces, and every declaration asserting the old output keeps asserting it. Nothing reports those, because a fixture carrying an old filename names neither this file nor the surface that reads it, so a diff over the change reaches none of them. Dropping the type segment is the change that proved it: four fixtures and three scenario scripts kept the type-carrying spelling, one surfaced as a red arm months later, and the other six were found by deriving from the rule.
36
+
37
+ Run `aitk gov superseded <old> <new>` in the same change, with the old output and the new one as the two arguments. The value is the only key both sides carry, and the sweep reads the whole tracked tree rather than a path someone predicted. Its output is a reading rather than a verdict, since a string appears for reasons unrelated to any convention, and a declaration disagreeing on purpose carries an `aitk-allow-superseded: <reason>` marker on its own line or the one above.
38
+
39
+ The sweep sees a value and nothing else. A prose reference that went stale without carrying it, such as a passage citing the wrong standard for the transform, is reached by reading rather than by any key.
@@ -22,6 +22,7 @@
22
22
  - Do not add features the user did not ask for
23
23
  - When rewriting a section, preserve existing code blocks, tables, and grouped examples unless the user asked to remove them
24
24
  - When planning an edit to `CLAUDE.md`, show the proposed change as a fenced `diff` block in chat first, then wait for approval before calling `Edit`
25
+ - Keep a rule in `CLAUDE.md` when it applies every session regardless of what is being edited. Move one that fires only on a specific path being edited and ships silently when violated into `.claude/rules/`.
25
26
  - Edit an existing file with the file-editing tool, never a shell stream editor. An unescaped `&` in a `sed` replacement expands to the whole match, and `sed -i` exits zero when its pattern matches nothing, so both fail silently while reporting success. This governs edits you make, not stream editors written into a project's own scripts.
26
27
 
27
28
  ## Indexes