@erclx/aitk 0.39.0 → 0.41.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,45 @@
1
+ ---
2
+ title: Output shape
3
+ description: The two framed shapes every command renders into, and how JSON and --names modes keep stdout clean
4
+ ---
5
+
6
+ # Output shape
7
+
8
+ Every CLI command renders into one of two framed shapes. Data goes to stdout. UI and logs go to stderr. Help output is the exception. It prints to stdout so it can be piped and grepped.
9
+
10
+ ## Data shape (lists, runs, errors)
11
+
12
+ ```plaintext
13
+
14
+ │ aitk <domain>
15
+
16
+ ├ Section
17
+ │ ✓ item
18
+ │ ✓ item
19
+
20
+ ```
21
+
22
+ Rules:
23
+
24
+ - `┌` opens the frame on stderr
25
+ - `│ aitk <domain>` is the command banner, one per invocation
26
+ - `├ Section` headers introduce groups of items. `log_step` produces the blank `│` spacer before each.
27
+ - `└` closes the frame on stderr, wired via `trap close_timeline EXIT`
28
+ - Errors render as `│ ✗ message` inside the same frame. Never emit a lone error line without a frame.
29
+
30
+ ## Help shape
31
+
32
+ ```plaintext
33
+
34
+ ├ Usage: aitk <domain> [command]
35
+
36
+ │ Commands:
37
+ │ ...
38
+
39
+ ```
40
+
41
+ Help skips the banner. The `Usage:` line sits directly on `├`. Help writes to stdout because `--help` is documentation, not runtime UI.
42
+
43
+ ## JSON and `--names` modes
44
+
45
+ `--json` and `--names` keep stdout clean and machine-readable. The frame still renders on stderr (open, banner, close) so the stream discipline is consistent across modes. Consumers that only read stdout see pure data.
@@ -0,0 +1,26 @@
1
+ ---
2
+ title: Overview
3
+ description: What this folder covers, the invocation rules every command inherits, and where domain behavior is documented instead
4
+ ---
5
+
6
+ # Overview
7
+
8
+ CLI catalog and invocation rules for agents working in this repository.
9
+
10
+ This folder is an index of what an agent can run and how to run it cleanly from a script. It does not cover domain behavior. Read `CLAUDE.md` for project behaviors and load the matching `.claude/skills/aitk-*` skill when working inside a domain.
11
+
12
+ ## Invocation rules
13
+
14
+ See `CLAUDE.md` design principles. They apply to every command in this folder.
15
+
16
+ ## Where to start
17
+
18
+ - `output-shape.md`: the stream contract every command renders into, which is what a caller parsing stdout depends on
19
+ - `commands.md`: the full command catalog, project-level and per-domain
20
+ - `scripting.md`: the runtime catalogs that replace hardcoded names, plus headless invocation examples
21
+
22
+ ## Related
23
+
24
+ - `CLAUDE.md`: project behaviors and design principles
25
+ - `.claude/skills/aitk-*`: domain-scoped guidance for editing work
26
+ - `docs/index.md`: full docs directory
@@ -0,0 +1,65 @@
1
+ ---
2
+ title: Sandbox
3
+ description: Scenario routing, the expectation scoring surface, and the coverage census over scenarios and skills
4
+ ---
5
+
6
+ # Sandbox
7
+
8
+ `aitk sandbox` provisions isolated project states, scores a provisioned one against a declared expectation, and reports which scenarios declare an expectation at all.
9
+
10
+ ## Scenarios
11
+
12
+ Scenarios live under `scripts/sandbox/`, one folder per category. `scripts/sandbox/fixtures/` is the exception, holding file content that scenarios stage rather than scenarios of its own, so both pickers filter it out. `files` in `package.json` excludes that tree, so an installed `aitk` carries the command, reports it as toolkit-only on one line, and exits 1 rather than failing on the missing directory. Route non-interactively with `SANDBOX_SCENARIO`:
13
+
14
+ ```bash
15
+ SANDBOX_SCENARIO=sync aitk sandbox infra:tooling
16
+ ```
17
+
18
+ Scenario categories: `infra:*` (domain flows), `git:*`, `scaffold:*`. `create` scenarios require interactive input and loop on empty input, so skip them in automated runs.
19
+
20
+ ## Scenario expectations
21
+
22
+ `aitk sandbox check <category>:<command> [arm]` scores a provisioned sandbox against the arm's `expect.toml`, printing a verdict on stderr and, with `--json`, the same verdict as a record on stdout.
23
+
24
+ ```bash
25
+ aitk sandbox check claude:docs drift --json
26
+ ```
27
+
28
+ | Flag | Effect |
29
+ | ------------------- | ---------------------------------------------------------- |
30
+ | `--envelope <file>` | Read `is_error`, `num_turns`, denials, and the reply text |
31
+ | `--writes <file>` | Newline-delimited paths the session wrote, for write scope |
32
+ | `--json` | Emit the verdict record on stdout |
33
+ | `--strict` | Exit 1 on `unchecked` instead of 0 |
34
+
35
+ The verdict `state` is `pass`, `fail`, or `unchecked`. An arm with no `expect.toml` is `unchecked` and exits 0, so the harness stays usable while expectations roll out. A declaration that exists but asserts nothing is a failure, since an expectation file that asserts nothing passes every run.
36
+
37
+ Omitting `--writes` or `--envelope` does not silently drop the assertion kinds that need them. Write scope, the turn ceiling, and the reply assertion report as unchecked and appear in the count, so the standalone command cannot claim more coverage than it had. A verdict never reports `pass` with zero assertions.
38
+
39
+ An envelope that parses but carries no `result` field skips the reply assertion the same way an absent file does. An envelope carrying an empty `result` fails it, since a run that returned no text is a finding rather than a gap in the input.
40
+
41
+ Exit 0 means `pass` or `unchecked`. Exit 1 means `fail`, or a caller error: a malformed target, or a sandbox that was never provisioned. A missing sandbox reports as an error rather than a failed verdict, because failing every path assertion would read as a skill that did nothing. `--strict` moves `unchecked` to exit 1 for a caller that has finished arming its scenarios.
42
+
43
+ ## Scenario coverage
44
+
45
+ `aitk sandbox coverage` reports which scenarios declare expectations and which only provision a state. It reads the fixture tree, so it needs no provisioned sandbox and runs nothing. Where that tree does not ship it exits 1 and prints no percentage, since a denominator nobody looked at is not a coverage result. A tree that is present and holds no scenarios is a real zero and still reports one.
46
+
47
+ ```bash
48
+ aitk sandbox coverage --json
49
+ ```
50
+
51
+ | Flag | Effect |
52
+ | ---------- | ----------------------------------------------------------- |
53
+ | `--json` | Emit the coverage record on stdout |
54
+ | `--strict` | Exit 1 while any scenario declares no expectation |
55
+ | `--skills` | Add a per-skill asserted, should-be-asserted, exempt census |
56
+
57
+ The record carries every scenario with the arms that declare, plus `totalScenarios`, `armedScenarios`, and `armedArms`. Scenarios and arms count separately, since several arms can share one scenario and dividing one by the other overstates the rollout.
58
+
59
+ ### The skills census
60
+
61
+ `--skills` answers what the scenario count cannot, which is whether anything can fail a given skill. It adds `skills`, `totalSkills`, `asserted`, `shouldBeAsserted`, `exempt`, `staleExemptions`, and `supersededExemptions` to the record, and keeps the scenario view rather than replacing it. The two denominators disagree on purpose: an armed scenario under `infra/` or `tooling/` exercises a CLI domain and pairs with no skill at all.
62
+
63
+ A skill pairs to a scenario by filename, `<category>-<command>` first and bare `<command>` second, so `claude/setup-init.sh` reaches the `setup-init` skill. `should-be-asserted` is the default rather than a queue to drain, and which of those skills earns an arm is a project decision the census does not make. `exempt` means no arm should be written and holds only with a reason, declared in `scripts/sandbox/exempt.toml` and limited to a harness limit the checker cannot reach past or a skill that writes no artifact. An armed arm outranks an exemption. An exemption naming no shipped skill, or naming one an arm now asserts, exits 1 without `--strict`. Each armed arm reports as `<category>:<command>/<arm>`, so two same-named arms under different scenarios stay distinct.
64
+
65
+ `scripts/sandbox/run.sh` calls this after a headless run and merges the verdict into the envelope it prints. It also writes that merged record to `.claude/.tmp/sandbox-runs/<target>-<arm>-<timestamp>.json` with a `writes` array appended, and logs the path on stderr. Both fields are what a later re-score needs, since `--envelope` and `--writes` read files the run deletes on exit. Stdout carries the same bytes it did before the record existed.
@@ -0,0 +1,115 @@
1
+ ---
2
+ title: Scripting
3
+ description: The runtime catalogs that replace hardcoded names, what each carries, and a headless invocation per domain
4
+ ---
5
+
6
+ # Scripting
7
+
8
+ What a skill or script reads to discover names at runtime, and how each domain is invoked with no TTY.
9
+
10
+ ## Runtime catalogs
11
+
12
+ Use these to discover what's available instead of hardcoding names.
13
+
14
+ | Command | Returns |
15
+ | -------------------------------- | --------------------------------------------- |
16
+ | `aitk tooling list --json` | Stacks, extends chain, dep and script counts |
17
+ | `aitk snippets list --json` | Presets and categories with their slugs |
18
+ | `aitk standards list --json` | Standards docs and the paths each governs |
19
+ | `aitk gov list --json` | Governance stacks and rule sets |
20
+ | `aitk claude seeds list --json` | Seed doc sources with content |
21
+ | `aitk claude skills list --json` | Plugin skills, descriptions, requirement flag |
22
+ | `aitk docs list --json` | Consumer docs plus per-domain context |
23
+
24
+ ### Catalog fields
25
+
26
+ Every catalog serializes through `JSON.stringify`, so a name carrying a quote
27
+ emits valid JSON. `aitk tooling list` and `aitk snippets list` previously built
28
+ their output with `printf` and no escaping.
29
+
30
+ `aitk standards list` carries `appliesTo` per standard, the paths that standard's
31
+ `## Scope` statement declares. It holds the backticked paths from the first
32
+ sentence of the statement, the single entry `*` for a standard governing an
33
+ attribute rather than a document type, and an empty array when the statement
34
+ declares nothing a parser can read. A consumer mapping a file to its governing
35
+ standards reads this rather than holding a table of its own, and reports an empty
36
+ array rather than skipping the standard behind it.
37
+
38
+ `aitk claude seeds list` reads the same plan `aitk claude init` applies, so the
39
+ listing and the install cannot disagree. It now reports
40
+ `.claude/context/index.md`, which `init` has always installed and the listing
41
+ never named, and it emits the project-level `CLAUDE.md` last rather than first.
42
+
43
+ ### The skills catalog
44
+
45
+ `aitk claude skills list` reads `claude/skills/*/SKILL.md` and reports the folder
46
+ name with the frontmatter description, sorted by name. Internal skills under
47
+ `.claude/skills/` are excluded, since they never install into a target and a
48
+ count spanning both overstates what ships. A skill whose frontmatter is missing
49
+ or unparseable returns an empty description rather than failing the listing, so
50
+ one malformed file cannot hide the rest of the catalog. `--names` emits skill
51
+ names one per line.
52
+
53
+ Each entry also carries `requirement`, whether the folder holds a sibling
54
+ `REQUIREMENT.md`. Every skill is meant to carry one, so a `false` is a gap to
55
+ close rather than a recorded exemption, and the flag answers which skills are
56
+ missing theirs without a caller listing the directory itself. Nothing gates the
57
+ rule yet, which is why the flag is worth reading against the shipped corpus after
58
+ a merge.
59
+
60
+ ## Non-interactive examples
61
+
62
+ ```bash
63
+ # Create a new tooling stack
64
+ AITK_NON_INTERACTIVE=1 aitk tooling create astro
65
+
66
+ # Sync a stack into a target project
67
+ AITK_NON_INTERACTIVE=1 aitk tooling sync astro /path/to/project
68
+
69
+ # Install a governance stack (the stack argument is required headlessly)
70
+ AITK_NON_INTERACTIVE=1 aitk gov install astro --add 260-shadcn /path/to/project
71
+
72
+ # Update installed governance rules, dropping a retired .claude/GOV.md
73
+ AITK_NON_INTERACTIVE=1 aitk gov sync /path/to/project
74
+
75
+ # Concatenate installed rules into a paste payload
76
+ AITK_NON_INTERACTIVE=1 aitk gov build /path/to/project
77
+
78
+ # Sync a monorepo subtree, skipping the base layer the repo root already owns
79
+ AITK_NON_INTERACTIVE=1 aitk tooling sync vite-react /path/to/repo/frontend --skip base
80
+
81
+ # Verify a stack end-to-end in a throwaway scaffold
82
+ aitk tooling verify vite-react
83
+
84
+ # Apply one stack without scanning or prompting, for scripted provisioning
85
+ aitk tooling inject base /path/to/project
86
+ aitk tooling inject base /path/to/project --configs --seeds
87
+
88
+ # Drop managed gitignore entries a manifest no longer declares
89
+ # Prints the number removed on stdout, diagnostics on stderr
90
+ aitk tooling prune-gitignore base /path/to/project
91
+
92
+ # Install a snippet preset
93
+ AITK_NON_INTERACTIVE=1 aitk snippets install essentials /path/to/project
94
+
95
+ # Update snippets already installed, leaving project-authored ones alone
96
+ AITK_NON_INTERACTIVE=1 aitk snippets sync /path/to/project
97
+
98
+ # Report standards drift without applying it, which is what headless does here
99
+ AITK_NON_INTERACTIVE=1 aitk standards sync /path/to/project
100
+
101
+ # Copy every standard into a target, overwriting what is there
102
+ AITK_NON_INTERACTIVE=1 aitk standards install /path/to/project
103
+
104
+ # Bootstrap a project. Any flag suppresses the confirmation prompt
105
+ AITK_NON_INTERACTIVE=1 aitk init --stack astro --skip wiki /path/to/project
106
+
107
+ # Run every domain sync. The git workflow is refused headlessly, so nothing is pushed
108
+ AITK_NON_INTERACTIVE=1 aitk sync /path/to/project
109
+
110
+ # Scaffold .claude/wiki/ with a stub index. The target must already exist
111
+ AITK_NON_INTERACTIVE=1 aitk wiki init /path/to/project
112
+
113
+ # Run a sandbox scenario non-interactively
114
+ SANDBOX_SCENARIO=sync aitk sandbox infra:tooling
115
+ ```
@@ -0,0 +1,33 @@
1
+ ---
2
+ title: Tasks
3
+ description: Selecting a shipped task by stem or pull request, the refusal reasons, and why the board root defaults to the main worktree
4
+ ---
5
+
6
+ # Tasks
7
+
8
+ `aitk tasks archive` moves a shipped task from `.claude/tasks/` into `.claude/.tmp/task-archive/`, drops its row from `priority.md`, and regenerates the board index. The three run as one unit, so the attended and unattended callers cannot archive differently.
9
+
10
+ Name the task by its filename stem, or by the pull request it carries:
11
+
12
+ ```bash
13
+ aitk tasks archive v28.1-trigger-escalation
14
+ aitk tasks archive --pull-request 673 --json
15
+ ```
16
+
17
+ | Option | Behavior |
18
+ | -------------------- | ------------------------------------------------------------ |
19
+ | `--pull-request <n>` | Select the task whose `Pull request:` line names this number |
20
+ | `--json` | Emit a machine-readable record on stdout |
21
+ | `--root <path>` | Board root, defaulting to the main worktree |
22
+
23
+ Exit codes: `0` archived, `1` refused. Every gate is a refusal rather than a warning, because `.husky/post-merge` calls this with nobody watching. The `reason` field carries which gate fired: `no-board`, `no-match`, `ambiguous`, `no-outcomes`, `open-outcomes`, or `plan-unswept`.
24
+
25
+ The board is shared scratch at the main worktree root, so `--root` defaults to the first entry of `git worktree list` rather than the working directory. A linked worktree archives against the same board every other session reads.
26
+
27
+ Skills branch on the reason rather than on the exit code:
28
+
29
+ ```bash
30
+ aitk tasks archive --pull-request 673 --json | jq -r 'if .ok then .task else .reason end'
31
+ ```
32
+
33
+ For the board format, the `Pull request:` line, and the archive rules, see `.claude/standards/tasks.md`.
package/docs/index.md CHANGED
@@ -9,7 +9,6 @@ One-line reference for each doc in this folder.
9
9
 
