@erclx/aitk 1.3.0 → 1.5.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": "1.3.0",
4
+ "version": "1.5.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  title: Context audit checks
3
- description: What each non-gating check reports, the unit each checkpoint is measured in, which folders each check reaches, and what moved to the attribute tier
3
+ description: What each non-gating check reports, the unit each checkpoint is measured in, the architecture record's length gate and claim coverage, which folders each check reaches, and what moved to the attribute tier
4
4
  ---
5
5
 
6
6
  # Context audit checks
7
7
 
8
- What each finding from `aitk context audit` means. The command surface, its flags, and the one gating check are in `context-audit.md`.
8
+ What each finding from `aitk context audit` means. The command surface, its flags, and the two gating checks are in `context-audit.md`.
9
9
 
10
10
  ## Required sections
11
11
 
@@ -87,6 +87,22 @@ A rejected alternative is a back-reference in the past tense by construction, an
87
87
 
88
88
  The JSON record carries the findings per entry as `entries[].narration` and the sets as `checkpoints.narration`, which is absent under `--citations-only` where the run never loads them and null where no rule publishes both.
89
89
 
90
+ ## The architecture record
91
+
92
+ Two findings read `.claude/ARCHITECTURE.md` rather than a folder, and only the first is a fact.
93
+
94
+ The length check compares the record against the ceiling it derives for itself, and only a record that states its own allowances has one. No standard sets a length rule for this document, so the numbers belong to whichever record declares them. The check reads a frame allowance and an allowance per decision out of the record's own prose and puts the ceiling at the frame plus the allowance times the decision count. The JSON record carries what it read as `architecture.allowances` and the reading as `architecture.lines` against `architecture.ceiling`.
95
+
96
+ A record stating no such rule is measured and reported and never gated, which is the ordinary shape of a project that wrote an architecture record and no convention about its length. Holding a pair of numbers in the toolkit and gating every project against them would fail a record on a ceiling that project never agreed to, and the framed line would credit the rule to a file that does not state it.
97
+
98
+ What the derivation costs where it does apply is that the ceiling rises when a decision is added and falls for nothing, so the check passes exactly when the file grew. It gates anyway, because a record that states a limit for itself and makes it computable from a count has turned it into a fact, which is what separates it from every judgment below. A rewrite of the declaring sentence past the clauses the check reads falls back to reporting rather than to a stale ceiling, so the failure shows up in the run's own output.
99
+
100
+ The coverage report classifies each decision as carrying a countable claim, a structural invariant, or neither, then reports each testable entry against whether it names a check that exists. A countable claim carries a figure a run could recompute, and an invariant quantifies over a named tree closely enough that a walk could falsify it. A check is a `scripts/**.sh` path the entry spells that is on disk, or an `aitk` invocation matching a registered audit, so coverage reads the entry rather than the tree and a claim some check happens to cover without the entry saying so reads as unchecked.
101
+
102
+ Three limits are stated on every run rather than hidden. The countable signal reads digits alone, so a measured claim written in words reads as uncounted. Entries are counted by heading, and one heading holding several decisions counts once. Nothing is stored, so an entry rewritten tomorrow is classified afresh the next time the verb runs and no verdict goes stale.
103
+
104
+ The report gates nothing. Deciding whether a sentence states a claim is a judgment no parser settles, so the output names candidates for a reader. This answers a different question from the verification anchors `.claude/standards/architecture.md` describes, which record that one cited number was re-read. That mechanism says whether a marked figure held, and this one says how much of the record could be checked at all.
105
+
90
106
  ## Which folders each check reaches
91
107
 
92
108
  The provenance, required-section, and narration checks cover `.claude/context/` alone, the reference-form check covers the split folders inside it, and length and the table finding reach every audited folder.
@@ -5,7 +5,7 @@ description: Running the audit, its flags and folder scope, the exit codes, the
5
5
 
6
6
  # Context audit
7
7
 
8
- `aitk context audit [path]` reports the structural state of the folders following the index-plus-entry contract, meaning a generated `index.md` beside entries carrying frontmatter. It reads and reports. Fixing what it finds is separate work. What each finding means is in `context-audit-checks.md`.
8
+ `aitk context audit [path]` reports the structural state of the folders following the index-plus-entry contract, meaning a generated `index.md` beside entries carrying frontmatter, and it measures `.claude/ARCHITECTURE.md` beside them. It reads and reports. Fixing what it finds is separate work. What each finding means is in `context-audit-checks.md`.
9
9
 
