@erclx/canon 4.48.0 → 4.48.1

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": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.48.0",
4
+ "version": "4.48.1",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -38,7 +38,7 @@ Review is the step that varies most. It gets skipped on a diff that needed one,
38
38
  ## Guards
39
39
 
40
40
  - Detached HEAD: stop, no slug resolves
41
- - No approved plan at the branch's plan path: stop and route to the planning skill
41
+ - No approved plan at the branch-derived plan path: stop, name the branch the slug came from, and say the invocation carried no argument, so a plan or a task path may be passed
42
42
  - Uncommitted changes unrelated to the plan: stop
43
43
  - No diff baseline against main: stop
44
44
  - Empty changed-file list: stop, and never advise removing the output from `.gitignore` to get past it
@@ -47,7 +47,7 @@ Resolve `<plan>` in this order, stopping at the first match:
47
47
 
48
48
  1. **Caller-supplied task.** The invocation carried a path under `.canon/tasks/`. If it does not resolve to a file, stop: `❌ No task at <path>. Path was supplied, not derived, so check it and re-run.` Read that task's first `Plan:` line and take what it names as `<plan>`, per `${CLAUDE_SKILL_DIR}/../../standards/tasks.md`.
49
49
  2. **Caller-supplied plan.** The invocation carried something else. Accept it as a plan path or a bare slug, in the same position `claude-worktree` tier 0 accepts its name. A bare slug resolves to `.canon/plans/feature-<slug>.md`, and a path is taken as given from the main worktree root. If it does not resolve to a file, stop: `❌ No plan at <path>. Path was supplied, not derived, so check it and re-run.`
50
- 3. **Derived.** `.canon/plans/feature-<slug>.md`, from the `<slug>` the Guards derived. If it does not exist, stop: `❌ No approved plan at .canon/plans/feature-<slug>.md. Run /claude-feature first.`
50
+ 3. **Derived.** `.canon/plans/feature-<slug>.md`, from the `<slug>` the Guards derived. If it does not exist, stop: `❌ No approved plan at .canon/plans/feature-<slug>.md, where <slug> was derived from the current branch, <branch>. The invocation carried no argument, so pass a plan or a task path, or run /claude-feature first.`
51
51
 
52
52
  Only a path reaches tier 1, and a bare slug is read as a plan's throughout. The two would collide on any similar name, and a caller who means the task holds its path already, having read it off the board. One plan per task is what makes the tier 1 read unambiguous, so it takes the first `Plan:` line and never scans for a second.
53
53
 
@@ -148,7 +148,7 @@ Read `.claude/context/index.md` at `pwd` to see which domain entries exist. Skip
148
148
 
149
149
  Two sources feed this step, the same split Step 2 runs on. The diff carries what the repository changed. The routed facts carry what the session learned, which a diff cannot show.
150
150
 
151
- **Routed facts.** Derive `<slug>` per `${CLAUDE_SKILL_DIR}/../../standards/slug.md`, falling back to `latest` on an empty result, and read `.canon/tmp/memory-routing/<slug>.md` at the main worktree root. `claude-memory-capture` writes it, one H2 per target entry naming the path, with the fact underneath. Fold each fact into the entry its heading names, then delete the handoff file so a later run does not fold it twice.
151
+ **Routed facts.** Derive `<slug>` per `${CLAUDE_SKILL_DIR}/../../standards/slug.md`, falling back to `latest` on an empty result, and read `.canon/tmp/memory-routing/<slug>.md` at the main worktree root. `claude-memory-capture` writes it, one H2 per target entry naming the path, with the fact underneath. Fold each fact into the entry its heading names, which for a nested `.claude/context/<domain>/index.md` heading is the sibling file the fact belongs under rather than the generated index itself. Then delete the handoff file so a later run does not fold it twice.
152
152
 
153
153
  This half is not diff-scoped and must not be. A gotcha a session hit while working is exactly the fact the diff never shows, and scoping it to changed files would drop the entries worth keeping. The handoff is a named input rather than a scan, so the reach stays bounded to what capture decided.
154
154
 
@@ -156,10 +156,10 @@ Skip this half silently when the file is absent, which is every run where nothin
156
156
 
157
157
  **The diff.** When the baseline is unusable, scope this half to the working tree and untracked files, and skip it only when that set is empty, reporting `⚠ No diff to scope against. Skipped the context refresh.` The routed half still runs, since it reads a file rather than a diff.
158
158
 
159
- Reuse the diff from the baseline above, names and content both. For each existing `.claude/context/<domain>.md`:
159
+ Reuse the diff from the baseline above, names and content both. For each domain listed in `.claude/context/index.md`, read its own entry: a flat `.claude/context/<domain>.md`, or, for a domain split into a folder, its `.claude/context/<domain>/index.md` and every sibling file that index links. Follow the index rather than globbing the folder, since a folder can hold a file the index does not list yet.
160
160
 
