@erclx/aitk 0.31.0 → 0.33.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": "0.31.0",
4
+ "version": "0.33.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -0,0 +1,36 @@
1
+ ---
2
+ name: bash-script
3
+ description: Scope boundary for the interactive script house style and the stream discipline underneath it
4
+ ---
5
+
6
+ # Bash script requirement
7
+
8
+ ## Gap
9
+
10
+ Without this skill, a session asked for a human-facing shell tool writes one whose frame decoration, log lines, and prompts all land on stdout, so the script cannot be piped and any data it emits arrives mixed with its own presentation.
11
+
12
+ Three more failures share a cause, which is that an interactive script is written as though a person is always watching. A prompt blocks forever when stdin is not a terminal, which is the shape every CI job and every agent run has. A cancellation path prints both a cancel line and an error, or an exit trap prints a closing frame the success path already printed, so the same run reports twice. And each generated script picks its own icons, casing, and frame characters, so a folder of them reads as several unrelated tools.
13
+
14
+ The visual conventions this skill fixes are otherwise arbitrary. Their value is that they are the same across every script, which is a property no individual script can establish for itself.
15
+
16
+ ## Must
17
+
18
+ - Keep every frame, log line, and prompt on stderr, and reserve stdout for data. `--help` is the one exception, since its consumer is a person rather than a pipe.
19
+ - Guard every prompt on an attached terminal and refuse inside the frame rather than blocking on a read that will never return
20
+ - Give the timeline one owner per exit path, so success, cancellation, and error each close it exactly once
21
+ - Copy the shared templates rather than restating them, keeping only the colors and functions the script uses
22
+
23
+ ## Must not
24
+
25
+ - Assert a convention the generated script cannot be checked against by reading it. A rule that cannot be verified from the output is style with no gate behind it.
26
+ - Carry a second copy of the timeline, logging, or prompt implementations. The bundled reference is the single copy, and a body that restates it drifts from it.
27
+
28
+ ## Guards
29
+
30
+ - A request for a script with no human at the terminal stops and routes to `cli-script` rather than generating a timeline nothing will render
31
+
32
+ ## Out of scope
33
+
34
+ - Non-interactive automation, CI, and agent-run scripts: `cli-script`, which keeps the error handling and the stdout contract and drops the timeline, the icons, and the prompts
35
+ - GitHub Actions workflow files: `ci-workflow`
36
+ - What the generated script does. This skill fixes the shape of the output and the stream it goes to, and the commands belong to the request.
@@ -0,0 +1,35 @@
1
+ ---
2
+ name: ci-workflow
3
+ description: Scope boundary for pipeline structure against job contents, and the reproducibility rules under it
4
+ ---
5
+
6
+ # CI workflow requirement
7
+
8
+ ## Gap
9
+
10
+ Without this skill, a session writes a pipeline whose jobs run in sequence because `needs` was used to express the order a person reads them in rather than a data dependency. The pipeline then costs the sum of its jobs where it could have cost the longest one, and the waste compounds on every push.
11
+
12
+ The rest are reproducibility failures that surface as flakes. An action pinned to a moving ref changes under the project, so a run that passed yesterday fails today with no commit behind it and the diff explains nothing. A cache keyed on a static string serves a stale browser or toolchain after a version bump, and the failure reads as a broken test rather than a stale cache. Artifacts upload on every run and never expire, so storage grows with the commit count while the ones worth reading are the failures. And a workflow with no manual trigger can only be reproduced by pushing a commit, which is the wrong instrument for a run that failed for an environmental reason.
13
+
14
+ ## Must
15
+
16
+ - Gate a job only on a data dependency or on a cost that justifies the wait, and leave every other job parallel
17
+ - Pin every action to a tag that cannot cross a major version
18
+ - Key a cache on the version string of the thing it caches
19
+ - Give every workflow a manual trigger beside its primary one
20
+ - Bound artifact upload to failures and set an expiry on it
21
+
22
+ ## Must not
23
+
24
+ - Own what runs inside a job. The build, test, and deploy commands come from the project, and a workflow asserting its own is a second copy of the project's scripts.
25
+ - Enumerate the job set as a fixed list. Projects add and remove jobs, and what has to survive that is the parallel and gated structure rather than the roster.
26
+
27
+ ## Guards
28
+
29
+ - A request for a deploy, publish, or release step stops at the gate. The job's contents carry credentials and an environment this skill cannot see, so it emits the `needs` wiring and names what the caller has to fill in rather than guessing a deploy command.
30
+
31
+ ## Out of scope
32
+
33
+ - The shell scripts a job invokes: `cli-script`
34
+ - Secrets, environments, and deploy targets, which live in the repository settings rather than in the workflow this skill writes
35
+ - CI systems other than GitHub Actions. The structure rules generalize and the file format does not, so a different system is a different skill rather than a flag on this one.
@@ -0,0 +1,40 @@
1
+ ---
2
+ name: claude-seed-sync
3
+ description: Scope boundary for section-granular seed reconciliation against the bulk install and sync commands
4
+ ---
5
+
6
+ # Claude seed sync requirement
7
+
8
+ ## Gap
9
+
10
+ Without this skill, a project that edited an installed seed or standard has two ways to take an upstream change and both lose something. `aitk standards install` overwrites every file, so the edits are gone with no record of what they were. `aitk standards sync` updates only files it already finds and adds none, so a standard written after the project installed never arrives at all. Neither can deliver one upstream section into a file the project has customized, which is the case a grown project is always in.
11
+
12
+ A whole-file diff does not close it either. It cannot separate a section the user rewrote on purpose from a section the toolkit moved on without them, so the choice reaches the user as accept everything or lose everything, and the safe answer is always to skip.
13
+
14
+ Two failures belong to the audit rather than to the diff. A decision taken in chat dies with the session, so an audit half applied cannot be resumed and the second run starts over. And a file the toolkit generates rather than ships, such as an index rebuilt from sibling frontmatter, is absent from every source catalog by design, so a naive comparison reports it as a local addition and invites the user to reconcile something nothing owns.
15
+
16
+ ## Must
17
+
18
+ - Read seed and standard content from the CLI rather than holding a copy, so the audit and the install cannot disagree
19
+ - Diff per section, treating the preamble as a section of its own
20
+ - Separate a customized section from a stale one, and default the customized one to no action
21
+ - Persist the proposal and every decision to a review file that stays the source of truth across re-pings
22
+ - Apply one section at a time, never by rewriting a file
23
+
24
+ ## Must not
25
+
26
+ - Propose removing a section present only in the target. Those are the customizations the skill exists to preserve.
27
+ - Write a target file before a decision is recorded against the item
28
+ - Read an empty decision slot as consent
29
+
30
+ ## Guards
31
+
32
+ - No `aitk` on PATH, or no `.claude/` directory at the project root, stops before any read
33
+
34
+ ## Out of scope
35
+
36
+ - Bulk install and sync of a whole domain, which `aitk <domain> install` and `aitk <domain> sync` own and `toolkit-cli` documents. Reach for this skill when the target holds edits worth keeping, and for those commands when it does not.
37
+ - Golden configs, which overwrite by design and carry no section structure to diff
38
+ - Governance rules: `aitk gov sync`
39
+ - First-time scaffold of a project that has installed nothing yet: `setup-init`
40
+ - Public `README.md` and `docs/` prose: `docs-sync`
@@ -0,0 +1,38 @@
1
+ ---
2
+ name: docs-sync
3
+ description: Scope boundary for consumer-facing prose against the agent-facing docs the ship chain syncs beside it
4
+ ---
5
+
6
+ # Docs sync requirement
7
+
8
+ ## Gap
9
+
10
+ Without this skill, a rename or a dropped flag ships with the README still describing the old surface. Nothing fails, because no test reads prose, so the doc stays wrong until a person hits it.
11
+
12
+ Three failures belong to the sync itself rather than to the drift. A session that syncs by rewriting whole files churns sections the change never reached, which costs the reviewer the ability to tell the sync from the feature. A doc edited earlier in the same session reads as current while a later change leaves one of its sections stale, so file-level classification passes where section-level would not. And a hardcoded doc list never checks the file someone added after the list was written.
13
+
14
+ The quiet one is the baseline. A diff resolved against a bare local ref equals HEAD on `main` and on a branch before its first commit, so every committed change drops out of the set. The skill then reports nothing to sync, which reads as a clean result rather than as an admission that it could not see the work.
15
+
16
+ ## Must
17
+
18
+ - Resolve one merge base, prefer the remote ref over the local one, and reuse it everywhere the skill reads the diff
19
+ - Say so in the output when the baseline degrades, rather than reporting a clean pass off a set it could not build
20
+ - Discover the doc set by glob at run time
21
+ - Classify and rewrite at section level, so a partly stale file is partly rewritten
22
+ - Write immediately after the preview, since the tool permission dialog is the confirmation gate
23
+
24
+ ## Must not
25
+
26
+ - Touch a section the change does not reach
27
+ - Rewrite a doc to match the diff when the doc records an intent the diff departed from. That is a finding rather than a sync.
28
+
29
+ ## Guards
30
+
31
+ - No committed change and no working-tree change stops the skill before it reads any doc
32
+
33
+ ## Out of scope
34
+
35
+ - `.claude/` planning docs, tasks, plans, and context entries: `claude-docs`, which runs immediately before this skill in the ship chain and resolves the same baseline. The split is by audience, so a file's location decides which skill owns it rather than its subject.
36
+ - `CLAUDE.md` and the installed seed docs: `claude-seed-sync`, which reconciles them per section against the toolkit source
37
+ - Changelog entries, which release tooling generates from commit messages
38
+ - Whether the prose conforms to its standards. `claude-standards-audit` reports violations and fixes none, and this skill writes prose it does not audit.
package/docs/agents.md CHANGED
@@ -391,11 +391,15 @@ Exit codes are `0` for a clean run, `1` for a refusal, and `2` for an unresolved
391
391
 