10
10
  ## Agent surface
11
11
 
12
- - [Agents](agents.md): CLI catalog and invocation rules for agents
13
12
  - [AI workflow](ai-workflow.md): Overarching AI workflow across domains
14
13
  - [Target projects](target-projects.md): Scaffold, add domains later, and sync upstream drift in a toolkit-managed project
15
14
 
@@ -18,3 +17,7 @@ One-line reference for each doc in this folder.
18
17
  - [Operating model](operating-model.md): Orchestrator and worker roles for building across parallel sessions
19
18
  - [Visual design workflow](visual-design-workflow.md): Tiered guide for design and wireframe authoring with Claude Code
20
19
  - [Zshrc aliases for Claude Code](zshrc-aliases.md): Shell aliases that shorten common Claude Code invocations
20
+
21
+ ## Sub-catalogs
22
+
23
+ - [Agents](agents/index.md): CLI catalog and invocation rules for agents, split by command domain. Start with overview.
@@ -8,7 +8,7 @@ category: Agent surface
8
8
 
9
9
  How a project outside this repo consumes the toolkit across its lifecycle. Three phases: scaffold once, add a domain later when a new need appears, and sync when the upstream toolkit moves.
10
10
 
11
- This doc stays at the narrative layer. For command flags and JSON shapes, see [agents](agents.md). For per-domain mechanics, see each `.claude/context/<domain>.md`.
11
+ This doc stays at the narrative layer. For command flags and JSON shapes, see [agents](agents/index.md). For per-domain mechanics, see each `.claude/context/<domain>.md`.
12
12
 