161
- - Map the entry's section headings to the changed files. An entry is relevant when its prose references files, modules, or decisions touched by the diff.
162
- - For each relevant entry, rewrite only the sections affected by the diff. Same pattern as `docs-sync`. Do not touch unrelated sections.
161
+ - Map the entry's section headings, whether they sit in one flat file or spread across a nested domain's sibling files, to the changed files. An entry is relevant when its prose references files, modules, or decisions touched by the diff.
162
+ - For each relevant entry, rewrite only the sections affected by the diff. Same pattern as `docs-sync`. Do not touch unrelated sections. Never rewrite a split domain's own `index.md` directly, since a regen overwrites it the same way it overwrites the top-level catalog. Rewrite the sibling file the affected section actually lives in instead.
163
163
  - Write a reference to another entry as the path that entry sits at, rather than as its bare filename. `${CLAUDE_SKILL_DIR}/../../standards/context.md` states the form, and a bare name strands the reference once a domain splits into subfolders.
164
164
 
165
165
  ### When the diff removes a capability
@@ -173,11 +173,11 @@ Grep the tree for the name that went, rather than for the paths the diff carries
173
173
 
174
174
  Report each hit as an ordinary rewrite.
175
175
 
176
- Do not create new entries automatically. New entries are a deliberate decision: the user invokes `claude-docs --new-context <domain>` (future flag) or hand-creates the file following `${CLAUDE_SKILL_DIR}/../../standards/context.md`. Auto-creation risks padding `.claude/context/` with low-signal entries.
176
+ Do not create new entries automatically. Before treating a domain as new, confirm it holds no entry under either spelling, `.claude/context/<domain>.md` or `.claude/context/<domain>/index.md`, since a domain already split into a folder still passes a check that only looked for the flat file. New entries are a deliberate decision: the user invokes `claude-docs --new-context <domain>` (future flag) or hand-creates the file following `${CLAUDE_SKILL_DIR}/../../standards/context.md`. Auto-creation risks padding `.claude/context/` with low-signal entries.
177
177
 
178
- Write each updated entry immediately. Output one line per file:
178
+ Write each updated entry immediately. Output one line per file, naming the path this run actually wrote rather than always the flat template:
179
179
 
180
- `✅ Context: .claude/context/<domain>.md`
180
+ `✅ Context: .claude/context/<domain>.md` for a flat entry, or `✅ Context: .claude/context/<domain>/<sub-area>.md` for the sibling file a nested edit landed in
181
181
 
182
182
  Add a line naming the handoff when one was consumed:
183
183
 
@@ -44,7 +44,9 @@ For each project candidate, match its subject against `.claude/context/index.md`
44
44
 
45
45
  Fail closed. A project candidate matching no entry stays a memory file, and so does one matching two entries where neither is clearly the owner. The residue is what the folder is for, and a fact filed under the wrong entry is worse than one in memory because a context entry is a surface sessions trust.
46
46
 
47
- Do not edit a context entry here. `claude-docs` owns those edits and folds the routed facts in on its own pass, or two skills write one file at the same step. Write each routed fact to `.canon/tmp/memory-routing/<slug>.md` at the main worktree root instead, appending when the file exists. An append is a whole-file operation the shell does directly, so send it as a plain single `Bash` command carrying a heredoc:
47
+ Do not edit a context entry here. `claude-docs` owns those edits and folds the routed facts in on its own pass, or two skills write one file at the same step. Write each routed fact to `.canon/tmp/memory-routing/<slug>.md` at the main worktree root instead, appending when the file exists. Name the heading with the entry's own path from `.claude/context/index.md`, flat or the nested `index.md`, since that heading is what tells `claude-docs`'s routed-facts fold which file to open. An append is a whole-file operation the shell does directly, so send it as a plain single `Bash` command carrying a heredoc:
48
+
49
+ A flat domain takes:
48
50
 
49
51
  ```markdown
50
52
  ## .claude/context/<domain>.md
@@ -52,6 +54,14 @@ Do not edit a context entry here. `claude-docs` owns those edits and folds the r
52
54
  <the fact in one or two sentences, stated as a fact about the domain rather than as a session narrative>
53
55
  ```
54
56
 
57
+ A domain split into a folder takes its own generated index instead:
58
+
59
+ ```markdown
60
+ ## .claude/context/<domain>/index.md
61
+
62
+ <the fact in one or two sentences, stated as a fact about the domain rather than as a session narrative>
63
+ ```
64
+
55
65
  Derive `<slug>` per `${CLAUDE_SKILL_DIR}/../../standards/slug.md`. Fall back to `latest` on an empty result.