10
10
  Findings stated over every markdown file rather than over a context entry are measured by `aitk markdown audit`, described in `markdown-audit.md`. That command resolves no folder, so it reaches trees this one refuses.
11
11
 
@@ -37,7 +37,7 @@ A run where no requested name resolves refuses, whichever list it read. Naming t
37
37
 
38
38
  ## Exit codes
39
39
 
40
- Exit codes are `0` for a clean run, `1` for a refusal, and `2` for a gating finding. An unresolved citation gates under every mode. Length, reference form, table, provenance, and narration findings print and return `0` under every mode, because each is a judgment and failing a push on one would make the check something to route around. Narration is the weakest of the five, since whether two bullets share a subject is a call the measure approximates from structure alone, and one of the shapes it matches is the rejected alternative the standard asks an entry to keep.
40
+ Exit codes are `0` for a clean run, `1` for a refusal, and `2` for a gating finding. An unresolved citation gates under every mode. An architecture record that states its own line allowances gates when it is past the ceiling those derive, on any run that measures it, which is every mode except `--citations-only`, and a record stating none is reported and never gated. Entry length, reference form, table, provenance, narration, and the record's claim classification print and return `0` under every mode, because each is a judgment and failing a push on one would make the check something to route around. Narration is the weakest of the five, since whether two bullets share a subject is a call the measure approximates from structure alone, and one of the shapes it matches is the rejected alternative the standard asks an entry to keep.
41
41
 
42
42
  Required-section and index findings sit between the two. Both are answerable from the file rather than weighed, so `--gate` promotes them to failing codes while a bare run leaves them advisory. The toolkit runs the bare form against itself and the widened form against the seed tree, described below.
43
43
 
@@ -47,7 +47,7 @@ Required-section and index findings sit between the two. Both are answerable fro
47
47
 
48
48
  The widened gate is correct here and wrong at the project root. A seed is authored once and read by every target, while a context entry in a live project is edited under time pressure by the people who own it. A missing section in the first is a defect shipping outward, and in the second it is a threshold worth reporting and not worth blocking a push over.
49
49
 
50
- Coverage follows the index-plus-entry contract, so it reaches seeded entries and the indexes beside them. Seed files sitting directly under `.claude/`, currently `ARCHITECTURE.md`, `DESIGN.md`, and `REQUIREMENTS.md`, belong to no audited folder and stay outside it.
50
+ Coverage follows the index-plus-entry contract, so it reaches seeded entries and the indexes beside them. `DESIGN.md` and `REQUIREMENTS.md` sitting directly under `.claude/` belong to no audited folder and stay outside it, while `ARCHITECTURE.md` is measured on its own path. The seed record states no line allowance, so it is reported rather than gated, which is what a seed template showing the shape of a record should be.
51
51
 
52
52
  The stage prints the entries it measured per root and warns on a root that measured none. A root can resolve an audited folder and hold no entry in it, which `tooling/claude/seeds` does today, so a single pass line over the set would report coverage of a tree nothing opened.
53
53
 
@@ -12,7 +12,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
12
12
  - [Capture](capture.md): Rendering committed HTML sources to PNG, what the command asserts about fonts, and why it is toolkit-only
13
13
  - [Command catalog](commands.md): Every project-level command and every domain subcommand, plus the shape each domain exposes
14
14
  - [Comments](comments.md): Comment density by language and kind, the two structural exclusions, and how the degradation sweep finds its vocabulary
15
- - [Context audit checks](context-audit-checks.md): What each non-gating check reports, the unit each checkpoint is measured in, which folders each check reaches, and what moved to the attribute tier
15
+ - [Context audit checks](context-audit-checks.md): What each non-gating check reports, the unit each checkpoint is measured in, the architecture record's length gate and claim coverage, which folders each check reaches, and what moved to the attribute tier
16
16
  - [Context audit](context-audit.md): Running the audit, its flags and folder scope, the exit codes, the citation gate, and the widened gate the seed stage runs
17
17
  - [Docs](docs.md): How aitk docs resolves the toolkit's own reference surface from an install root, and how a split domain is named
18
18
  - [Indexes](indexes.md): Flags, exit codes, and JSON shape for aitk indexes regen, plus when it auto-stages what it rewrote