392
392
  ### What each check reports
393
393
 
394
- Length and depth quote their checkpoints from `.claude/standards/context.md`: roughly 150 lines for an entry, roughly 40 for a run of lines no heading breaks. Depth measures the longest such run rather than everything under one `##`, skips fenced blocks so a markdown example does not read as three headings, and exempts a run whose lines are all list items at one indent. Runs count blank lines, which the standard leaves open, so a hand reader who drops them lands a line or two lower. The report states the convention on every run.
394
+ Length and depth quote their checkpoints from `.claude/standards/context.md`: roughly 150 rendered lines for an entry, roughly 40 for a run no heading breaks. Depth measures the longest such run rather than everything under one `##`, skips fenced blocks so a markdown example does not read as three headings, and exempts a run whose lines are all list items at one indent averaging under 130 characters. The weight condition is what separates a scannable catalog of one-liners from a stack of paragraph-bullets, which reach the same count and read nothing alike.
395
+
396
+ Both checks count rendered lines rather than source lines, wrapping each line at 80 columns and summing the heights. Entries here are authored one line per bullet, so a block of fifteen paragraph-bullets occupies fifteen source lines and renders past sixty, which source counting cannot see. Measuring one checkpoint in each unit would put an entry length beside a run length that mean different things. Their exclusions still differ: the file measure counts fenced blocks and frontmatter, while the run measure skips a fence so an example cannot break the run around it. A reference-heavy entry therefore ranks by its examples, which the length legend states on every run. Runs count blank lines, which the standard leaves open, so a hand reader who drops them lands a line or two lower. Both sections state the width on every run, since a number in rendered lines cannot be reproduced without it.
395
397
 