56
66
 
57
67
  The handoff is a file rather than a spoken result so the routed fact survives a compaction between this step and the `claude-docs` pass, and so the standalone caller leaves something behind for a later `/claude-docs` to consume.
@@ -80,7 +90,7 @@ Run `canon records validate memory` when the writes are done and fix what it nam
80
90
 
81
91
  Respond with one line per fact routed, written, or updated:
82
92
 
83
- - `➡️ Routed: <fact subject> → .claude/context/<domain>.md`
93
+ - `➡️ Routed: <fact subject> → .claude/context/<domain>.md` for a flat entry, or `→ .claude/context/<domain>/index.md` for a nested one, matching the heading the routing file carries
84
94
  - `✅ Wrote: .canon/memory/<file> (<type>)`
85
95
  - `✏️ Updated: .canon/memory/<file> (<type>)`
86
96
 
@@ -14,11 +14,11 @@ A reference page that reads like a lesson is a lesson wearing the wrong extensio
14
14
 
15
15
  Sort by who owns the subject, which is the test the wiki already runs. The reader's activity decided the workspace and decides nothing here.
16
16
 
17
- | The page's subject | Destination |
18
- | -------------------------------------------------------------- | ----------------------------- |
19
- | Owned by someone outside the project, such as a tool or vendor | the project's wiki |
20
- | How this project itself works, for the people who build it | `.claude/context/<domain>.md` |
21
- | Written for someone consuming what this project ships | the project's public docs |
17
+ | The page's subject | Destination |
18
+ | -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
19
+ | Owned by someone outside the project, such as a tool or vendor | the project's wiki |
20
+ | How this project itself works, for the people who build it | `.claude/context/<domain>.md`, or the domain's own nested `index.md` when it is already split into a folder |
21
+ | Written for someone consuming what this project ships | the project's public docs |
22
22
 
23
23
  A page a reader cannot sort by that question is a page whose subject has not been settled. Say which two destinations it sits between and let the operator choose, rather than picking the nearer one.
24
24
 
@@ -37,7 +37,7 @@ Split `CLAUDE.md` by `##` heading. Score each section against this order:
37
37
  For each section proposed for a move:
38
38
 
39
39
  - Path-scoped: propose `.claude/rules/<subdir>/<n>-<slug>.md` with a `paths:` glob. Pick the subdir and a free number the way `create-rule` does. If `.claude/rules/<subdir>/` already holds a rule on the topic, mark as "conflict" and skip.
40
- - Domain narrative: propose `.claude/context/<domain>.md`. If the entry already exists, propose appending rather than creating.
40
+ - Domain narrative: propose `.claude/context/<domain>.md`. If the entry already exists, flat or as a same-named `.claude/context/<domain>/` folder, propose appending to the file that owns it rather than creating a duplicate flat entry beside a domain already split.
41
41
 
42
42
  ## Step 4: output
43
43
 
@@ -55,6 +55,7 @@ Print one grouped proposal block. Omit empty groups.
55
55
  ## Extract to a context entry
56
56
 
57
57
  - `## <heading>` → `.claude/context/<domain>.md` (<reason>)
58
+ - `## <heading>` → `.claude/context/<domain>/<sub-area>.md`, appending to the domain's existing sibling file (<reason>)
58
59
 
59
60
  ## Needs manual split
60
61
 
@@ -44,7 +44,7 @@ For each file, score against this rule order:
44
44
 
45
45
  For every file proposed for a move:
46
46
 
47
- - If `.claude/context/<filename>` already exists, mark as "conflict" and skip the move.
47
+ - If `.claude/context/<filename>` already exists, or a same-named folder exists at `.claude/context/` with `<filename>`'s `.md` extension dropped, mark as "conflict" and skip the move. A folder of the same name is still a collision, since the domain already lives there split into siblings.
48
48
  - Run `git grep -n "docs/<filename>"` (or grep equivalent) to list inbound references in `CLAUDE.md`, `README.md`, and other markdown. Report each as a TODO line. Do not rewrite.
49
49
 
50
50
  ## Step 4: output
@@ -11,20 +11,22 @@ description: Running every health check as one set, what the single verdict mean
11
11
  canon audits run
12
12
  canon audits run --json
13
13
  canon audits run --record
14
+ canon audits run --corpus tracked --corpus per-machine
14
15
  canon audits list --json
