@journeykit/canon 0.15.0 → 0.17.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,13 +1,34 @@
1
1
  # canon-plan-import
2
2
 
3
- `canon ingest plans` imports a FOREIGN plan dialect's on-disk state into
4
- canon's `change`/`task` records, through the same validated path every
5
- other ingest family uses. Imported rows carry no special marker — once
3
+ `canon ingest plans` imports plan state into canon's `change`/`task`
4
+ records, through the same validated path every other ingest family uses:
5
+ canon's own changes under `.canon/changes/`, and any FOREIGN plan dialect
6
+ listed in `canon.yaml`. Imported rows carry no special marker — once
6
7
  imported, `canon query` cannot tell an imported row from a hand-authored
7
8
  one, and neither can `canon gate`.
8
9
 
9
- ## 1. Configure a `plans:` source
10
-
10
+ ## 1. Canon's own changes and external `plans:` sources
11
+
12
+ `.canon/changes/` is canon's native plan source (dialect id `canon`):
13
+ one `<slug>/` directory per change holding `brief.md` and `tasks.md`,
14
+ written by `canon change new` (see `canon-subject`). Every plan reader —
15
+ `canon ingest plans`, `canon gate task`, `canon evidence add --task`,
16
+ `canon dispatch --task`, `canon status` and the brief check — reads it
17
+ first whenever the directory exists, with no `plans:` entry. Being first,
18
+ it owns a change id an external source also carries (the external copy
19
+ is the later duplicate, skipped and counted under `duplicate_change_id`).
20
+ Its records carry the actor `canon-plan-import-canon`; an external
21
+ openspec change's carry `canon-plan-import-openspec`. A `brief.md` with a
22
+ `Source: <path>` line is not a change of its own: it is the canon brief
23
+ of a change imported from an external plan (it points at the external
24
+ document and holds the human's `Approved:` line), so the native reader
25
+ skips that directory and the external source keeps the change id.
26
+
27
+ `plans.sources` lists only EXTERNAL plans — documents you prepare outside
28
+ canon, which canon only reads. The one exception: `canon gate task`
29
+ flips the task's checkbox in an external plan's `tasks.md` (see
30
+ `canon-gate`). Their briefs and approvals live in
31
+ `.canon/changes/<change>/brief.md`, never in the external document.
11
32
  `canon.yaml`:
12
33
 
13
34
  ```yaml
@@ -17,17 +38,22 @@ plans:
17
38
  root: . # resolved relative to canon.yaml's directory
18
39
  ```
19
40
 
20
- - `canon init` writes exactly this source, plus an empty
21
- `openspec/changes/` (so the adapter never scans the repo root itself).
22
- - An absent `plans:` section = zero sources (a clean no-op).
41
+ - `canon init` writes `sources: []`, preceded by comments explaining
42
+ `.canon/changes/` and commented examples for `openspec` (`root: .`) and
43
+ `superpowers` (`root: docs/superpowers/plans`). It creates no
44
+ `openspec/changes/` directory.
45
+ - An absent `plans:` section = zero external sources (a clean no-op;
46
+ `.canon/changes/` is still read).
23
47
  - A present section parses STRICTLY: a typo'd key, an unregistered
24
48
  `dialect`, or a nonexistent `root` fails the command loud, naming the
25
- offender.
49
+ offender. `canon` is not a registered dialect: a source naming it is
50
+ refused, since `.canon/changes/` needs no entry.
26
51
  - `root` is whatever the dialect's adapter scans — for `openspec`, a
27
52
  repo root containing `openspec/changes/`, or that changes dir directly.
28
53
 
29
- One-shot override (bypasses `canon.yaml` for a single ad hoc import;
30
- either flag alone fails loud):
54
+ One-shot override (bypasses `canon.yaml` and `.canon/changes/` for a
55
+ single ad hoc import, reading only the source it names; either flag alone
56
+ fails loud):
31
57
 
32
58
  ```bash
33
59
  canon ingest plans --dialect openspec --source ../other-repo
@@ -36,7 +62,7 @@ canon ingest plans --dialect openspec --source ../other-repo
36
62
  ## 2. Run the import
37
63
 
38
64
  ```bash
39
- canon ingest plans # canon.yaml-driven, every configured source
65
+ canon ingest plans # .canon/changes/ + every configured source
40
66
  canon ingest plans --json # machine-readable outcome
41
67
  canon ingest plans --repo ../svc # a specific repo root
42
68
  ```
@@ -60,9 +86,19 @@ canon query --kind task [--json]
60
86
  No dialect/plugin filter exists or is needed — an imported `change`/
61
87
  `task` is an ordinary core record.
62
88
 
89
+ ## The `canon` dialect (`.canon/changes/`)
90
+
91
+ Uses the `openspec` change-dir grammar below, with three differences:
92
+ the brief is `brief.md` (its `## Why` first paragraph is the summary; a
93
+ missing one is a `missing-brief-md` malformed dir), the source root is
94
+ always `.canon/changes/` itself (archived changes under
95
+ `.canon/changes/archive/`), and a `brief.md` carrying a `Source:` line is
96
+ skipped (a pointer brief, above). `tasks.md` uses the same checkbox row
97
+ grammar.
98
+
63
99
  ## The `openspec` dialect