13
13
  ## Getting the skills
14
14
 
@@ -179,7 +179,7 @@ Sync also refuses a target whose working tree is dirty, so commit or stash befor
179
179
 
180
180
  ## Related
181
181
 
182
- - [agents](agents.md): CLI flags, exit codes, and JSON output shapes
182
+ - [agents](agents/index.md): CLI flags, exit codes, and JSON output shapes
183
183
  - [AI workflow](ai-workflow.md): feature-development loop inside a toolkit-managed project
184
184
  - [tooling](../.claude/context/tooling.md), [governance](../.claude/context/governance.md), [claude plugin](../.claude/context/claude-plugin/index.md), [indexes](../.claude/context/indexes.md), [snippets](../.claude/context/snippets.md), [standards](../.claude/context/standards.md): per-domain mechanics
185
185
  - [sandbox](../.claude/context/sandbox/index.md): scenario catalog for verifying domain flows
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "0.39.0",
4
+ "version": "0.41.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -38,6 +38,35 @@ is_internal_topic() {
38
38
  esac
39
39
  }
40
40
 
41
+ # Emits `name<TAB>description<TAB>category<TAB>target` per target-facing doc,
42
+ # sorted so a domain split into a folder lands in its alphabetical place rather
43
+ # than after every file. A folder declares its category on its own index, since
44
+ # the allowlist is what separates a target-facing doc from a workflow one and a
45
+ # split domain is not exempt from it.
46
+ collect_docs() {
47
+ local file name description category
48
+ {
49
+ while IFS= read -r file; do
50
+ name=$(basename "$file" .md)
51
+ [ "$name" = "index" ] && continue
52
+ category=$(read_frontmatter_field "$file" "category")
53
+ is_target_facing "$category" || continue
54
+ description=$(read_frontmatter_field "$file" "description")
55
+ printf '%s\t%s\t%s\t%s\n' "$name" "$description" "$category" "docs/$name.md"
56
+ done < <(find "$DOCS_DIR" -maxdepth 1 -type f -name "*.md")
57
+
58
+ # A split domain is named by its folder and described by its generated
59
+ # index, which carries subtitle where a sibling file carries description
60
+ while IFS= read -r file; do
61
+ name=$(basename "$(dirname "$file")")
62
+ category=$(read_frontmatter_field "$file" "category")
63
+ is_target_facing "$category" || continue
64
+ description=$(read_frontmatter_field "$file" "subtitle")
65
+ printf '%s\t%s\t%s\t%s\n' "$name" "$description" "$category" "docs/$name/index.md"
66
+ done < <(find "$DOCS_DIR" -mindepth 2 -maxdepth 2 -type f -name "index.md")
67
+ } | sort
68
+ }
69
+
41
70
  # Emits `name<TAB>description<TAB>target` per context entry, sorted so a domain