15
16
  ```
16
17
 
17
- | Option | Behavior |
18
- | --------------- | ---------------------------------------------------------------- |
19
- | `--json` | Add a machine-readable record on stdout, keeping the frame |
20
- | `--root <path>` | Measure this tree instead of the current worktree |
21
- | `--record` | Write this run's tracked counts to `.claude/canon/baseline.json` |
18
+ | Option | Behavior |
19
+ | ----------------- | ---------------------------------------------------------------------------------------------------------- |
20
+ | `--json` | Add a machine-readable record on stdout, keeping the frame |
21
+ | `--root <path>` | Measure this tree instead of the current worktree |
22
+ | `--record` | Write this run's tracked counts to `.claude/canon/baseline.json` |
23
+ | `--corpus <name>` | Limit the run to one corpus (`tracked`, `per-machine`, `upstream`), repeatable, defaulting to every corpus |
22
24
 
23
25
  ## What it runs
24
26
 
25
27
  Nineteen verbs, listed by `canon audits list`. Each runs once in its fullest form, and the aggregate reads that verb's own record rather than imposing a shared envelope on it. Every one of those records already has consumers naming its keys, so a common shape would be a breaking change bought for tidiness.
26
28
 
27
- The verbs walk separate trees and share no state, so they run together. Measured on the authoring machine at twelve verbs, a run finished in 0.8 seconds of wall clock against 4.4 seconds of processor, which is under every other stage in `bun run check`. `canon deps audit` is the one that changes that reading, since it reaches a network rather than a tree and its latency is the index's rather than this machine's.
29
+ The verbs walk separate trees and share no state, so they run together. Measured on the authoring machine at twelve verbs, a run finished in 0.8 seconds of wall clock against 4.4 seconds of processor, which is under every other stage in `bun run check`. `canon deps audit` is the one that changes that reading, since it reaches a network rather than a tree and its latency is the index's rather than this machine's: three clean runs measured 44.7, 77.6, and 62.9 seconds, and a stalled lookup runs to an explicit 120-second timeout rather than to bun's own 299-second ceiling. `--corpus` is what keeps a caller from paying that cost when it wants only the corpora on this disk.
28
30
 
29
31
  Sixteen of the nineteen read a tree on this disk, the one added by `restated.md` and the one added by `census.md` among them, since the first reads four such trees against each other and the second counts every file in one. The two added by `state-scoped-risk.md` read committed state rather than an arriving change, which is the gap every review surface here leaves by construction, and the one added by `label-coverage.md` reads a branch range against a map the project declares.
30
32
 
@@ -93,6 +95,8 @@ A hand-edited baseline that does not parse refuses the whole run. Reading a brok
93
95
 
94
96
  ## In the verify pipeline
95
97
 
96
- `bun run check` runs the set as a reporting stage after the three gating stages, and never fails on it. Those three stages keep their own specific remedies, so the aggregate reports the rest and the growth, and a fact still fails the push at the stage that names what to do about it.
98
+ `bun run check` runs a reporting stage after the three gating stages, and never fails on it. Those three stages keep their own specific remedies, so the aggregate reports the rest and the growth, and a fact still fails the push at the stage that names what to do about it.
97
99
 
98
- The stage reads `summary`, a flat object of scalars published beside the nested arrays. Every key in it is unique across the whole record, so a shell stage greps one out without a JSON parser. The three verbs the gating stages already ran walk their trees a second time here, which is the 0.8 seconds measured above and the reason the whole set runs rather than only the part those stages skip: one verdict over every audit is the value, and a stage measuring a subset would report a health nobody took.
100
+ The stage runs `--corpus tracked --corpus per-machine` rather than the full set, so `deps` and its network latency sit outside the gate entirely. A weekly job reads the advisory index instead, on a schedule rather than on every push. `summary.audited` in the gate's own record therefore reads 19 against the 20 `canon audits run` reaches with no filter.
101
+
102
+ The stage reads `summary`, a flat object of scalars published beside the nested arrays. Every key in it is unique across the whole record, so a shell stage greps one out without a JSON parser. The three verbs the gating stages already ran walk their trees a second time here, which is the 0.8 seconds measured above and the reason the scoped set runs rather than only the part those stages skip: one verdict over the corpora describing this tree is the value, and a stage measuring a narrower subset would report a health nobody took.
@@ -79,7 +79,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
79
79
  | `canon repo metadata propose` | Compare a description, homepage, and topic set computed from the README and `package.json` against what the remote carries, writing nothing (`--root`, `--json`) |
80
80
  | `canon repo metadata apply` | Write an explicitly supplied description, homepage, or topic set to the remote through `gh repo edit` (`--description`, `--homepage`, `--topics`, `--root`, `--json`) |
81
81
  | `canon census [path]` | Report tracked file count, a breakdown by extension, and a line total that skips whatever reads as binary (`--json`) |
82
- | `canon audits run` | Run every audit as one set, report per check under one verdict, and compare each count to the recorded baseline (`--json`, `--record`) |
82
+ | `canon audits run` | Run every audit as one set, report per check under one verdict, and compare each count to the recorded baseline (`--json`, `--record`, `--corpus`) |
83
83
  | `canon audits list` | List every audit the set runs, with the corpus each reads and whether it gates (`--json`) |
84
84
  | `canon gate run` | Run every stage that guards a branch here, scoping shell, types, and tests to the changed set (`--all`, `--no-write`, `--nested`, `--json`) |
85
85
  | `canon inventory [subject]` | Walk every route a project declares and group its elements by the property each computes, as a listing rather than a gate (`--json`) |
@@ -77,8 +77,13 @@ One code for a failure, which is what a `bun run` caller and a git hook both rea
77
77
  "unmeasured": 0,
78
78
  "failed": 0
79
79
  },
80
- "stages": [{ "id": "indexes", "label": "Indexes", "status": "passed" }]
80
+ "ms": 44453,
81
+ "stages": [
82
+ { "id": "indexes", "label": "Indexes", "status": "passed", "ms": 210 }
83
+ ]
81
84
  }
82
85
  ```