@@ -46,6 +46,19 @@ Help skips the banner. The `Usage:` line sits directly on `├`. Help writes to
46
46
 
47
47
  `--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.
48
48
 
49
+ ## Color
50
+
51
+ Escape sequences reach a destination that renders them and nowhere else. The question is asked per stream rather than once for the process, so a run piping its data while keeping a terminal on stderr still gets color on the frame.
52
+
53
+ Either condition alone turns color off:
54
+
55
+ - `NO_COLOR` is set to any non-empty value, whatever that value says
56
+ - The destination is not a terminal, which covers a pipe, a file, and a captured session
57
+
58
+ The frame survives both. `┌`, `│`, `├`, `└`, and the `✓ ! + - ✗` marks are structure rather than color, and they are what lets a captured run still read as one block. A caller wanting neither the frame nor the color reads `--json` instead.
59
+
60
+ Terminal control is a separate question this section does not cover. The cursor and key sequences an interactive prompt writes run only where a terminal already exists.
61
+
49
62
  ## Process exit
50
63
 
51
64
  A command action sets `process.exitCode` and returns. Calling `process.exit()` there ends the process before a stdout write drains, which truncates piped output at the 64K pipe buffer while still reporting the right exit code. Redirecting to a file hides the truncation, so it surfaces only through a pipe, which is what a check has to use to catch it.
@@ -24,6 +24,8 @@ The toolkit seed in `tooling/claude/seeds/.claude/DESIGN.md` ships a token-table
24
24
 
25
25
  The `aitk:claude-design-extract` skill drafts the file, sourcing tokens from a project's existing prose and CLI UI surfaces, or proposing them from `.claude/REQUIREMENTS.md` and a `## Personality` paragraph when no UI code exists yet. `aitk design render` writes an HTML plus CSS preview to `.claude/review/design/` for eyeballing the current system without leaving Claude Code. See `.claude/context/design.md`.
26
26
 
27
+ A cell no source anchors ends in `? verify`, and the preview shows that marker beside the value rather than folding it in, so a swatch and a font sample stay built from the value alone. A confidence line above the sections names how many cells are anchored against how many are tagged, which is what tells a reader whether they are looking at a record of the code or a proposal about it. It reads the columns a source could anchor and leaves out the row names, so the ratio is not diluted by cells no tag could ever reach. The proposal path tags nearly all of them, so that count reads low on day one by design.
28
+
27
29
  ### Tools
28
30
 
29
31
  - None beyond Claude Code itself
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "1.3.0",
4
+ "version": "1.5.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -103,6 +103,61 @@ function allOf(
103
103
  return counts
104
104
  }
105
105
 