64
100
 
65
- Maps `openspec/changes/**` onto core kinds:
101
+ Maps an external `openspec/changes/**` onto core kinds:
66
102
 
67
103
  | openspec construct | Core mapping |
68
104
  | --- | --- |
@@ -76,7 +112,9 @@ Maps `openspec/changes/**` onto core kinds:
76
112
  A missing/unreadable `proposal.md`, or a basename that isn't a valid
77
113
  `change_id`, skips the WHOLE dir (counted `malformed`); siblings
78
114
  unaffected. A proposal-only dir imports as `Change { status: proposed }`
79
- with zero tasks and zero diagnostics.
115
+ with zero tasks and zero diagnostics. An `Approved:` line in an external
116
+ `proposal.md` is not read: the change's approval lives in
117
+ `.canon/changes/<change>/brief.md` (see `canon-subject`).
80
118
 
81
119
  ## The `superpowers` dialect
82
120
 
@@ -136,9 +174,10 @@ downstream reader's job.
136
174
 
137
175
  ## What this skill does NOT cover
138
176
 
139
- - **Authoring an openspec change dir** (`proposal.md`/`tasks.md`
140
- conventions) — `canon change new` scaffolds one and adopts it into a
141
- subject (see `canon-subject`); this skill covers IMPORTING that shape.
177
+ - **Authoring a change** — `canon change new` writes
178
+ `.canon/changes/<slug>/brief.md` and `tasks.md` and adopts the change
179
+ into a subject (see `canon-subject`); it never scaffolds an openspec
180
+ change dir. This skill covers IMPORTING plans.
142
181
  - **`canon gate check`/`canon gate task`** (the hand-authored one-task-at-
143
182
  a-time path this complements at bulk) — see `canon-gate`.
144
183
  - **Wiring an imported `Task`/`Change` into a `canon gate` decision** —
@@ -26,21 +26,45 @@ diagnostic and reports `clean: false`.
26
26
  ## The starter policy `canon init` writes
27
27
 
28
28
  A fresh `canon init` writes `.canon/policy.yaml` (skip it with
29
- `--no-policy`; an existing file is never overwritten):
29
+ `--no-policy`; an existing file is never overwritten). `--policy
30
+ light|standard|strict` picks how much it asks for; the default is
31
+ `strict`:
32
+
33
+ | Level | `spec_coverage` |
34
+ |---|---|
35
+ | `light` | `require_evidence: true` — every scenario needs evidence |
36
+ | `standard` | light, plus `require_cases: [failure]` — every feature surface needs a `@case:failure` scenario |
37
+ | `strict` | standard, plus `require_review: {}` — verifying/shipped subjects need an independent review |
30
38
 
31
39
  ```yaml
40
+ # canon policy level: strict
32
41
  spec_coverage:
33
42
  require_evidence: true # every scenario needs evidence
34
43
  require_cases: [failure] # every feature surface needs a @case:failure scenario
35
44
  require_review: {} # verifying/shipped subjects need an independent review
36
45
  ```
37
46
 