83
86
 
84
87
  A failing stage carries its remedy in `failure`, and the same line goes to stderr so a caller reading neither the record nor the frame is still told what to fix.
88
+
89
+ `ms` carries the wall time each stage's checks took, including every process spawn, and the top-level `ms` sums them. Neither reading changes what a stage measures. Both exist so a slow run is attributable to a stage rather than read off a stopwatch held against the whole thing.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.48.0",
4
+ "version": "4.48.1",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -131,7 +131,8 @@ main() {
131
131
  run_phase "Prepare" bash -c "cd '$tmp_dir' && $prepare"
132
132
  fi
133
133
 
134
- run_phase "Sync" bash -c "cd '$tmp_dir' && CANON_NON_INTERACTIVE=1 canon tooling sync $stack . --write"
134
+ log_info "Sync resolves via bun $PROJECT_ROOT/src/cli.ts, not PATH canon"
135
+ run_phase "Sync" bash -c "cd '$tmp_dir' && CANON_NON_INTERACTIVE=1 bun '$PROJECT_ROOT/src/cli.ts' tooling sync $stack . --write"
135
136
 
136
137
  if [ -f "$tmp_dir/package.json" ]; then
137
138
  run_phase "lint:fix" bash -c "cd '$tmp_dir' && bun run lint:fix"
@@ -178,4 +179,6 @@ main() {
178
179
  fi
179
180
  }
180
181
 
181
- main "$@"
182
+ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
183
+ main "$@"
184
+ fi
@@ -100,6 +100,8 @@ export interface AuditResult {
100
100
  readonly counts?: Record<string, number>
101
101
  /** Why the audit did not report, present only on `unmeasured`. */
102
102
  readonly reason?: string
103
+ /** Wall time the spawn took, from the aggregate's own clock. */
104
+ readonly ms: number
103
105
  }
104
106
 
105
107
  /** The exit code every command here sets when it refuses. */
@@ -816,6 +818,7 @@ export function classify(
816
818
  spec: AuditSpec,
817
819
  exitCode: number,
818
820
  stdout: string,
821
+ ms = 0,
819
822
  ): AuditResult {
820
823
  const base = {
821
824
  id: spec.id,
@@ -823,6 +826,7 @@ export function classify(
823
826
  tracked: isTracked(spec),
824
827
  corpus: spec.corpus,
825
828
  exitCode,
829
+ ms,
826
830
  }
827
831
 
828
832
  let record: unknown
package/src/audits/run.ts CHANGED
@@ -1,10 +1,32 @@
1
1
  import { join } from 'node:path'
2
2
  import { execa } from 'execa'
3
3
  import type { Delta } from '@/audits/baseline'
4
- import { type AuditResult, type AuditSpec, classify } from '@/audits/catalog'
4
+ import {
5
+ type AuditResult,
6
+ type AuditSpec,
7
+ classify,
8
+ type Corpus,
9
+ } from '@/audits/catalog'
5
10
  import { gitEnv } from '@/git-env'
6
11
  import { PROJECT_ROOT } from '@/project-root'
7
12
 
13
+ /** Every value a corpus filter accepts, matching the `Corpus` union. */
14
+ export const CORPORA: readonly Corpus[] = ['tracked', 'per-machine', 'upstream']
15
+
16
+ /**
17
+ * The catalog scoped to the requested corpora, or the whole set when none
18
+ * were named. `--corpus` on `audits run` reads this, and so does `auditSet`
19
+ * in `src/gate/measures.ts`, which excludes the upstream corpus so the gate
20
+ * reads only what describes this tree.
21
+ */
22
+ export function auditsFor(
23
+ specs: readonly AuditSpec[],
24
+ requested: readonly string[],
25
+ ): readonly AuditSpec[] {
26
+ if (requested.length === 0) return specs
27
+ return specs.filter((spec) => requested.includes(spec.corpus))
28
+ }
29
+
8
30
  /** At least one audit reported a finding that is a fact. */
9
31
  export const EXIT_FINDING = 2
10
32
 
@@ -77,9 +99,10 @@ export async function runAudits(
77
99
  ): Promise<AuditResult[]> {
78
100
  return Promise.all(
79
101
  specs.map(async (spec) => {
102
+ const startedAt = performance.now()
80
103
  try {
81
104
  const { exitCode, stdout } = await spawn(spec)
82
- return classify(spec, exitCode, stdout)
105
+ return classify(spec, exitCode, stdout, performance.now() - startedAt)
83
106
  } catch (error) {
84
107
  return {
85
108
  id: spec.id,
@@ -89,6 +112,7 @@ export async function runAudits(
89
112
  corpus: spec.corpus,
90
113
  exitCode: 1,
91
114
  reason: `could not be started: ${error instanceof Error ? error.message : String(error)}`,
115
+ ms: performance.now() - startedAt,
92
116
  }
93
117
  }
94
118
  }),
@@ -9,8 +9,10 @@ import {
9
9
  readBaseline,
10
10
  writeBaseline,
11
11
  } from '@/audits/baseline'
12
- import { AUDITS, type AuditResult } from '@/audits/catalog'
12
+ import { AUDITS, type AuditResult, type Corpus } from '@/audits/catalog'
13
13
  import {
14
+ auditsFor,
15
+ CORPORA,
14
16
  exitCodeFor,
15
17
  runAudits,
16
18
  spawnAudit,
@@ -25,6 +27,11 @@ interface RunCommandOptions {
25
27
  readonly json?: boolean
26
28
  readonly record?: boolean
27
29
  readonly root?: string
30
+ readonly corpus?: readonly string[]
31
+ }
32
+
33
+ function collectCorpus(value: string, previous: string[]): string[] {
34
+ return [...previous, value]
28
35
  }
29
36
 
30
37
  interface ListCommandOptions {
@@ -52,6 +59,12 @@ export function register(program: Command): void {
52
59
  '--record',
53
60
  `Write this run's tracked counts to ${BASELINE_REL} as the new baseline`,
54
61
  )
62
+ .option(
63
+ '--corpus <corpus>',
64
+ `Limit the run to one corpus (${CORPORA.join(', ')}), repeatable. Defaults to every corpus`,
65
+ collectCorpus,
66
+ [] as string[],
67
+ )
55
68
  .addHelpText(
56
69
  'after',
57
70
  [
@@ -81,6 +94,7 @@ export function register(program: Command): void {
81
94
  ' canon audits run',
82
95
  ' canon audits run --json',
83
96
  ' canon audits run --record',
97
+ ' canon audits run --corpus tracked --corpus per-machine',
84
98
  '',
85
99
  ].join('\n'),
86
100
  )
@@ -175,6 +189,28 @@ async function headCommit(root: string): Promise<string> {
175
189
  async function runAll(opts: RunCommandOptions): Promise<number> {
176
190
  const emitJson = opts.json ?? false
177
191
  const root = opts.root ?? (await currentWorktreeRoot())
192
+ const requested = opts.corpus ?? []
193
+
194
+ const invalid = requested.filter(
195
+ (corpus) => !CORPORA.includes(corpus as Corpus),
196
+ )
197
+ if (invalid.length > 0) {
198
+ const message = `Unknown corpus ${invalid.join(', ')}. Valid corpora: ${CORPORA.join(', ')}.`
199
+ if (emitJson) {
200
+ process.stderr.write(`${message}\n`)
201
+ process.stdout.write(
202
+ `${JSON.stringify({ ok: false, reason: 'bad-corpus', message })}\n`,
203
+ )
204
+ return 1
205
+ }
206
+ intro('canon audits run')
207
+ logStep('Refused')
208
+ logError(message)
209
+ outro()
210
+ return 1
211
+ }
212
+
213
+ const audits = auditsFor(AUDITS, requested)
178
214
 
179
215
  let baseline: Baseline | undefined
180
216
  try {
@@ -195,7 +231,7 @@ async function runAll(opts: RunCommandOptions): Promise<number> {
195
231
  return 1
196
232
  }
197
233
 
198
- const results = await runAudits(AUDITS, spawnAudit(root))
234
+ const results = await runAudits(audits, spawnAudit(root))
199
235
  const deltas = compareBaseline(baseline, results)
200
236
  const summary = summarize(results, deltas)
201
237
 
@@ -134,11 +134,13 @@ async function runGate(opts: RunCommandOptions): Promise<number> {
134
134
  scoped: changed.scoped,
135
135
  changed: changed.files.length,
136
136
  summary,
137
- stages: results.map(({ id, label, status, failure }) => ({
137
+ ms: results.reduce((total, result) => total + result.ms, 0),
138
+ stages: results.map(({ id, label, status, failure, ms }) => ({
138
139
  id,
139
140
  label,
140
141
  status,
141
142
  failure,
143
+ ms,
142
144
  })),
143
145
  })}\n`,
144
146
  )
package/src/deps/audit.ts CHANGED
@@ -38,6 +38,15 @@ export interface Advisory {
38
38
  */
39
39
  export type AuditRefusal = 'no-manifest' | 'no-lockfile' | 'no-record'
40
40
 
41
+ /**
42
+ * The ceiling on `bun audit --json`, which carries no timeout of its own and
43
+ * runs to bun's own 299-second ceiling on a stall. Three consecutive clean
44
+ * runs on this tree measured 44.7, 77.6, and 62.9 seconds, so 120 seconds
45
+ * leaves margin over the slowest observed pass while still bounding a stall
46
+ * well under bun's own ceiling.
47
+ */
48
+ const AUDIT_TIMEOUT_MS = 120_000
49
+
41
50
  /** The lockfiles a resolved dependency set leaves behind, in any manager. */
42
51
  const LOCKFILES = [
43
52
  'bun.lock',
@@ -115,6 +124,22 @@ export function countBySeverity(
115
124
  return counts
116
125
  }
117
126
 
127
+ /**
128
+ * The message a caller reads for a refusal with no advisory record, naming a
129
+ * stall as its own cause rather than folding it into "no output on stdout",
130
+ * which is what an empty stdout reads as either way.
131
+ */
132
+ export function noRecordMessage(result: {
133
+ readonly timedOut: boolean
134
+ readonly stderr: string
135
+ }): string {
136
+ if (result.timedOut) {
137
+ return `timed out after ${AUDIT_TIMEOUT_MS}ms waiting on the advisory index`
138
+ }
139
+ const line = result.stderr.trim().split('\n').pop()
140
+ return line !== undefined && line !== '' ? line : 'no output on stdout'
141
+ }
142
+
118
143
  /**
119
144
  * Shells the runtime's own advisory command rather than carrying an index.
120
145
  *
@@ -138,6 +163,7 @@ export async function auditDependencies(root: string): Promise<DepsAudit> {
138
163
  const result = await execa('bun', ['audit', '--json'], {
139
164
  cwd: root,
140
165
  reject: false,
166
+ timeout: AUDIT_TIMEOUT_MS,
141
167
  })
142
168
 
143
169
  const advisories = parseAdvisories(result.stdout)
@@ -145,7 +171,7 @@ export async function auditDependencies(root: string): Promise<DepsAudit> {
145
171
  return {
146
172
  kind: 'refused',
147
173
  reason: 'no-record',
148
- message: result.stderr.trim().split('\n').pop() ?? 'no output on stdout',
174
+ message: noRecordMessage(result),
149
175
  }
150
176
  }
151
177
 
@@ -686,10 +686,15 @@ interface AuditSummary {
686
686
 
687
687
  /**
688
688
  * The three stages gating on the three findings here that are facts sit above,
689
- * and this stage reports the rest. It runs the whole set anyway rather than
690
- * only what those stages skip, because the aggregate's own value is one verdict
691
- * over every audit, and a stage measuring a subset would report a health this
692
- * repository never took.
689
+ * and this stage reports the rest. It runs every tracked and per-machine audit
690
+ * rather than only what those stages skip, because the aggregate's own value
691
+ * is one verdict over the corpora describing this tree, and a stage measuring
692
+ * a narrower subset would report a health this repository never took.
693
+ *
694
+ * The upstream corpus is excluded. `deps` reaches a network index rather than
695
+ * this tree, and its wall time dwarfs every other stage combined, so a push
696
+ * gated on it waits on a lookup this tree's own health never depended on. A
697
+ * scheduled `verify.yml` job reports the advisory instead.
693
698
  *
694
699
  * This reports and never fails. Growth in a judgment count is the thing the
695
700
  * baseline exists to make visible, and failing a push on one would teach a
@@ -697,7 +702,15 @@ interface AuditSummary {
697
702
  * specific stage above that names its own remedy.
698
703
  */
699
704
  export const auditSet: Measure = async (ctx) => {
700
- const run = await ctx.cli(['audits', 'run', '--json'])
705
+ const run = await ctx.cli([
706
+ 'audits',
707
+ 'run',
708
+ '--corpus',
709
+ 'tracked',
710
+ '--corpus',
711
+ 'per-machine',
712
+ '--json',
713
+ ])
701
714
 
702
715
  if (run.stdout.trim() === '') {
703
716
  return {
@@ -27,6 +27,8 @@ export interface StageResult {
27
27
  readonly emissions: readonly Emission[]
28
28
  /** The remedy line a failed stage prints, naming what to do about it. */
29
29
  readonly failure?: string
30
+ /** Wall time the stage's checks took, including every process spawn. */
31
+ readonly ms: number
30
32
  }
31
33
 
32
34
  export interface GateContext extends MeasureContext {
@@ -133,6 +135,17 @@ export async function runStage(
133
135
  stage: Stage,
134
136
  ctx: GateContext,
135
137
  ): Promise<StageResult> {
138
+ const startedAt = performance.now()
139
+ const result = await executeStage(stage, ctx)
140
+ return { ...result, ms: performance.now() - startedAt }
141
+ }
142
+
143
+ type StageOutcome = Omit<StageResult, 'ms'>
144
+
145
+ async function executeStage(
146
+ stage: Stage,
147
+ ctx: GateContext,
148
+ ): Promise<StageOutcome> {
136
149
  if (stage.scope !== undefined && !hasChanged(stage.scope, ctx.changed)) {
137
150
  return {
138
151
  id: stage.id,
@@ -496,6 +496,22 @@ async function resolveStem(
496
496
 
497
497
  if (selector.kind === 'stem') {
498
498
  if (!stems.includes(selector.stem)) {
499
+ const prefixed = stems.filter((stem) => stem.startsWith(selector.stem))
500
+ if (prefixed.length === 1) {
501
+ const [match] = prefixed
502
+ return refuse(
503
+ 'no-match',
504
+ `${selector.stem} does not name a task by itself. One task starts with it: ${match}. Pass the full name to archive it.`,
505
+ [match],
506
+ )
507
+ }
508
+ if (prefixed.length > 1) {
509
+ return refuse(
510
+ 'ambiguous',
511
+ `${selector.stem} does not name a task by itself. ${prefixed.length} tasks start with it. Pass the full name to archive one.`,
512
+ prefixed,
513
+ )
514
+ }
499
515
  return refuse(
500
516
  'no-match',
501
517
  `No task named ${selector.stem} on the board.`,
@@ -30,6 +30,14 @@ A wireframe works when someone can rebuild the surface from it without opening t
30
30
 
31
31
  A wireframe that fails these is non-conforming regardless of whether it satisfies every section rule below. The fences are the means. These three questions are the test.
32
32
 
33
+ ## Transcription wireframes
34
+
35
+ A wireframe regenerated from an already-built surface's own render code may carry what the sections below forbid: the source file and function, the class or token names driving each region, and pixel-exact geometry. Open the file by naming that source: the render function, the stylesheet rule, or the built file the block was read off. The citation is what separates a transcription wireframe from one that merely broke the rules below, since a role label with nothing behind it is unchecked and a class name traced to a real file and line is not.
36
+
37
+ A drafted-ahead wireframe states intent in role terms because nothing exists yet to check it against. A regenerated one is checkable against the code it mirrors, and a role label standing in for the real class name would throw that check away. The two documents answer different questions, so one set of rules cannot govern both.
38
+
39
+ Route detail the source citation does not already carry to `.claude/context/` as usual: algorithms, event-handler code, workarounds, and rejected approaches stay out of the wireframe regardless of which mode it is written in.
40
+
33
41
  ## Frontmatter
34
42
 
35
43
  - `title` (required): sentence case, names the surface (`Header`, `Section nav`, `Footer`).
@@ -41,7 +49,7 @@ Both fields feed `.claude/wireframes/index.md` when regenerated.
41
49
 
42
50
  - Draw each surface as an ASCII block inside a `plaintext` fence. One fence per distinct layout.
43
51
  - Label regions with `←` annotations. Never use `#` for annotations.
44
- - Show a region's role, not its styling. `← status pill` reads better than a class name or hex value.
52
+ - Show a region's role, not its styling. `← status pill` reads better than a class name or hex value. A transcription wireframe is the exception: see `## Transcription wireframes`.
45
53
  - Keep the grid honest. The ASCII proportions should match the intended widths, since conveying proportion is the wireframe's job.
46
54
 
47
55
  ## Variants
@@ -63,7 +71,7 @@ Both fields feed `.claude/wireframes/index.md` when regenerated.
63
71
 
64
72
  ## What moves to .claude/context/
65
73
 
66
- Implementation detail that answers how the surface is built belongs in a `.claude/context/` entry, not the wireframe:
74
+ This section does not govern a transcription wireframe's citation of its own source. See `## Transcription wireframes`. Implementation detail that answers how the surface is built belongs in a `.claude/context/` entry, not the wireframe:
67
75
 
68
76
  - Algorithms, event-handler code, and lifecycle ordering
69
77
  - Framework component names, props, and CSS class or token names