396
398
  The table check reports a catalog that grows a row per shipped thing, not a table count. A fixed comparison table never reflows, so its size costs nothing. A table qualifies at six or more body rows whose first column mostly carries a path, command, or link, which is what separates a catalog from a comparison without reading the prose.
397
399
 
398
- The provenance check reports the markers narrating how a domain reached its shape rather than describing what it is: a date, a change number, or a release label. The standard admits a rejected alternative and the reasoning that killed it while refusing the provenance attached to it, so a marker names a line to read rather than a line to delete. Findings group by entry and sort left to right within a line, since what a reader acts on is which file to open. Fenced blocks are excluded, which keeps a pinned version in an install command from reading as a claim the entry makes. Frontmatter is excluded with them, since every check reads the body alone, and that is what keeps a diagram entry's dated `verified` stamp a record of its last check rather than a marker to settle.
400
+ The provenance check reports the markers narrating how a domain reached its shape rather than describing what it is: a date, a change number, or a release label. The standard admits a rejected alternative and the reasoning that killed it while refusing the provenance attached to it, so a marker names a line to read rather than a line to delete. Findings group by entry and sort left to right within a line, since what a reader acts on is which file to open. Fenced blocks are excluded, which keeps a pinned version in an install command from reading as a claim the entry makes. Frontmatter is excluded with them, since the content checks read the body alone, and that is what keeps a diagram entry's dated `verified` stamp a record of its last check rather than a marker to settle. Length is the exception, counting the whole file, so a reader applying the 150-rendered-line checkpoint against the body alone lands a few lines under what the tool reports.
401
+
402
+ This one check covers `.claude/context/` alone, while length, depth, and the table finding reach every audited folder. The rule is stated in `.claude/standards/context.md`, which opens its scope by handing diagrams and wireframes to `diagrams.md` and `wireframes.md`, and the sibling standards do not restate it. A marker reported in a diagram entry would cite a rule that entry's own standard routes elsewhere. The split is between kinds of rule rather than kinds of folder: a threshold on how far a reader travels generalizes across entry types, while a rule about what an entry may say is the jurisdiction a scope statement exists to settle. The scoping key is the folder an entry was audited under, so `--folder` still reaches a folder the default list does not carry, and a domain split into `context/<sub-area>/` is governed as `context`. Every run states the reach, including a run where no audited folder is the governed one. The JSON record carries it as `checkpoints.provenanceFolder` and a per-folder `governsContent`.
399
403
 
400
404
  Index drift compares an index against its siblings in both directions. An entry the index does not link is invisible to a session choosing what to open, and a linked name resolving to nothing sends one to a path that opens nothing.
401
405
 
@@ -411,15 +415,15 @@ What remains is a sentence naming a hypothetical entry to show the shape of a na
411
415
 
412
416
  Use these to discover what's available instead of hardcoding names.
413
417
 
414
- | Command | Returns |
415
- | -------------------------------- | -------------------------------------------- |
416
- | `aitk tooling list --json` | Stacks, extends chain, dep and script counts |
417
- | `aitk snippets list --json` | Presets and categories with their slugs |
418
- | `aitk standards list --json` | Standards docs and the paths each governs |
419
- | `aitk gov list --json` | Governance stacks and rule sets |
420
- | `aitk claude seeds list --json` | Seed doc sources with content |
421
- | `aitk claude skills list --json` | Plugin skills with their descriptions |
422
- | `aitk docs list --json` | Consumer docs plus per-domain context |
418
+ | Command | Returns |
419
+ | -------------------------------- | --------------------------------------------- |
420
+ | `aitk tooling list --json` | Stacks, extends chain, dep and script counts |
421
+ | `aitk snippets list --json` | Presets and categories with their slugs |
422
+ | `aitk standards list --json` | Standards docs and the paths each governs |
423
+ | `aitk gov list --json` | Governance stacks and rule sets |
424
+ | `aitk claude seeds list --json` | Seed doc sources with content |
425
+ | `aitk claude skills list --json` | Plugin skills, descriptions, requirement flag |
426
+ | `aitk docs list --json` | Consumer docs plus per-domain context |
423
427
 