38
- The file carries comments explaining each field. It loads with zero
39
- diagnostics. It is what `canon gate check` grades the work against, so a
40
- human should review and approve it, and every later change to it (next
41
- section). The agent doing the work should not write its own grading
42
- policy. Narrow it with `scope: [building, verifying]` to check evidence
43
- only for subjects in those statuses (see `canon-gate`).
47
+ The first line names the level. The file carries comments explaining
48
+ each field, and a `light` or `standard` starter carries the next levels'
49
+ keys as commented lines: to raise the level, uncomment them and change
50
+ the level line, and a human approves the edit (next section). An
51
+ unknown `--policy` value exits 2, and so does `--policy` with `--no-policy`.
52
+
53
+ `canon status` prints the level under the policy settings
54
+ (`level: strict`, or `level: light — raise it by …` with how to raise
55
+ it) and in `--json` as `policy.level`. It reads the level line when the
56
+ file's leading comment block has one naming a known level; otherwise it
57
+ infers the level from the `spec_coverage` section, which must match a
58
+ level exactly (no `scope`, `exclude_lanes`, `require_audit`, or
59
+ non-default `require_review` settings); anything else is `custom`.
60
+ `policy.level` is `null` when there is no policy.
61
+
62
+ Every level loads with zero diagnostics. The policy is what `canon gate
63
+ check` grades the work against, so a human should review and approve
64
+ it, and every later change to it (next section). The agent doing the
65
+ work should not write its own grading policy. Narrow it with `scope:
66
+ [building, verifying]` to check evidence only for subjects in those
67
+ statuses (see `canon-gate`).
44
68
 
45
69
  ## Approving the policy
46
70
 
@@ -48,8 +72,11 @@ only for subjects in those statuses (see `canon-gate`).
48
72
  canon policy approve --by <who> [--note <text>] [--repo <dir>]
49
73
  ```
50
74
 
51
- A human runs this after reviewing `.canon/policy.yaml`. It writes
52
- `.canon/policy.approval.json` directly (nothing to promote):
75
+ A human approves the policy after reviewing `.canon/policy.yaml`, in the
76
+ same answer that approves a subject's brief (0.16; see the skill's working
77
+ loop). The human runs this, or the agent runs it right after that answer
78
+ as `canon policy approve --by <the human> --note "<their words>"`. It
79
+ writes `.canon/policy.approval.json` directly (nothing to promote):
53
80
 
54
81
  ```json
55
82
  {
@@ -85,8 +112,8 @@ An approval file that does not parse reads
85
112
  `--json` `warnings`). The advisory never changes an exit code: canon
86
113
  cannot tell whether a human or an agent ran the command, so the record
87
114
  names who approved which bytes, and the advisory says when that no
88
- longer holds. An agent never runs `canon policy approve` on its own
89
- behalf.
115
+ longer holds. An agent runs `canon policy approve` only to record a
116
+ human's answer, never without one and never on its own behalf.
90
117
 
91
118
  ## The closed CEL profile
92
119
 
@@ -17,8 +17,10 @@ state they are in.
17
17
  The order an agent follows to claim a subject done:
18
18
 
19
19
  1. **Test-run evidence** per owned scenario (`canon evidence add
20
- --scenario-id <id> --project-id <root-id> ... --session-id <session>`,
21
- then `canon gate promote`). See `canon-gate`.
20
+ --scenario-id <id> --project-id <root-id> ... --session-id <session>`;
21
+ final when it returns under `write: { promote: auto }`, which
22
+ `canon init` writes, and followed by `canon gate promote` only under
23
+ `write.promote: manual`). See `canon-gate`.
22
24
  2. **Independent review** of the change(s) the subject adopted. Record
23
25
  each issue the reviewer raises as a Finding, fix it, then close it:
24
26
 
@@ -26,13 +28,14 @@ The order an agent follows to claim a subject done:
26
28
  canon finding add --change-id c-demo --round 1 --seq 1 \
27
29
  --severity blocker --disposition open --reviewer reviewer-2 \
28
30
  --summary "unbounded subprocess wait" --session-id review-session-1
29
- canon gate promote
30
31
  # ... fix, commit ...
31
32
  canon finding close --change-id c-demo --round 1 --seq 1 \
32
33
  --disposition fixed --resolution-sha "$(git rev-parse HEAD)"
33
- canon gate promote
34
34
  ```
35
35
 