106
+ /**
107
+ * Reads the architecture record's three measures, or nothing when the project
108
+ * carries no record.
109
+ *
110
+ * Three states rather than two, matching what the verb publishes. The key
111
+ * absent is a run that never opened the record, which the aggregate never asks
112
+ * for and so reads as a shape that moved. Null is a project entitled to carry
113
+ * no record, whose other context counts still stand, so it contributes no key
114
+ * rather than a zero that would read as a conforming record.
115
+ */
116
+ function architectureCounts(
117
+ root: Record<string, unknown>,
118
+ ): Record<string, number> | undefined | 'absent' {
119
+ if (!('architecture' in root)) return undefined
120
+ if (root.architecture === null) return 'absent'
121
+
122
+ const record = asObject(root.architecture)
123
+ const decisions = record?.decisions
124
+ if (
125
+ record === undefined ||
126
+ !Array.isArray(decisions) ||
127
+ typeof record.lines !== 'number'
128
+ ) {
129
+ return undefined
130
+ }
131
+
132
+ // A record stating no length rule has no ceiling to be past, and reporting
133
+ // zero there would read as one measured and found conforming.
134
+ const ceiling =
135
+ typeof record.ceiling === 'number' ? record.ceiling : undefined
136
+
137
+ let unverifiable = 0
138
+ let unchecked = 0
139
+ for (const raw of decisions) {
140
+ const entry = asObject(raw)
141
+ const claim = entry?.claim
142
+ const checks = lengthOf(entry?.checks)
143
+ if (typeof claim !== 'string' || checks === undefined) return undefined
144
+
145
+ if (claim === 'neither') unverifiable += 1
146
+ else if (checks === 0) unchecked += 1
147
+ }
148
+
149
+ return {
150
+ // A boolean, counted so the aggregate reads it the way it reads every
151
+ // other measure. The verb gates on it separately, and the key is absent
152
+ // rather than zero on a record that declared no ceiling.
153
+ ...(ceiling !== undefined && {
154
+ recordOverLength: record.lines > ceiling ? 1 : 0,
155
+ }),
156
+ recordUnverifiable: unverifiable,
157
+ recordUnchecked: unchecked,
158
+ }
159
+ }
160
+
106
161
  function contextCounts(record: unknown): Record<string, number> | undefined {
107
162
  const root = asObject(record)
108
163
  if (root === undefined) return undefined
@@ -117,13 +172,19 @@ function contextCounts(record: unknown): Record<string, number> | undefined {
117
172
  bareReferences += bare
118
173
  }
119
174
 
120
- return allOf({
175
+ const architecture = architectureCounts(root)
176
+ if (architecture === undefined) return undefined
177
+
178
+ const counts = allOf({
121
179
  unresolvedCitations: lengthOf(asObject(root.citations)?.unresolved),
122
180
  longEntries: lengthOf(root.length),
123
181
  missingSections: lengthOf(root.missingSections),
124
182
  indexDrift: lengthOf(root.indexDrift),
125
183
  bareReferences,
126
184
  })
185
+
186
+ if (counts === undefined) return undefined
187
+ return architecture === 'absent' ? counts : { ...counts, ...architecture }
127
188
  }
128
189
 
129
190
  function markdownCounts(record: unknown): Record<string, number> | undefined {
package/src/cli.ts CHANGED
@@ -28,12 +28,12 @@ import { register as sessions } from '@/commands/sessions'
28
28
  import { register as audits } from '@/commands/audits'
29
29
  import { register as upgrade } from '@/commands/upgrade'
30
30
  import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
31
-
32
- const GREY = '\x1b[0;90m'
33
- const WHITE = '\x1b[1;37m'
34
- const NC = '\x1b[0m'
31
+ import { palette } from '@/ui'
35
32
 
36
33
  function showHelp(): void {
34
+ // The help text is the one framed surface written to stdout, so it asks
35
+ // about that stream rather than the stderr every other writer here uses.
36
+ const { GREY, NC, WHITE } = palette(process.stdout)
37
37
  const lines = [
38
38
  `${GREY}┌${NC}`,
39
39
  `${GREY}├${NC} ${WHITE}Usage:${NC} aitk [command]`,
@@ -44,16 +44,13 @@ import {
44
44
  logStep,
45
45
  logWarn,
46
46
  outro,
47
+ palette,
47
48
  pipeOutput,
48
49
  plural,
49
50
  select,
50
51
  } from '@/ui'
51
52
  import { describeSkew, readSkew, type SkewReport } from '@/version/skew'
52
53
 
53
- const GREEN = '\x1b[0;32m'
54
- const GREY = '\x1b[0;90m'
55
- const NC = '\x1b[0m'
56
-
57
54
  interface SeedsListOptions {
58
55
  readonly json?: boolean
59
56
  readonly names?: boolean
@@ -266,6 +263,7 @@ export function register(program: Command): void {
266
263
  }
267
264
 
268
265
  function succeed(message: string): number {
266
+ const { GREEN, NC } = palette(process.stderr)
269
267
  outro()
270
268
  process.stderr.write(`${GREEN}✓ ${message}${NC}\n`)
271
269
  return 0
@@ -472,6 +470,7 @@ async function runSeedsList(opts: SeedsListOptions): Promise<number> {
472
470
  return 0
473
471
  }
474
472
 
473
+ const { GREY, NC } = palette(process.stderr)
475
474
  intro('aitk claude')
476
475
  logStep('Seed docs')
477
476
  for (const listing of listings) {
@@ -531,6 +530,7 @@ async function runSkillsDrift(
531
530
  // is the moment a skew warning is worth most, since an old binary is one
532
531
  // reason the cache and the CLI disagree in the first place.
533
532
  if (skew.state === 'behind') {
533
+ const { GREY, NC } = palette(process.stderr)
534
534
  process.stderr.write(`${GREY}${describeSkew(skew)}${NC}\n`)
535
535
  }
536
536
  }
@@ -15,6 +15,14 @@ import {
15
15
  REQUIRED_SECTIONS,
16
16
  type SectionFinding,
17
17
  } from '@/context/audit'
18
+ import {
19
+ type ArchitectureReport,
20
+ coveredCount,
21
+ isOverLength,
22
+ measureArchitecture,
23
+ RECORD_REL,
24
+ testableCount,
25
+ } from '@/context/architecture'
18
26
  import { auditCitations, type CitationReport } from '@/context/citations'
19
27
  import {
20
28
  type AuditedFolder,
@@ -65,7 +73,7 @@ export function register(program: Command): void {
65
73
  context
66
74
  .command('audit')
67
75
  .description(
68
- 'Report required sections, entry length, citations, reference form, catalog tables, provenance, superseded-decision narration, and index drift',
76
+ 'Report required sections, entry length, citations, reference form, catalog tables, provenance, superseded-decision narration, index drift, and the architecture record against its own ceiling',
69
77
  )
70
78
  .argument('[path]', 'Project root, defaulting to the current directory')
71
79
  .helpOption('-h, --help', 'Show this help message')
@@ -88,11 +96,14 @@ export function register(program: Command): void {
88
96
  ' 1 refused, with the reason on stderr',
89
97
  ' 2 a gating finding is present',
90
98
  '',
91
- 'An unresolved citation always gates. --gate widens the gate to the',
92
- 'other two findings that are facts rather than judgments: a missing',
93
- 'required section and index drift. Length, reference form, table,',
94
- 'provenance, and narration findings are judgments and stay advisory',
95
- 'under both.',
99
+ 'An unresolved citation always gates. An architecture record that',
100
+ 'states its own line allowances gates when it is past the ceiling',
101
+ 'those derive, on any run except --citations-only, which never',
102
+ 'measures it. A record stating no allowance is reported and never',
103
+ 'gated. --gate widens the gate to the other two findings that are',
104
+ 'facts rather than judgments: a missing required section and index',
105
+ 'drift. Entry length, reference form, table, provenance, narration,',
106
+ 'and the record claim classification are judgments under both.',
96
107
  '',
97
108
  'Depth and bullet weight are stated over every markdown file rather',
98
109
  'than over a context entry, so `aitk markdown audit` measures them.',
@@ -205,6 +216,12 @@ async function runAudit(
205
216
  const drift = gateOnly ? [] : await auditIndexes(folders)
206
217
  const sections = gateOnly ? [] : missingSections(root, folders, entries)
207
218
  const length = gateOnly ? undefined : lengthFindings(entries)
219
+ // Absent under `--citations-only` and null when the project carries no
220
+ // record, for the reason `checkpoints.narration` states about its own two
221
+ // absences. A run that never looked and a project with nothing to look at
222
+ // are different answers, and one value for both reports the second as the
223
+ // first.
224
+ const record = gateOnly ? undefined : await measureArchitecture(root)
208
225
 
209
226
  if (gateOnly) {
210
227
  reportGate(citations)
@@ -219,6 +236,7 @@ async function runAudit(
219
236
  reportProvenance(entries, folders)
220
237
  reportNarration(entries, folders, narration)
221
238
  reportDrift(drift)
239
+ reportRecord(record)
222
240
  outro()
223
241
  }
224
242
 
@@ -251,6 +269,10 @@ async function runAudit(
251
269
  length,
252
270
  missingSections: sections,
253
271
  indexDrift: drift,
272
+ // Null says the run opened the project and found no record, which a
273
+ // target that never wrote one is entitled to. Absent says the run
274
+ // never looked, which is `--citations-only`.
275
+ architecture: gateOnly ? undefined : (record ?? null),
254
276
  checkpoints: {
255
277
  lines: LENGTH_CHECKPOINT,
256
278
  renderWidth: RENDER_WIDTH,
@@ -277,6 +299,7 @@ async function runAudit(
277
299
 
278
300
  const gating = isGating({
279
301
  unresolvedCitations: citations.unresolved.length,
302
+ recordOverLength: record !== undefined && isOverLength(record),
280
303
  sections,
281
304
  drift,
282
305
  widened,
@@ -696,6 +719,101 @@ function reportNarration(
696
719
  )
697
720
  }
698
721
 
722
+ /** How each classification reads in the report. */
723
+ const CLAIM_LABEL: Record<string, string> = {
724
+ countable: 'countable claim',
725
+ invariant: 'structural invariant',
726
+ neither: 'reasoning only',
727
+ }
728
+
729
+ /**
730
+ * Reports the architecture record against the ceiling it states for itself and
731
+ * against what a machine could test in it.
732
+ *
733
+ * The length reading is a fact and gates. Everything below it names candidates
734
+ * a reader adjudicates, because deciding whether a sentence states a claim is a
735
+ * judgment no parser settles, and a stored verdict would age the way the
736
+ * anchors it sits beside already do. Nothing is stored: every run reclassifies,
737
+ * so an entry rewritten tomorrow is read as it stands then.
738
+ */
739
+ function reportRecord(report: ArchitectureReport | undefined): void {
740
+ logStep('Architecture record')
741
+
742
+ if (report === undefined) {
743
+ logInfo(
744
+ `Out of scope. The project carries no ${RECORD_REL}, so there was no record to measure.`,
745
+ )
746
+ return
747
+ }
748
+
749
+ const decisions = report.decisions.length
750
+ const { allowances } = report
751
+
752
+ if (allowances === undefined) {
753
+ logInfo(
754
+ `Covers ${report.rel} alone. No standard sets a length rule for it and this record states none, so its ${plural(report.lines, 'line')} across ${plural(decisions, 'decision')} are reported and nothing is gated.`,
755
+ )
756
+ logInfo(
757
+ 'A record declaring an allowance for its frame and one a decision is measured against the ceiling those two derive. That rule belongs to whichever record writes it, never to the toolkit.',
758
+ )
759
+ } else {
760
+ logInfo(
761
+ `Covers ${report.rel} alone, which states its own allowance of ${plural(allowances.frame, 'line')} for the frame and ${allowances.perDecision} a decision.`,
762
+ )
763
+ if (isOverLength(report)) {
764
+ logError(
765
+ `${report.lines} lines against a ceiling of ${report.ceiling} from ${plural(decisions, 'decision')}`,
766
+ )
767
+ } else {
768
+ logInfo(
769
+ `${report.lines} lines against a ceiling of ${report.ceiling} from ${plural(decisions, 'decision')}.`,
770
+ )
771
+ }
772
+ logInfo(
773
+ `The ceiling rises with the decision count, so adding a decision buys ${allowances.perDecision} lines and the check passes exactly when the file grew.`,
774
+ )
775
+ }
776
+
777
+ if (decisions === 0) {
778
+ logWarn('The record declares no decision, so nothing was classified.')
779
+ return
780
+ }
781
+
782
+ const testable = testableCount(report)
783
+ const covered = coveredCount(report)
784
+
785
+ logInfo(
786
+ 'A countable claim carries a figure a run could recompute and an invariant quantifies over a named tree a walk could falsify. Both are candidates a reader settles, and neither gates.',
787
+ )
788
+ logInfo(
789
+ 'A figure spelled in words reads as uncounted, since a cardinal in prose is pronominal more often than measured. Entries are counted by heading, so a heading carrying several decisions counts once and the total reads low by however many it holds.',
790
+ )
791
+ const line = `${testable} of ${decisions} carry a claim a machine could test, ${covered} of which name a check that exists`
792
+ // A record whose every testable claim names a check has nothing to act on,
793
+ // and so does one carrying no testable claim at all. Warning on both is how
794
+ // a section becomes one nobody reads after the second run.
795
+ if (testable > covered) logWarn(line)
796
+ else logInfo(`${line}.`)
797
+ logInfo(
798
+ 'Coverage reads the entry rather than the tree, so a claim some check happens to cover without the entry naming it reads as unchecked.',
799
+ )
800
+
801
+ pipeOutput(
802
+ report.decisions
803
+ .map((entry) => {
804
+ const kind = CLAIM_LABEL[entry.claim] ?? entry.claim
805
+ const evidence =
806
+ entry.figures.length > 0 ? ` ${entry.figures.join(' ')}` : ''
807
+ const checks =
808
+ entry.checks.length > 0
809
+ ? `\n checked by ${entry.checks.join(', ')}`
810
+ : ''
811
+ return `${report.rel}:${entry.line} ${kind}${evidence}\n ${entry.heading}${checks}`
812
+ })
813
+ .join('\n'),
814
+ )
815
+ }
816
+
699
817
  function reportDrift(drift: readonly FolderDrift[]): void {
700
818
  logStep('Index drift')
701
819
 
@@ -2,12 +2,7 @@ import { existsSync } from 'node:fs'
2
2
  import { resolve } from 'node:path'
3
3
  import type { Command } from 'commander'
4
4
  import { renderDesignDoc } from '@/design/render'
5
-
6
- const GREY = '\x1b[0;90m'
7
- const WHITE = '\x1b[1;37m'
8
- const RED = '\x1b[0;31m'
9
- const GREEN = '\x1b[0;32m'
10
- const NC = '\x1b[0m'
5
+ import { palette } from '@/ui'
11
6
 
12
7
  export function register(program: Command): void {
13
8
  const design = program
@@ -22,6 +17,7 @@ export function register(program: Command): void {
22
17
  .action((opts: { source: string; out: string }) => {
23
18
  const sourcePath = resolve(process.cwd(), opts.source)
24
19
  const outDir = resolve(process.cwd(), opts.out)
20
+ const { GREEN, GREY, NC, RED, WHITE } = palette(process.stderr)
25
21
  if (!existsSync(sourcePath)) {
26
22
  process.stderr.write(
27
23
  `${GREY}┌${NC}\n${GREY}│${NC} ${RED}✗${NC} ${opts.source} not found\n${GREY}└${NC}\n`,
@@ -4,10 +4,7 @@ import type { Command } from 'commander'
4
4
  import { deriveSlug, deriveTitle } from '@/commands/feedback-format'
5
5
  import { PROJECT_ROOT } from '@/project-root'
6
6
  import { createGithubIssue } from '@/github'
7
- import { frameError, frameSuccess } from '@/ui'
8
-
9
- const YELLOW = '\x1b[0;33m'
10
- const NC = '\x1b[0m'
7
+ import { frameError, frameSuccess, palette } from '@/ui'
11
8
 
12
9
  function readStdin(): Promise<string> {
13
10
  return new Promise((resolveStream, rejectStream) => {
@@ -88,6 +85,7 @@ export function register(program: Command): void {
88
85
  process.exitCode = 1
89
86
  return
90
87
  }
88
+ const { NC, YELLOW } = palette(process.stderr)
91
89
  process.stderr.write(
92
90
  `${YELLOW}! gh unavailable, wrote local scratch instead${NC}\n`,
93
91
  )
@@ -30,12 +30,10 @@ import {
30
30
  logStep,
31
31
  logWarn,
32
32
  outro,
33
+ palette,
33
34
  select,
34
35
  } from '@/ui'
35
36
 
36
- const GREEN = '\x1b[0;32m'
37
- const NC = '\x1b[0m'
38
-
39
37
  const PAYLOAD_REL = join('.claude', '.tmp', 'gov', 'rules.md')
40
38
  const RULES_REL = join('.claude', 'rules')
41
39
 
@@ -461,6 +459,7 @@ async function runInstall(
461
459
  )
462
460
  }
463
461
 
462
+ const { GREEN, NC } = palette(process.stderr)
464
463
  outro()
465
464
  process.stderr.write(`${GREEN}✓ Rules installed${NC}\n`)
466
465
  return 0
@@ -510,6 +509,7 @@ async function runBuild(target: string): Promise<number> {
510
509
  await writeFile(output, buildRulesPayload(files))
511
510
  logAdd(PAYLOAD_REL)
512
511
 
512
+ const { GREEN, NC } = palette(process.stderr)
513
513
  outro()
514
514
  process.stderr.write(
515
515
  `${GREEN}✓ Rules built (${files.length} rules → ${PAYLOAD_REL})${NC}\n`,
@@ -6,11 +6,7 @@ import { type InitFlags, parseSkip, planInit } from '@/init/plan'
6
6
  import { runDomains } from '@/init/run'
7
7
  import { buildSteps } from '@/init/steps'
8
8
  import { resolveTarget } from '@/target'
9
- import { intro, logInfo, logStep, logWarn, outro, select } from '@/ui'
10
-
11
- const GREEN = '\x1b[0;32m'
12
- const YELLOW = '\x1b[0;33m'
13
- const NC = '\x1b[0m'
9
+ import { intro, logInfo, logStep, logWarn, outro, palette, select } from '@/ui'
14
10
 
15
11
  interface InitOptions {
16
12
  /** Always present: the option falls back to `DEFAULT_STACK`. */
@@ -107,6 +103,8 @@ async function runInit(
107
103
  outro()
108
104
  process.stderr.write('\n')
109
105
 
106
+ const { GREEN, NC, YELLOW } = palette(process.stderr)
107
+
110
108
  if (failed.length === 0) {
111
109
  process.stderr.write(
112
110
  `${GREEN}✓ Project initialized (${plan.total} domains)${NC}\n`,
@@ -5,12 +5,7 @@ import { LAYOUTS } from '@/slides/layouts'
5
5
  import { openDeck } from '@/slides/open'
6
6
  import { renderSlidesDoc } from '@/slides/render'
7
7
  import type { Variant } from '@/slides/styles'
8
- import { intro, outro } from '@/ui'
9
-
10
- const GREY = '\x1b[0;90m'
11
- const GREEN = '\x1b[0;32m'
12
- const RED = '\x1b[0;31m'
13
- const NC = '\x1b[0m'
8
+ import { intro, outro, palette } from '@/ui'
14
9
 
15
10
  export function register(program: Command): void {
16
11
  const slides = program
@@ -44,6 +39,7 @@ export function register(program: Command): void {
44
39
  }
45
40
  const variant = parseVariant(opts.variant)
46
41
  const mirror = resolveMirror(opts.mirror)
42
+ const { GREEN, GREY, NC, RED } = palette(process.stderr)
47
43
  intro('Render slides')
48
44
  const result = await renderSlidesDoc(sourcePath, outDir, {
49
45
  variant,
@@ -81,6 +77,7 @@ export function register(program: Command): void {
81
77
  process.stdout.write(`${JSON.stringify(LAYOUTS)}\n`)
82
78
  return
83
79
  }
80
+ const { GREEN, GREY, NC } = palette(process.stderr)
84
81
  intro('Slide layouts')
85
82
  for (const layout of LAYOUTS) {
86
83
  process.stderr.write(
@@ -121,6 +118,7 @@ function fail(message: string): never {
121
118
  */
122
119
  function reportFailure(error: unknown): void {
123
120
  if (!(error instanceof SlidesError)) throw error
121
+ const { GREY, NC, RED } = palette(process.stderr)
124
122
  process.stderr.write(
125
123
  `${GREY}┌${NC}\n${GREY}│${NC} ${RED}✗${NC} ${error.message}\n${GREY}└${NC}\n`,
126
124
  )
@@ -21,12 +21,10 @@ import {
21
21
  logStep,
22
22
  logWarn,
23
23
  outro,
24
+ palette,
24
25
  select,
25
26
  } from '@/ui'
26
27
 
27
- const GREEN = '\x1b[0;32m'
28
- const NC = '\x1b[0m'
29
-
30
28
  const SNIPPETS_REL = '.claude/snippets'
31
29
 
32
30
  const PASS_THROUGH_VERBS = ['create'] as const
@@ -180,6 +178,7 @@ async function runInstall(
180
178
  }
181
179
  await recordStamp(createSnippetsAdapter(PROJECT_ROOT), resolved, new Date())
182
180
 
181
+ const { GREEN, NC } = palette(process.stderr)
183
182
  outro()
184
183
  process.stderr.write(`${GREEN}✓ Snippets installed${NC}\n`)
185
184
  return 0
@@ -21,13 +21,10 @@ import {
21
21
  logStep,
22
22
  logWarn,
23
23
  outro,
24
+ palette,
24
25
  select,
25
26
  } from '@/ui'
26
27
 
27
- const GREEN = '\x1b[0;32m'
28
- const GREY = '\x1b[0;90m'
29
- const NC = '\x1b[0m'
30
-
31
28
  interface InstallOptions {
32
29
  /** Always present: the option falls back to `ALL_SELECTION`. */
33
30
  readonly only: string
@@ -196,6 +193,7 @@ async function runInstall(target: string, selection: string): Promise<number> {
196
193
  await recordStamp(createStandardsAdapter(PROJECT_ROOT), resolved, new Date())
197
194
 
198
195
  outro()
196
+ const { GREEN, GREY, NC } = palette(process.stderr)
199
197
  process.stderr.write(
200
198
  `\n${GREEN}✓ Standards installed${NC} ${GREY}(${files.length} files)${NC}\n`,
201
199
  )