424
428
  Every catalog serializes through `JSON.stringify`, so a name carrying a quote
425
429
  emits valid JSON. `aitk tooling list` and `aitk snippets list` previously built
@@ -446,6 +450,12 @@ or unparseable returns an empty description rather than failing the listing, so
446
450
  one malformed file cannot hide the rest of the catalog. `--names` emits skill
447
451
  names one per line.
448
452
 
453
+ Each entry also carries `requirement`, whether the folder holds a sibling
454
+ `REQUIREMENT.md`. Coverage of that file is selective by design, so a `false` is
455
+ not a gap to close and the flag answers which skills carry one without a caller
456
+ listing the directory itself. It says nothing about why, which is a judgment the
457
+ toolkit records against its own corpus rather than in the catalog.
458
+
449
459
  ## Non-interactive examples
450
460
 
451
461
  ```bash
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "0.31.0",
4
+ "version": "0.33.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -6,6 +6,7 @@ const FRONTMATTER = /^---\n([\s\S]*?)\n---/
6
6
  export interface SkillListing {
7
7
  readonly name: string
8
8
  readonly description: string
9
+ readonly requirement: boolean
9
10
  }
10
11
 
11
12
  /**
@@ -15,6 +16,10 @@ export interface SkillListing {
15
16
  *
16
17
  * The folder name wins over the frontmatter `name` when they disagree, because
17
18
  * Claude Code invokes a skill by its directory.
19
+ *
20
+ * `requirement` reports whether the folder carries `REQUIREMENT.md`. Coverage is
21
+ * selective by design, so a false is not a gap to close and this field carries no
22
+ * reason for one. The toolkit records those separately from the catalog.
18
23
  */
19
24
  export function listSkills(root: string): SkillListing[] {
20
25
  const skillsRoot = join(root, 'claude', 'skills')
@@ -30,6 +35,7 @@ export function listSkills(root: string): SkillListing[] {
30
35
  return paths.map((path) => ({
31
36
  name: dirname(path),
32
37
  description: readDescription(join(skillsRoot, path)),
38
+ requirement: existsSync(join(skillsRoot, dirname(path), 'REQUIREMENT.md')),
33
39
  }))
34
40
  }
35
41
 
@@ -2,8 +2,12 @@ import { resolve } from 'node:path'
2
2
  import type { Command } from 'commander'
3
3
  import {
4
4
  type EntryReport,
5
+ governsContent,
5
6
  LENGTH_CHECKPOINT,
6
7
  measureFolders,
8
+ PEER_BULLET_CHECKPOINT,
9
+ PROVENANCE_FOLDER,
10
+ RENDER_WIDTH,
7
11
  RUN_CHECKPOINT,
8
12
  } from '@/context/audit'
9
13
  import { auditCitations, type CitationReport } from '@/context/citations'
@@ -88,8 +92,8 @@ function parseFolders(list: string | undefined): string[] | string {
88
92
 
89
93
  if (names.length === 0) return 'Empty --folder list. Pass at least one name.'
90
94
 
91
- // `..` would resolve the audit root above `.claude/`, where `presentNames`
92
- // has no folder name to slice out and hands the citation pattern undefined.
95
+ // `..` would resolve the audited folder above `.claude/`, taking the scan
96
+ // and the citation pattern outside the tree the audit describes.
93
97
  const invalid = names.filter((name) => !FOLDER_NAME.test(name))
94
98
  if (invalid.length > 0) {
95
99
  return `--folder takes folder names under .claude/, not paths: ${invalid.join(', ')}`
@@ -136,7 +140,7 @@ async function runAudit(
136
140
  reportLength(entries)
137
141
  reportDepth(entries)
138
142
  reportTables(entries)
139
- reportProvenance(entries)
143
+ reportProvenance(entries, folders)
140
144
  reportDrift(drift)
141
145
  outro()
142
146
  }
@@ -148,6 +152,7 @@ async function runAudit(
148
152
  folders: folders.map((folder) => ({
149
153
  path: folder.rel,
150
154
  entries: folder.entries.length,
155
+ governsContent: governsContent(folder),
151
156
  })),
152
157
  citations: {
153
158
  scanned: citations.scanned,
@@ -160,6 +165,9 @@ async function runAudit(
160
165
  lines: LENGTH_CHECKPOINT,
161
166
  run: RUN_CHECKPOINT,
162
167
  runCountsBlankLines: true,
168
+ renderWidth: RENDER_WIDTH,
169
+ peerBullet: PEER_BULLET_CHECKPOINT,
170
+ provenanceFolder: PROVENANCE_FOLDER,
163
171
  },
164
172
  })}\n`,
165
173
  )