36
+ Under `write.promote: manual` (or no `write:` key), run
37
+ `canon gate promote` after each of the two.
38
+
36
39
  3. **One Review per scenario**, by someone other than the evidence actor,
37
40
  from a session other than the one that attested the evidence, after
38
41
  the evidence is in (the review pins what it saw):
@@ -48,7 +51,9 @@ The order an agent follows to claim a subject done:
48
51
  reviewer could not reach a verdict, record that instead of signing:
49
52
  `--outcome indeterminate` (or `unavailable` when it could not run).
50
53
  Changing the scenario or re-attesting it afterwards makes the review
51
- stale; review it again.
54
+ stale; review it again. A reviewer of user-visible behavior attaches
55
+ the screenshots or recordings it looked at with `--artifact <path>`
56
+ (below).
52
57
 
53
58
  4. **Move the subject**: `canon subject status <id> verifying`, later
54
59
  `shipped`. See `canon-subject`.
@@ -70,8 +75,12 @@ canon finding close --change-id <c> --round <n> --seq <n> \
70
75
  --disposition fixed --resolution-sha <sha> [--session-id <id>] # or rejected / deferred / open
71
76
  ```
72
77
 
73
- Both stage a candidate; `canon gate promote` commits it, and both say so
74
- (``… — run `canon gate promote` to commit it``). `finding close`
78
+ Both stage a candidate. Under `write: { promote: auto }` (what
79
+ `canon init` writes) the same run promotes it and the success line names
80
+ the committed record (``wrote kind=finding/… — staged and promoted in this
81
+ run; nothing to promote``); under `write.promote: manual`
82
+ `canon gate promote` commits it, and the line says so (``staged … — run
83
+ `canon gate promote` to commit it``). `finding close`
75
84
  changes only the disposition (and `resolution_sha` with it); severity,
76
85
  reviewer, summary, and refs are copied from the committed record. It
77
86
  appends a second version at the same natural key — the first stays. Its
@@ -130,7 +139,8 @@ canon review add --project-id <root-id> --scenario-id <id> \
130
139
  --reviewer <who> --pin <sha-or-ref> --role <role> \
131
140
  (--upstream-ref <ref> | --original-spec-ref <ref>) [--actor-id <id>] [--session-id <id>] \
132
141
  [--outcome signed|indeterminate|unavailable] \
133
- [--reviewer-provider <p>] [--reviewer-model <m>] [--reviewer-harness <h>]
142
+ [--reviewer-provider <p>] [--reviewer-model <m>] [--reviewer-harness <h>] \
143
+ [--artifact <path>]... [--max-artifact-mib <MiB>]
134
144
  ```
135
145
 
136
146
  One attributed Review record for one scenario, joined on
@@ -143,6 +153,40 @@ own. It is written directly to the ledger, with no staging step, and says
143
153
  so: `canon review add: wrote <path> (new) — written directly; nothing to
144
154
  promote`.
145
155
 
156
+ ### What the reviewer looked at (artifacts)
157
+
158
+ `--artifact <path>` (repeatable) binds a file the reviewer looked at — a
159
+ screenshot, a recording, a trace — exactly as `canon evidence add
160
+ --artifact` binds one: the path must resolve to a file inside the
161
+ repository, a file over 25 MiB is refused unless `--max-artifact-mib <N>`
162
+ raises the limit, and the bytes are copied into
163
+ `.canon/artifacts/sha256/<hex>` (commit it with the ledger) before the
164
+ review is written. Any refusal exits 2 and writes and stores nothing. The
165
+ review records each distinct file once, in the order given:
166
+
167
+ ```json
168
+ "artifacts": [{"path": "reports/title.png", "sha256": "<64 hex>"}]
169
+ ```
170
+
171
+ The field is absent when no `--artifact` is given. `review add` prints
172
+ `canon review add: artifact store holds <n> bound file(s) under
173
+ .canon/artifacts/sha256/ (<k> newly stored) — commit them with the
174
+ ledger`.
175
+
176
+ `canon gate check` re-hashes the stored blob of every file bound to an
177
+ in-effect review (the latest per `(project_id, scenario_id, reviewer)`).
178
+ A missing or altered blob is `stale-evidence` on the review's scenario,
179
+ naming the review, even when the working-tree file still matches (see
180
+ `canon-gate`, "Bound evidence stays provable"):
181
+
182
+ ```
183
+ stale-evidence world.demo.01 — review by `reviewer-2` (actor `reviewer-2`, 2026-10-11T09:00:00Z) bound file `reports/title.png` recorded sha256 <hex>; stored blob .canon/artifacts/sha256/<hex>: missing; working tree: <hex> — the bound bytes are gone, look again and re-record the review with `canon review add --artifact <path>`
184
+ ```
185
+
186
+ The reviewer's later review replaces the earlier one, so its files are
187
+ the ones checked. Canon binds and re-checks bytes only; it never compares
188
+ images or judges what a screenshot shows.
189
+
146
190
  ### What the review saw (pins)