42
71
  # split into a folder lands in its alphabetical place rather than after every
43
72
  # file. Matches listTopics in src/docs/read.ts, which sorts both together.
@@ -64,16 +93,11 @@ collect_context() {
64
93
  }
65
94
 
66
95
  list_text() {
67
- local file name description category target
96
+ local name description category target
68
97
  log_step "Docs"
69
- while IFS= read -r file; do
70
- name=$(basename "$file" .md)
71
- [ "$name" = "index" ] && continue
72
- category=$(read_frontmatter_field "$file" "category")
73
- is_target_facing "$category" || continue
74
- description=$(read_frontmatter_field "$file" "description")
98
+ while IFS=$'\t' read -r name description category target; do
75
99
  log_info "$name : $description"
76
- done < <(find "$DOCS_DIR" -maxdepth 1 -type f -name "*.md" | sort)
100
+ done < <(collect_docs)
77
101
 
78
102
  # Absent in a registry install, which ships docs/ without .claude/
79
103
  if [ -d "$CONTEXT_DIR" ]; then
@@ -97,17 +121,12 @@ emit_json_entry() {
97
121
  }
98
122
 
99
123
  list_json() {
100
- local file name description category target
124
+ local name description category target
101
125
  JSON_FIRST=1
102
126
  printf '['
103
- while IFS= read -r file; do
104
- name=$(basename "$file" .md)
105
- [ "$name" = "index" ] && continue
106
- category=$(read_frontmatter_field "$file" "category")
107
- is_target_facing "$category" || continue
108
- description=$(read_frontmatter_field "$file" "description")
109
- emit_json_entry "$name" "$description" "$category" "docs/$(basename "$file")"
110
- done < <(find "$DOCS_DIR" -maxdepth 1 -type f -name "*.md" | sort)
127
+ while IFS=$'\t' read -r name description category target; do
128
+ emit_json_entry "$name" "$description" "$category" "$target"
129
+ done < <(collect_docs)
111
130
 
112
131
  if [ -d "$CONTEXT_DIR" ]; then
113
132
  while IFS=$'\t' read -r name description target; do
@@ -0,0 +1,37 @@
1
+ Write the pre-compact handoff as orchestrator. Do this before a compaction, because a compaction keeps conclusions and drops the reasoning that produced them, and no other file in the repository carries that reasoning.
2
+
3
+ 1. Resolve the main worktree root with `git worktree list --porcelain | grep -m 1 '^worktree ' | cut -d' ' -f2-`, falling back to `pwd`. Write `.claude/tasks/session.md` under it.
4
+ 2. Write only what a compaction destroys and no other file already carries. The board holds the ordering and what each task waits on, a task file holds its own findings, and a groundwork folder holds its track.
5
+ 3. Use this shape:
6
+
7
+ ```markdown
8
+ ---
9
+ title: Session map
10
+ description: <what the board cannot show, and the date it was written>
11
+ ---
12
+
13
+ # Session map
14
+
15
+ <one line marking the file throwaway and naming the board as the real source>
16
+
17
+ ## State
18
+
19
+ <what is clean, what is running, what is open, and any untracked file that needs committing>
20
+
21
+ ## Decisions taken under delegated authority
22
+
23
+ <each decision and why it went that way, so nobody re-proposes it>
24
+
25
+ ## Mistakes worth not repeating
26
+
27
+ <what went wrong and the rule it yields>
28
+
29
+ ## Standing cautions
30
+
31
+ <commands that lie, tools that measure the wrong tree, and anything unbacked>
32
+ ```
33
+
34
+ 4. Cite a commit, a task, or a file and line for every claim, so the next session can tell a read from a recall.
35
+ 5. Overwrite the previous handoff rather than appending to it. A stale entry read as current is worse than no handoff.
36
+
37
+ Add a section only for content that fits none of the four and would otherwise be lost. Do not restate the board, and do not summarize the work that shipped, because git already carries it.
@@ -408,6 +408,9 @@ function reportDepth(entries: readonly EntryReport[]): void {
408
408
  logInfo(
409
409
  `Fenced blocks are excluded, and so are peer lists averaging under ${PEER_BULLET_CHECKPOINT} characters a bullet.`,
410
410
  )
411
+ logInfo(
412
+ 'A run that is entirely table rows is excluded too, since a heading inside a table splits the table rather than the run.',
413
+ )
411
414
 
412
415
  const over = entries
413
416
  .filter((entry) => entry.longestRun > RUN_CHECKPOINT)
@@ -389,7 +389,7 @@ function runCheck(
389
389
 
390
390
  // The report always renders on stderr. `--json` adds the machine copy on
391
391
  // stdout rather than replacing the frame, per the stream contract in
392
- // `docs/agents.md`, so one invocation serves a human and a caller at once.
392
+ // `docs/agents/output-shape.md`, so one invocation serves a human and a caller at once.
393
393
  reportVerdict(verdict)
394
394
  if (options.json === true)
395
395
  process.stdout.write(`${JSON.stringify(verdict)}\n`)
@@ -231,6 +231,36 @@ function isScannablePeerList(run: readonly BodyLine[]): boolean {
231
231
  return characters / items < PEER_BULLET_CHECKPOINT
232
232
  }
233
233
 
234
+ /**
235
+ * Reports whether a run is a table, the second shape the checkpoint cannot fix.
236
+ *
237
+ * The peer list above is exempt because it is already navigable. A table is
238
+ * exempt for the other reason: the remedy does not exist. A heading dropped
239
+ * inside one splits the table into two tables rather than breaking the run, so
240
+ * a catalog renders as an unbroken stretch by construction and no edit short of
241
+ * rewriting it as a list clears the report.
242
+ *
243
+ * Every non-blank line has to be a row. A run holding a table between
244
+ * paragraphs is genuinely mixed, and a heading breaks it at a seam either side,
245
+ * so testing whether the run holds a table would hide the case the checkpoint
246
+ * exists for.
247
+ *
248
+ * A delimiter is required rather than assumed, matching the table scan below. A
249
+ * stack of lines opening with a pipe and no delimiter renders as paragraph text
250
+ * and would otherwise earn the exemption on its punctuation.
251
+ */
252
+ function isTableRun(run: readonly BodyLine[]): boolean {
253
+ let separators = 0
254
+
255
+ for (const line of run) {
256
+ if (line.text.trim() === '') continue
257
+ if (!TABLE_ROW.test(line.text)) return false
258
+ if (TABLE_SEPARATOR.test(line.text)) separators++
259
+ }
260
+
261
+ return separators > 0
262
+ }
263
+
234
264
  /**
235
265
  * Height a source line occupies once wrapped.
236
266
  *
@@ -272,7 +302,7 @@ function longestRun(lines: readonly BodyLine[]): {
272
302
  // two headings rather than a stretch a reader travels, so it never counts.
273
303
  const first = run.find((line) => line.text.trim() !== '')
274
304
 
275
- if (first && !isScannablePeerList(run)) {
305
+ if (first && !isScannablePeerList(run) && !isTableRun(run)) {
276
306
  const height = run.reduce(
277
307
  (sum, line) => sum + renderedHeight(line.text),
278
308
  0,
package/src/ui.ts CHANGED
@@ -31,7 +31,7 @@ export function logRemove(message: string): void {
31
31
  }
32
32
 
33
33
  /**
34
- * Renders the `✗` shape `docs/agents.md` specifies for a failure inside an
34
+ * Renders the `✗` shape `docs/agents/output-shape.md` specifies for a failure inside an
35
35
  * open frame. It does not exit, so the caller closes the frame and returns an
36
36
  * exit code rather than terminating mid-write.
37
37
  */
@@ -104,6 +104,7 @@ Only the `development` entry carries this section. It is not a general-purpose h
104
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
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
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.
107
+ - Exempt a block whose lines are all table rows, at any length. The peer list above is exempt because it is already navigable, and a table because the remedy does not exist: a subheading dropped inside one splits the table rather than the run, so no edit short of rewriting it as a list clears the checkpoint. Prose either side of the table ends the exemption, since that block has a seam and a heading breaks it there.
107
108
  - Past roughly 400 characters in one top-level bullet, counting the lines that continue it and excluding any bullet nested under it, check whether the incident that motivated the decision sits beside the decision itself. Keep the current design and the alternative that lost, and move the incident to the change that introduced it, the issue that tracked it, or the research record behind it. The number is a checkpoint like the two above, and a bullet reading well past it means the number is wrong rather than the rule.
108
109
  - Collapse a stack of bullets narrating one subsystem into a single `###` subsection carrying one narrative. Splitting a heavy bullet into three light ones satisfies the checkpoint above and leaves the reader no better off, and subdividing a block does not lighten the bullets inside it, so the two rules answer different defects.
109
110
  - Never cut a `## Decisions` or `## Gotchas` entry to shorten a file. Cut a `## Layout` or `## CLI` section instead.