@@ -241,6 +249,12 @@ function reportCitations(report: ScannedCitations): void {
241
249
 
242
250
  function reportLength(entries: readonly EntryReport[]): void {
243
251
  logStep('Length')
252
+ logInfo(
253
+ `Entries measure rendered lines at ${RENDER_WIDTH} columns, counting frontmatter and fenced blocks.`,
254
+ )
255
+ logInfo(
256
+ 'A reference-heavy entry therefore ranks by its examples, which the depth check excludes.',
257
+ )
244
258
 
245
259
  const over = entries
246
260
  .filter((entry) => entry.lines > LENGTH_CHECKPOINT)
@@ -253,20 +267,29 @@ function reportLength(entries: readonly EntryReport[]): void {
253
267
 
254
268
  logWarn(`${over.length} past the ${LENGTH_CHECKPOINT}-line checkpoint`)
255
269
  pipeOutput(
256
- over.map((entry) => `${entry.rel} ${entry.lines} lines`).join('\n'),
270
+ over
271
+ .map((entry) => `${entry.rel} ${entry.lines} rendered lines`)
272
+ .join('\n'),
257
273
  )
258
274
  }
259
275
 
260
276
  /**
261
- * Names the blank-line convention on every run.
277
+ * Names the render width and the blank-line convention on every run.
262
278
  *
263
279
  * The standard settles heading level and fenced blocks and stops there, so a
264
280
  * hand reader who drops blank lines lands a line or two below this number.
265
- * Stating it is what keeps the two measurements reconcilable.
281
+ * Stating both is what keeps the two measurements reconcilable, and the width
282
+ * matters more than the blank lines because a number counted in rendered lines
283
+ * cannot be reproduced without it.
266
284
  */
267
285
  function reportDepth(entries: readonly EntryReport[]): void {
268
286
  logStep('Depth')
269
- logInfo('Runs count blank lines. Fenced blocks and peer lists are excluded.')
287
+ logInfo(
288
+ `Runs measure rendered lines at ${RENDER_WIDTH} columns and count blank lines.`,
289
+ )
290
+ logInfo(
291
+ `Fenced blocks are excluded, and so are peer lists averaging under ${PEER_BULLET_CHECKPOINT} characters a bullet.`,
292
+ )
270
293
 
271
294
  const over = entries
272
295
  .filter((entry) => entry.longestRun > RUN_CHECKPOINT)
@@ -282,7 +305,7 @@ function reportDepth(entries: readonly EntryReport[]): void {
282
305
  over
283
306
  .map(
284
307
  (entry) =>
285
- `${entry.rel}:${entry.longestRunLine} ${entry.longestRun} lines unbroken`,
308
+ `${entry.rel}:${entry.longestRunLine} ${entry.longestRun} rendered lines unbroken`,
286
309
  )
287
310
  .join('\n'),
288
311
  )
@@ -313,9 +336,28 @@ function reportTables(entries: readonly EntryReport[]): void {
313
336
  * time, and a flat list of those buries the entries holding one. What a reader
314
337
  * acts on is which file to open, so the count sits beside the name and the
315
338
  * lines follow it.
339
+ *
340
+ * The reach is stated on every run, including the run where nothing is in
341
+ * scope. A check that covered three folders and now covers one reads as quietly
342
+ * missing things unless the report says which folder it measured.
316
343
  */
317
- function reportProvenance(entries: readonly EntryReport[]): void {
344
+ function reportProvenance(
345
+ entries: readonly EntryReport[],
346
+ folders: readonly AuditedFolder[],
347
+ ): void {
318
348
  logStep('Provenance')
349
+
350
+ const governed = folders.filter(governsContent)
351
+ if (governed.length === 0) {
352
+ logInfo(
353
+ `Out of scope. The rule is stated in the standard governing .claude/${PROVENANCE_FOLDER}/, and no audited folder is that one.`,
354
+ )
355
+ return
356
+ }
357
+
358
+ logInfo(
359
+ `Covers .claude/${PROVENANCE_FOLDER}/ alone, whose standard carries the rule. The sibling standards do not restate it.`,
360
+ )
319
361
  logInfo('Fenced blocks are excluded. A marker is a judgment, never a defect.')
320
362
 
321
363
  const carrying = entries
@@ -6,6 +6,27 @@ import type { AuditedFolder } from '@/context/folders'
6
6
  export const LENGTH_CHECKPOINT = 150
7
7
  export const RUN_CHECKPOINT = 40
8
8
 
9
+ /**
10
+ * Columns a source line wraps at when rendered.
11
+ *
12
+ * Nothing in this repository sets a line width and entries are authored one
13
+ * line per bullet, so the rendered width is the viewer's rather than the file's.
14
+ * The common terminal and diff width is the reproducible choice, and the report
15
+ * legend states it so a reader can arrive at the same number by hand.
16
+ */
17
+ export const RENDER_WIDTH = 80
18
+
19
+ /**
20
+ * Characters a bullet averages before its list stops reading as a set of peers.
21
+ *
22
+ * The exemption below covers a flat catalog of one-liners and a stack of
23
+ * paragraphs equally, and only the first is navigable. Measured across this
24
+ * corpus the two shapes separate with nothing between roughly 100 and 170
25
+ * characters a bullet, so the midpoint splits the population rather than a
26
+ * continuum. It is a checkpoint like the two above, not a cap.
27
+ */
28
+ export const PEER_BULLET_CHECKPOINT = 130
29
+
9
30
  /**
10
31
  * A table this size or larger whose first column mostly names artifacts reads
11
32
  * as a catalog that grows a row per shipped thing, which is the shape the
@@ -40,6 +61,25 @@ const PROVENANCE: readonly { kind: ProvenanceKind; pattern: RegExp }[] = [
40
61
  { kind: 'release', pattern: /\bv\d+\.\d+(?:\.\d+)?\b/g },
41
62
  ]
42
63
 
64
+ /**
65
+ * The folder whose standard carries the exclusion above.
66
+ *
67
+ * `standards/context.md` opens its scope by handing diagrams and wireframes to
68
+ * `diagrams.md` and `wireframes.md`, so a marker reported in either would cite
69
+ * a rule that entry's own standard routes elsewhere. The length, depth, and
70
+ * table checkpoints are quoted from the same standard and keep reaching every
71
+ * audited folder, because a threshold on how far a reader travels generalizes
72
+ * across entry types while a rule about what an entry may say does not.
73
+ *
74
+ * Restating the exclusion in the sibling standards was the alternative. It
75
+ * duplicates one knowledge item across three surfaces, which the root
76
+ * instruction file forbids, and pointing is not available because the surface
77
+ * they would point at is the one disclaiming them. Should a diagram entry ever
78
+ * accumulate narration, the escalation is an attribute standard owning the rule
79
+ * across document types, not restoring this reach without an owner.
80
+ */
81
+ export const PROVENANCE_FOLDER = 'context'
82
+
43
83
  export type ProvenanceKind = 'date' | 'change' | 'release'
44
84
 
45
85
  export interface TableFinding {
@@ -56,11 +96,25 @@ export interface ProvenanceFinding {
56
96
 
57
97
  export interface EntryReport {
58
98
  readonly rel: string
99
+ /**
100
+ * Rendered lines across the whole file, counting frontmatter and fenced
101
+ * blocks. Both this and `longestRun` measure in the same unit, since the two
102
+ * checkpoints they feed sit in one section of the standard and a reader
103
+ * compares them.
104
+ *
105
+ * The exclusions differ on purpose. `longestRun` skips a fence so an example
106
+ * cannot break the run around it, and a file measure has no run to protect.
107
+ * Excluding fences here would change which entries report by one and would
108
+ * not reach the case that motivates it: the most fenced entry in the corpus
109
+ * runs 20 percent fenced and sits past the checkpoint either way.
110
+ */
59
111
  readonly lines: number
112
+ /** Rendered lines at `RENDER_WIDTH`, not source lines. */
60
113
  readonly longestRun: number
61
114
  /** First line of the longest run, or 0 when the entry has no run at all. */
62
115
  readonly longestRunLine: number
63
116
  readonly catalogTables: readonly TableFinding[]
117
+ /** Empty for an entry no standard bans a change narrative in. */
64
118
  readonly provenance: readonly ProvenanceFinding[]
65
119
  }
66
120
 
@@ -90,24 +144,44 @@ function bodyLines(source: string): BodyLine[] {
90
144
  *
91
145
  * Every non-blank line has to be a list item at one indent. Prose mixed into
92
146
  * the run or a nested level inside it ends the exemption, because either one
93
- * means the block is no longer a flat set a reader can skim.
147
+ * means the block is no longer a flat set a reader can skim. Bullet count says
148
+ * nothing on its own, since a catalog of one-liners and a wall of paragraphs
149
+ * reach the same count and read nothing alike, so the average bullet is what
150
+ * decides whether the set is still skimmable.
94
151
  */
95
- function isPeerList(run: readonly BodyLine[]): boolean {
152
+ function isScannablePeerList(run: readonly BodyLine[]): boolean {
96
153
  const indents = new Set<number>()
154
+ let items = 0
155
+ let characters = 0
97
156
 
98
157
  for (const line of run) {
99
- if (line.text.trim() === '') continue
158
+ const text = line.text.trim()
159
+ if (text === '') continue
100
160
 
101
161
  const match = line.text.match(LIST_ITEM)
102
162
  if (!match) return false
103
163
  indents.add(match[1].length)
164
+ items++
165
+ characters += text.length
104
166
  }
105
167
 
106
- return indents.size === 1
168
+ if (indents.size !== 1) return false
169
+
170
+ return characters / items < PEER_BULLET_CHECKPOINT
107
171
  }
108
172
 
109
173
  /**
110
- * Measures the longest run of lines no heading breaks.
174
+ * Height a source line occupies once wrapped.
175
+ *
176
+ * A blank line renders as the gap it is rather than as nothing, which keeps it
177
+ * the distance the source measure already counted it as.
178
+ */
179
+ function renderedHeight(text: string): number {
180
+ return Math.max(1, Math.ceil(text.length / RENDER_WIDTH))
181
+ }
182
+
183
+ /**
184
+ * Measures the longest run of lines no heading breaks, in rendered lines.
111
185
  *
112
186
  * Fenced blocks are skipped rather than treated as breaks, per the standard:
113
187
  * they leave the count without ending the run, so prose either side of an
@@ -116,6 +190,11 @@ function isPeerList(run: readonly BodyLine[]): boolean {
116
190
  * between signposts and a blank line is distance like any other. A hand reader
117
191
  * measuring without them lands one or two lines lower, which the report legend
118
192
  * states.
193
+ *
194
+ * Height is what a reader travels, and source lines only stand in for it while
195
+ * lines stay short. An entry authored one line per bullet puts a paragraph on
196
+ * each, so a block of fifteen bullets measures as fifteen and renders past
197
+ * sixty. Wrapping every line at a stated width is what closes that gap.
119
198
  */
120
199
  function longestRun(lines: readonly BodyLine[]): {
121
200
  length: number
@@ -132,9 +211,16 @@ function longestRun(lines: readonly BodyLine[]): {
132
211
  // two headings rather than a stretch a reader travels, so it never counts.
133
212
  const first = run.find((line) => line.text.trim() !== '')
134
213
 
135
- if (first && run.length > longest && !isPeerList(run)) {
136
- longest = run.length
137
- longestLine = first.number
214
+ if (first && !isScannablePeerList(run)) {
215
+ const height = run.reduce(
216
+ (sum, line) => sum + renderedHeight(line.text),
217
+ 0,
218
+ )
219
+
220
+ if (height > longest) {
221
+ longest = height
222
+ longestLine = first.number
223
+ }
138
224
  }
139
225
  run = []
140
226
  }
@@ -254,17 +340,31 @@ function provenance(lines: readonly BodyLine[]): ProvenanceFinding[] {
254
340
  .map((each) => each.finding)
255
341
  }
256
342
 
257
- export function measureEntry(rel: string, source: string): EntryReport {
343
+ /**
344
+ * Measures one entry, scanning for provenance only when a standard claims it.
345
+ *
346
+ * The caller passes jurisdiction rather than deriving it from `rel`, because a
347
+ * path prefix hardcodes what `--folder` exists to override and misses a domain
348
+ * split into `context/<sub-area>/`.
349
+ */
350
+ export function measureEntry(
351
+ rel: string,
352
+ source: string,
353
+ governsContent = true,
354
+ ): EntryReport {
258
355
  const lines = bodyLines(source)
259
356
  const run = longestRun(lines)
260
357
 
261
358
  return {
262
359
  rel,
263
- lines: source.replace(/\n$/, '').split('\n').length,
360
+ lines: source
361
+ .replace(/\n$/, '')
362
+ .split('\n')
363
+ .reduce((sum, text) => sum + renderedHeight(text), 0),
264
364
  longestRun: run.length,
265
365
  longestRunLine: run.line,
266
366
  catalogTables: catalogTables(lines),
267
- provenance: provenance(lines),
367
+ provenance: governsContent ? provenance(lines) : [],
268
368
  }
269
369
  }
270
370
 
@@ -272,6 +372,9 @@ export function measureEntry(rel: string, source: string): EntryReport {
272
372
  * Measures every entry in the audited folders. A generated `index.md` is not
273
373
  * among them, since its body is rewritten on every regen and no checkpoint
274
374
  * describes a catalog.
375
+ *
376
+ * Jurisdiction is applied here rather than at the report, so the JSON record
377
+ * and the printed run agree on which entries a content rule reached.
275
378
  */
276
379
  export async function measureFolders(
277
380
  root: string,
@@ -282,10 +385,19 @@ export async function measureFolders(
282
385
  for (const folder of folders) {
283
386
  for (const path of folder.entries) {
284
387
  reports.push(
285
- measureEntry(relative(root, path), await readFile(path, 'utf8')),
388
+ measureEntry(
389
+ relative(root, path),
390
+ await readFile(path, 'utf8'),
391
+ governsContent(folder),
392
+ ),
286
393
  )
287
394
  }
288
395
  }
289
396
 
290
397
  return reports
291
398
  }
399
+
400
+ /** Reports whether the folder's standard is the one carrying the exclusion. */
401
+ export function governsContent(folder: AuditedFolder): boolean {
402
+ return folder.name === PROVENANCE_FOLDER
403
+ }
@@ -18,6 +18,13 @@ export const DEFAULT_FOLDERS: readonly string[] = [
18
18
  ]
19
19
 
20
20
  export interface AuditedFolder {
21
+ /**
22
+ * The requested folder name this was resolved under, which is what says
23
+ * which standard governs the entries. A nested split folder carries the name
24
+ * of the folder it sits beneath rather than its own, so
25
+ * `.claude/context/claude-plugin` is governed as `context`.
26
+ */
27
+ readonly name: string
21
28
  /** Repo-relative folder path, used verbatim in every report line. */
22
29
  readonly rel: string
23
30
  readonly indexPath: string
@@ -35,7 +42,7 @@ export interface AuditedFolder {
35
42
  * here for the sole reason that this repository has no wireframes.
36
43
  */
37
44
  export function presentNames(folders: readonly AuditedFolder[]): string[] {
38
- return [...new Set(folders.map((folder) => folder.rel.split('/')[1]))]
45
+ return [...new Set(folders.map((folder) => folder.name))]
39
46
  }
40
47
 
41
48
  async function readEntries(dir: string): Promise<string[]> {
@@ -80,6 +87,7 @@ export async function resolveFolders(
80
87
 
81
88
  for (const each of [...new Set(dirs)].sort()) {
82
89
  folders.push({
90
+ name,
83
91
  rel: relative(root, each),
84
92
  indexPath: `${each}/${INDEX_FILE}`,
85
93
  entries: await readEntries(each),
@@ -100,9 +100,10 @@ Only the `development` entry carries this section. It is not a general-purpose h
100
100
  ## Length
101
101
 
102
102
  - Aim for one entry per domain. There is no hard cap. Length is a symptom, not the defect.
103
- - Past roughly 150 lines, check three things before adding more: whether the entry still covers a single domain, whether it has filled with content `ls` or `--help` reproduces, and whether it has accumulated the history of its own changes. Fix whichever is true rather than trimming to hit a number.
104
- - Past roughly 40 lines with no heading of any level breaking them, add a subheading at the seam. Measure the longest such run rather than everything under one `##`, and exclude fenced code blocks. The number is a checkpoint like the 150 above, not a cap.
105
- - Exempt a block whose lines are all list items at one level. A long list of peers is already navigable, and a subheading dropped into it splits a set that belongs together. Mixing prose with the list, or nesting levels inside it, ends the exemption.
103
+ - Past roughly 150 rendered lines, check three things before adding more: whether the entry still covers a single domain, whether it has filled with content `ls` or `--help` reproduces, and whether it has accumulated the history of its own changes. Fix whichever is true rather than trimming to hit a number.
104
+ - Past roughly 40 rendered lines with no heading of any level breaking them, add a subheading at the seam. Measure the longest such run rather than everything under one `##`, and exclude fenced code blocks. The number is a checkpoint like the 150 above, not a cap.
105
+ - Both checkpoints count rendered lines, so wrap each source line at 80 columns and sum the heights. Source lines undercount an entry authored one line per bullet, where a block of fifteen paragraph-bullets occupies fifteen lines and renders past sixty. Counting the two checkpoints in different units would put a file measured one way beside a run measured another.
106
+ - Exempt a block whose lines are all list items at one level averaging under roughly 130 characters. A flat list of short peers is already navigable, and a subheading dropped into it splits a set that belongs together. Bullet count says nothing on its own, since a catalog of one-liners and a stack of paragraphs reach the same count and read nothing alike, so weight is what decides. Mixing prose with the list, or nesting levels inside it, ends the exemption at any weight.
106
107
  - Never cut a `## Decisions` or `## Gotchas` entry to shorten a file. Cut a `## Layout` or `## CLI` section instead.
107
108
  - Split into a folder (`.claude/context/<domain>/<sub-area>.md`) when a domain has three or more sub-areas that do not fit cleanly in one file. That split is the natural ceiling.
108
109
  - Keep a split domain's `index.md` generated. The catalog body is rewritten on every regen, so the domain's own overview and layout belong in a sibling file rather than in it, and the `subtitle` is what names the file to start with.
@@ -48,7 +48,33 @@ Readiness is three groups under fixed headings, `## Run now`, `## Up next`, and
48
48
  - `## Up next`: a written plan exists, and the task either collides with something running or waits on another task to land. The blocker column names which.
49
49
  - `## Needs a plan`: everything else. The task has no plan, or the plan it carries no longer describes the work.
50
50
 
51
- Each group fixes its own columns, which follow from the test above it rather than from preference. `## Run now` carries `Task`, `Touches`, and `Plan`, since neither half of its test is checkable without the last two. `## Up next` carries `Task`, `Touches`, and `Waiting on`, the last naming whether a collision or a dependency holds it. `## Needs a plan` carries `Task` and `Waiting on` alone, because a task with no plan has no bounded file set to state.
51
+ Each group fixes its own columns, which follow from the test above it rather than from preference. Neither half of the `## Run now` test is checkable without the file set and the plan sitting beside the task. The blocker column under `## Up next` names whether a collision or a dependency holds the row. `## Needs a plan` states no file set at all, because a task with no plan has no bounded one to state. A group with no rows keeps its heading and its header row.
52
+
53
+ ```markdown
54
+ ---
55
+ title: Priority
56
+ description: One line on what the board covers
57
+ ---
58
+
59
+ # Priority
60
+
61
+ ## Run now
62
+
63
+ | Task | Touches | Plan |
64
+ | ------------------------------- | ----------------------- | ------------------------------------ |
65
+ | [vXX.Y <slug>](vXX.Y-<slug>.md) | <what the task touches> | [<slug>](../plans/feature-<slug>.md) |
66
+
67
+ ## Up next
68
+
69
+ | Task | Touches | Waiting on |
70
+ | ---- | ------- | ---------- |
71
+
72
+ ## Needs a plan
73
+
74
+ | Task | Waiting on |
75
+ | ------------------------------- | --------------------------------------- |
76
+ | [vXX.Y <slug>](vXX.Y-<slug>.md) | <the collision or the task it waits on> |
77
+ ```
52
78
 
53
79
  The tests live here so the board does not carry them. Writing them as a sentence under each heading produces the paragraph the rule above deletes, and a criterion with no home gets restated from memory every time the board is touched.
54
80