147
191
 
148
192
  `review add` reads the corpus and records `pins` on the review; the
@@ -245,20 +289,43 @@ The scenario is reviewed when at least one of those latest reviews:
245
289
  1. is `signed`;
246
290
  2. still matches its pins — the scenario record's current
247
291
  `source_digest`, and its current latest evidence record per role —
248
- when it has pins;
292
+ when it has pins; or, written before 0.15 without pins, still
293
+ describes its scenario at the commit it pinned (below);
249
294
  3. passes `distinct_actor`, when that is on.
250
295
 
251
- A review written before 0.15 has no pins. It keeps counting under rules
252
- 1 and 3, without the freshness check, and `canon gate check` prints one
253
- advisory line counting such in-effect reviews (exit code unchanged):
296
+ ### Reviews written before pinning
297
+
298
+ A review written before 0.15 has no pins, only `pin`: the commit it
299
+ reviewed. The gate reads the scenario's `.feature` file at that commit
300
+ (`git`, with the same per-scenario normalization `canon inventory sync`
301
+ uses for `scenario_digest`; if the scenario was in another file then, the
302
+ spec root's files at that commit are searched for its id) and decides:
303
+
304
+ - **current** when that digest equals the scenario's current one and no
305
+ declared evidence record of the scenario is newer than the review: it
306
+ counts under rules 1 and 3;
307
+ - **stale** when the scenario changed since the commit (`its spec changed
308
+ since <pin>`) or has evidence newer than the review (`it has declared
309
+ evidence newer than the review`): it no longer counts, exactly like a
310
+ stale pinned review;
311
+ - **undecidable** when the pin is not a commit in this clone (shallow
312
+ clone, rewritten history) or the scenario id is absent at it: it keeps
313
+ counting, as in 0.15. A missing commit never becomes a violation.
314
+
315
+ `canon gate check` prints one advisory line with the split (exit code
316
+ unchanged by the line itself). A stale one whose scenario already has
317
+ another qualifying review is counted as superseded and not named:
254
318
 
255
319
  ```
256
- advisory: 340 review(s) predate review pinning (0.15) and still count without a check that their scenario's spec and evidence are unchanged; a new `canon review add` pins what it saw
320
+ advisory: 338 review(s) predate review pinning (0.15) and were checked against the commit each one pinned: 318 current, 12 stale, 8 undecidable; 7 stale one(s) superseded by a current review of the same scenario, so nothing is owed for them; a stale one with no current review in its place no longer counts — review its scenario again (`canon review add`): platformer.movement.01, platformer.movement.02, …; an undecidable one still counts unchecked, because its pinned commit or the scenario at it is not in this clone — check out full history (`fetch-depth: 0` in CI) to check it
257
321
  ```
258
322
 
259
323
  `canon status` prints the same line as a warning, and `canon subject
260
- status` prints it on stderr for the subject's own scenarios. Reviewing
261
- the scenario again pins it and drops it from the count.
324
+ status` prints it on stderr for the subject's own scenarios; all three
325
+ make the same decision. Re-review exactly the scenarios the line names:
326
+ the new review pins what it saw and replaces the reviewer's legacy one.
327
+ In CI, check out full history (`fetch-depth: 0`) for the job that runs
328
+ the gate, or every legacy review is undecidable.
262
329
 
263
330
  ### `unreviewed-promotion`
264
331
 
@@ -149,7 +149,150 @@ Every query, export, and purge appends an actor-stamped record to
149
149
  `.canon/audit/access.jsonl` (mode `0600` on creation) in the repository
150
150
  whose `canon.yaml` it read (through `--repo` or `--canon-yaml`), never in
151
151
  the current directory; with no `canon.yaml` there is no audit and nothing
152
- is written. `CANON_ACTOR` takes
152
+ is written. The log is local operator state: `canon init` keeps
153
+ `.canon/audit/` out of git (below). `CANON_ACTOR` takes
153
154
  precedence over the OS username. Audit rows contain operation, kind, scope,
154
155
  sensitive flag, counts, and timestamp only—never query text, secrets, or
155
156
  record bodies.
157
+
158
+ ## Staged writes and promotion
159
+
160
+ Four writes stage their record instead of committing it: `canon evidence
161
+ add`, `canon finding add` and `canon finding close` (under
162
+ `.canon/ledger/_staging/`, committed by `canon gate promote`) and `canon
163
+ divergence stage` (committed by `canon divergence promote`). `canon.yaml`'s
164
+ `write:` section decides whether they promote in the same run:
165
+
166
+ ```yaml
167
+ write:
168
+ promote: auto # or manual
169
+ ```
170
+
171
+ - `auto` — the command stages, then promotes that one record through the
172
+ same validation and assignment `canon gate promote` (or `canon
173
+ divergence promote`) applies. Records other runs staged stay staged;
174
+ only `canon gate promote` / `canon divergence promote` commit
175
+ everything staged. The one success line names the committed record
176
+ (`wrote kind=…/….json`; promotion renames it, so the staged file is not
177
+ named) and ends `— staged and promoted in this run; nothing to
178
+ promote`. `canon init` writes `auto`.
179
+ - `manual` — the record stays staged and the line ends ``— run `canon
180
+ gate promote` to commit it``. An absent `write:` key (or `promote:`
181
+ field) is `manual`, so a repo that never set it is unchanged.
182
+
183
+ `--promote` / `--no-promote` decide for one write and win over the config
184
+ without reading it; both at once is a usage error (exit `2`). If the
185
+ promotion refuses the command's own record, the record stays staged, the
186
+ line ends ``— run `canon gate promote` to commit it`` (`canon divergence
187
+ promote` for a divergence), and the command exits `1`. With neither flag,
188
+ a malformed `write:` section (anything but `promote: auto | manual`)
189
+ refuses the write, exit `2`, before anything is staged. `canon init
190
+ --check-config` reports `[PASS] write (promote: auto)`, `[not configured]
191
+ write (promote: manual)`, or `[FAIL] write: …`. `canon gate promote` still
192
+ works under `auto` and is idempotent: with nothing staged it prints
193
+ `nothing staged` and exits `0`.
194
+
195
+ ## Harnesses: the `skills:` section
196
+
197
+ `canon.yaml`'s `skills:` section names the harnesses the canon skill is
198
+ installed for (0.17):
199
+
200
+ ```yaml
201
+ skills:
202
+ providers: [claude, codex] # any of claude, codex, omp, pi; [] for none
203
+ ```
204
+
205
+ `canon init` writes it: on a terminal it asks, with the harnesses whose
206
+ `.claude`, `.codex`, `.omp` or `.pi` directory exists checked;
207
+ `--providers a,b` (or `none`) answers without asking; with neither it takes
208
+ the detected ones, and with none detected it writes only a commented example
209
+ and installs nothing. `.agents` alone never selects Codex. `canon skills
210
+ install|check|doctor` follow the section; `canon skills install
211
+ --providers=<list>` rewrites it in place. Without the section they use the
212
+ install lock's providers, then the detected ones; nothing undetected is ever
213
+ added. A present section that is not this shape, or names another harness,
214
+ fails those commands (exit `1`), and `canon init --check-config` reports
215
+ `[PASS] skills (providers: …)`, `[not configured] skills (…)`, or
216
+ `[FAIL] skills: …`.
217
+
218
+ ## The scenario index
219
+
220
+ `canon inventory sync` materializes one `Scenario` record per
221
+ `(project_id, scenario_id)` under `.canon/ledger/kind=scenario/`. The
222
+ index is derived — a pure function of the `.feature` files — so the
223
+ commands that read it rebuild it first: `canon gate check`, `canon
224
+ status`, `canon subject status`, `canon review add` and `canon evidence
225
+ add`. The index is stale when a `.feature` file holds a scenario with no
226
+ live index record, or one whose recorded source digest differs from the
227
+ file's current bytes, or when the index holds a scenario of a spec root
228
+ that no `.feature` file of it declares any more. The reader then runs
229
+ `canon inventory sync`'s code on the changed files (validation still
230
+ covers the whole root) and prints one stderr line:
231
+
232
+ ```
233
+ canon status: refreshed the scenario index from 1 changed feature file(s) (.canon/specs/kind=feature/area=cart/add.feature): 1 record(s) written
234
+ ```
235
+
236
+ A deleted scenario is not erased: `canon inventory sync` — and a reader's
237
+ refresh, identically — appends a tombstone, a new generation of its
238
+ record with `removed: true`. The gate, `canon status`, `canon query` and
239
+ the report panels then treat it as absent; re-adding the scenario writes
240
+ a live generation that supersedes the tombstone. A root that fails to
241
+ sync writes no tombstone either.
242
+
243
+ ```
244
+ canon status: refreshed the scenario index from 1 scenario(s) no longer in any feature file (cart.pay.01): 1 record(s) written
245
+ ```
246
+
247
+ `canon inventory sync` and every refresh hold an OS file lock on
248
+ `.canon/scenario-index.lock` while they write, so concurrent runs never
249
+ index one change twice; a refresh that waited checks staleness again and
250
+ is silent if another run already did the work. The lock is released when
251
+ its holder exits, however it exits, so it never goes stale; a writer
252
+ gives up after 30 s with a message naming the file (`canon inventory
253
+ sync` exits `2`; a reader skips the refresh and still runs).
254
+
255
+ A fresh index prints nothing. A root that fails to sync (format
256
+ violations, a duplicate id) is named and left as it was, and the command
257
+ still runs; `canon gate check` then fails with `uncovered-cell <root>`,
258
+ the `verifying → shipped` guard refuses, and `canon status` lists only
259
+ `canon inventory sync`, while the reporting readers (`query`, `context`)
260
+ keep only this line:
261
+
262
+ ```
263
+ canon gate check: found the scenario index stale for 1 changed feature file(s) (…): 0 record(s) written; NOT refreshed: root `root` (2 format violation(s), 0 sync error(s)) — `canon inventory sync` lists them
264
+ ```
265
+
266
+ Nothing is written in a directory without `canon.yaml`. `canon inventory
267
+ sync` stays the explicit command, and the one that lists violations and
268
+ tag diagnostics.
269
+
270
+ ## The artifact store lock
271
+
272
+ Every write to the evidence artifact store (`.canon/artifacts/sha256/`:
273
+ `canon evidence add`, `canon review add`, `canon evidence vault`) holds an
274
+ OS file lock on `.canon/artifact-store.lock` from its "already stored?"
275
+ check through its last rename, so a write that fails and rolls back never
276
+ removes a blob another writer published or counted as stored. A rollback
277
+ removes only the blobs its own write renamed into place; a blob already
278
+ stored intact counts as stored and is not written again. The lock is
279
+ released when its holder exits, however it exits, so it never goes stale;
280
+ a writer gives up after 30 s with a message naming the file and saying to
281
+ retry (the command exits `2` and writes no record).
282
+
283
+ ## What `canon init` keeps out of git
284
+
285
+ A fresh `canon init` writes these lines to `.gitignore` (each under a
286
+ comment, only the missing ones):
287
+
288
+ - `.canon/hot.db*` — the sqlite hot tier and its WAL/SHM sidecars.
289
+ - `.canon/audit/` — the query/export/purge access log.
290
+ - `.canon/ledger/kind=scenario/` — the scenario index; every reader
291
+ rebuilds it.
292
+ - `.canon/scenario-index.lock` — the lock serializing index rebuilds.
293
+ - `.canon/artifact-store.lock` — the lock serializing artifact store
294
+ writes.
295
+
296
+ A rerun on a repo that already has `canon.yaml` only refreshes
297
+ `AGENTS.md`, so an existing repo's `.gitignore` is unchanged; add the
298
+ lines by hand to adopt them.