@journeykit/canon 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,119 @@
1
+ # canon-policy
2
+
3
+ Canon uses ONE expression language — CEL — for every conditional knob a
4
+ spec needs: policy-routing predicates, tier-aging thresholds, verdict-
5
+ mapping guards, retrieval filters, and a template's `applies_when:`
6
+ section. Wherever a `policy.yaml` predicate or an `applies_when:` string
7
+ appears, it is a CEL expression evaluated against the target record.
8
+
9
+ ## The closed CEL profile
10
+
11
+ Every expression is evaluated against exactly one variable, `record` (the
12
+ target kind's own fields — always `record.<field>`, never the raw
13
+ envelope), plus one registered function:
14
+
15
+ - `record.<field>` — any field the target kind declares. Nested objects
16
+ (e.g. `record.actor.agent_id`) resolve to a bounded depth; deeply-open
17
+ fields (an unconstrained `detail` blob) are readable but not field-
18
+ checked at write time.
19
+ - `age_days(ts) -> int` — whole days between a timestamp field and a
20
+ caller-supplied "now" (never the wall clock read inside the function, so
21
+ the same expression against the same facts and the same `now` always
22
+ evaluates identically).
23
+ - `has(record.<field>)` — CEL's built-in field-presence macro. Validates
24
+ like a normal field read.
25
+ - CEL's operator grammar (`==`, `!=`, `&&`, `||`, `>`, `<`, `in`, the
26
+ ternary `? :`) and macros (`map`, `filter`, `all`, `exists`,
27
+ `exists_one`) are always available.
28
+
29
+ Nothing else is. There is NO `size()`, `matches()`, `contains()`,
30
+ `string()`, or any other CEL standard-library method — the profile is a
31
+ closed allowlist of exactly the one function above. A
32
+ `record.title.contains('x')`-shaped expression is rejected at WRITE time
33
+ with an `UnknownFunction` diagnostic, not deferred to a runtime failure.
34
+ Express the same check with `record.<field> == <literal>` / `in` / `has`
35
+ instead.
36
+
37
+ ## Discovering what's bindable for a kind
38
+
39
+ Run `canon context --json` and read its `cel` section: for each record
40
+ kind it lists the bindable `record.<field>` names + types and the
41
+ callable function allowlist a `policy.yaml` predicate may reference. This
42
+ is the source of truth for "what can a CEL predicate for this kind
43
+ reference?" — it's derived from the same schema `canon format` validates
44
+ against, so there is no second, hand-maintained list to drift. See the
45
+ `canon-context` skill.
46
+
47
+ ## Writing and validating an expression
48
+
49
+ An expression is compiled BEFORE storage: both parsing AND a full walk
50
+ (checking every `record.<field>` chain and every function call) run up
51
+ front. There is no "accepted but fails on first evaluation" path — a
52
+ type-invalid expression is rejected at author time with every diagnostic,
53
+ not just the first.
54
+
55
+ Evaluation is bounded by a wall-clock deadline (default 200ms): a
56
+ pathological expression is reported as budget-exceeded rather than
57
+ blocking. `now` is always caller-supplied — one `canon gate` run scoring
58
+ many records uses the same `now` across the batch for mutually consistent
59
+ results.
60
+
61
+ ## Reading a write-time "expected …" diagnostic
62
+
63
+ Every rejection names what was expected, never a bare "invalid
64
+ expression":
65
+
66
+ - **Undeclared field** — `` `record.severty` is not a declared field of
67
+ `task` (expected one of: actor, at, evidence_note, kind, schema,
68
+ status, task_id, title) `` — check the name against `canon context`'s
69
+ field list; a typo is the usual cause.
70
+ - **Undeclared variable** — `` `foo` is not a declared variable (expected
71
+ one of: record) `` — only `record` (plus a `map`/`filter` loop
72
+ variable) is ever in scope.
73
+ - **Unknown function** — `` `contains` is not an allowlisted function
74
+ (expected one of: age_days) `` — a typo, or a CEL standard-library
75
+ method the closed profile doesn't register.
76
+ - **Arity mismatch** — `` `age_days` expects 1 argument(s), got 0 ``.
77
+ - **Type mismatch** — `` `age_days` expects argument 0 of type
78
+ `timestamp`, got `string` `` — the field passed isn't the type the
79
+ function's signature declares.
80
+
81
+ ## What never gets a CEL hook
82
+
83
+ Some knobs deliberately stay code, not configurable CEL: reward-scoring
84
+ functions, ingest/verdict-mapping transforms, and the content of an
85
+ evidence record itself. If a task looks like "let this record configure
86
+ its own evaluation logic," it is out of scope — raise it as a design
87
+ question rather than adding an expression field. Conditional logic about
88
+ what a record *means* lives in `policy.yaml`/`applies_when:`, evaluated
89
+ against the record's fields, never inside the record.
90
+
91
+ ## Effect-aware risk tiers
92
+
93
+ The optional `risk_tiers` section is not CEL. Absent means no risk-approval
94
+ check. A present section must be a mapping of tier names to:
95
+
96
+ ```yaml
97
+ risk_tiers:
98
+ high:
99
+ rank: 3
100
+ paths: ["src/auth/**"]
101
+ effects: ["secret-access"]
102
+ min_human_approvals: 1
103
+ ```
104
+
105
+ Malformed sections or entries are retained as a policy diagnostic and make
106
+ `canon gate check` fail closed with `uncovered-cell risk_tiers — risk_tiers
107
+ policy-invalid: ...`; they are never treated as an empty map. The gate
108
+ selects the highest-ranked tier against the current, faithful
109
+ `EvidenceRecord` generation. A current `evidence_sha` and an explicit
110
+ `surface_ref`/`effect:<kebab-slug>` binding are required for a tier that
111
+ requires approval. Surface refs are validated syntax and attestation input,
112
+ not proof that the caller's path is authoritative; configured rules cannot
113
+ be bypassed by declaring a safe ref.
114
+
115
+ Approval text is also only an attestation unless identity is verified.
116
+ `canon evidence add` marks an approval verified only when `--approval-by`
117
+ equals non-empty `CANON_ACTOR` and `--approval-role human` is supplied.
118
+ The persisted verification bit is what later gates count; arbitrary caller
119
+ strings, empty identities, and agent roles do not count.
@@ -0,0 +1,345 @@
1
+ # canon-report-dashboard
2
+
3
+ canon's single status surface: `canon report` renders TEN panels to
4
+ markdown, `canon report --snapshot` exports the same ten marts to
5
+ Parquet, and `canon dashboard` serves a small zero-network web app that
6
+ renders SEVEN of them from such a snapshot. Neither surface computes an
7
+ aggregate itself — both read canon's precomputed
8
+ `crates/canon-store/sql/views.sql` marts, and each run reads the corpus
9
+ ONCE (`crates/canon-report/src/query.rs`'s `PIN_CORPUS_SQL`), so no two
10
+ panels within one report, or two files within one snapshot, can
11
+ disagree. Across surfaces the input is what differs: a dashboard renders
12
+ the snapshot it was given, so it matches a report only insofar as the
13
+ two were generated from the same corpus.
14
+
15
+ They can still disagree about what a number MEANS, and that is the
16
+ failure this skill is written against: a friendly label or a paraphrase
17
+ asserting something its query never computed. Every claim below is kept
18
+ verbatim with the prose `canon report` actually renders
19
+ (`canon_report::render`'s `pub const *_PANEL` literals and its inline
20
+ one-liners), and `packages/dashboard/test/panel-copy.test.ts` pins this
21
+ file to those literals. Copy a claim; do not paraphrase it.
22
+
23
+ ## Generated, never hand-edited
24
+
25
+ `.canon/REPORT.md` carries a leading `<!-- GENERATED by \`canon report\` …
26
+ DO NOT EDIT. -->` comment. If it looks stale or wrong, the fix is
27
+ **always** re-running `canon report` (or fixing the underlying
28
+ ledger/policy it renders) — never hand-editing the file. The report
29
+ header embeds only input-derived digests (corpus hash, policy hash,
30
+ ledger-head hash) and **no timestamp, no `source_git_sha`**, so a
31
+ committed report is reproducible from named inputs and `canon report
32
+ --check` can byte-diff it deterministically. `source_git_sha` belongs
33
+ only in `--snapshot`'s `manifest.json` (below), which is never
34
+ drift-checked.
35
+
36
+ ## `canon report [--repo <dir>]`
37
+
38
+ Renders the current ten-panel report to `<repo>/.canon/REPORT.md`.
39
+ `--repo` resolves through the nearest-`canon.yaml`-ancestor walk every
40
+ subcommand uses — omit it (or pass `.`) to run from any subdirectory.
41
+
42
+ ```bash
43
+ canon report # writes .canon/REPORT.md
44
+ canon report --repo /path/to/repo
45
+ ```
46
+
47
+ Between the digest header and the first panel, the report may also
48
+ render a `## Kinds not read directly` section. It is config-derived
49
+ (`canon.yaml`'s static `routing`/`tiers` tables), not a live backend
50
+ probe, and an empty set renders NO section at all — so a repo routing
51
+ nothing to a not-directly-readable backend renders exactly as before
52
+ the section existed.
53
+
54
+ ## `canon report --check` — the CI drift gate
55
+
56
+ Regenerates the report in memory and byte-compares it against the
57
+ committed `.canon/REPORT.md`:
58
+
59
+ - `MISSING` (exit 1) — no report has ever been written; run `canon report` first.
60
+ - no drift (exit 0) — the committed file is byte-identical to what the
61
+ current inputs would render now.
62
+ - `DRIFT` (exit 1) — the committed file disagrees with current inputs
63
+ (hand-edited, or the corpus/policy/ledger moved and nobody re-ran
64
+ `canon report`).
65
+
66
+ ```bash
67
+ canon report --check # wire into CI exactly like canon format --check / canon gate check
68
+ ```
69
+
70
+ Wire it as a CI step or pre-push hook the same way `canon gate
71
+ install-hooks` wires `canon gate check`; run `canon report --check`
72
+ directly wherever CI already runs those.
73
+
74
+ Exports all ten views to `<dir>/<table>.parquet` plus a
75
+ `<dir>/manifest.json`. One explicit `COPY` per view — the exported
76
+ columns are each view's own `SELECT` list, wider than the curated column
77
+ subset the markdown tables render. `file` is always
78
+ `<table>.parquet`, and the table order is the report's own panel order,
79
+ with `mart_scope_status`, `mart_subjects`, `mart_review_rounds`,
80
+ `mart_review_totals` and `mart_run_observability` APPENDED last so an
81
+ existing consumer's order never moves:
82
+
83
+ ```json
84
+ {
85
+ "generated_at": "...",
86
+ "source_git_sha": "...",
87
+ "source_digest": "...",
88
+ "tables": [
89
+ { "table": "mart_trust_matrix", "file": "mart_trust_matrix.parquet" },
90
+ { "table": "mart_session_costs", "file": "mart_session_costs.parquet" },
91
+ { "table": "mart_role_memory", "file": "mart_role_memory.parquet" },
92
+ { "table": "mart_flywheel_funnel", "file": "mart_flywheel_funnel.parquet" },
93
+ { "table": "mart_review_burndown", "file": "mart_review_burndown.parquet" },
94
+ { "table": "mart_scope_status", "file": "mart_scope_status.parquet" },
95
+ { "table": "mart_subjects", "file": "mart_subjects.parquet" },
96
+ { "table": "mart_review_rounds", "file": "mart_review_rounds.parquet" },
97
+ { "table": "mart_review_totals", "file": "mart_review_totals.parquet" },
98
+ { "table": "mart_run_observability", "file": "mart_run_observability.parquet" }
99
+ ]
100
+ }
101
+ ```
102
+
103
+ This is where `source_git_sha`/`generated_at` live (never in the
104
+ drift-checked markdown header) — a snapshot is output-inclusive
105
+ provenance. `source_digest` is one 12-hex fingerprint over the same
106
+ corpus/policy/ledger digests the report header renders, so a snapshot's
107
+ provenance is comparable to a report's without re-deriving anything.
108
+
109
+ ```bash
110
+ canon report --snapshot /tmp/canon-snap
111
+ ```
112
+
113
+ ## `canon dashboard [--repo <dir>] [--snapshot <dir>] [--port <n>]`
114
+
115
+ Serves the dashboard web app locally, pointed at a real snapshot:
116
+
117
+ ```bash
118
+ # build the app once
119
+ bun install && bun run --cwd packages/dashboard build
120
+
121
+ canon dashboard # regenerates a fresh snapshot every run, serves it
122
+ canon dashboard --snapshot /tmp/canon-snap --port 8080
123
+ ```
124
+
125
+ - `--snapshot` omitted: `canon dashboard` calls `canon report --snapshot`
126
+ itself every run, into the conventional `.canon/dashboard-snapshot`
127
+ scratch directory — always fresh.
128
+ - `--snapshot <dir>` given: served AS-IS when `<dir>` already has a
129
+ `manifest.json` (a separately generated snapshot, or a downloaded CI
130
+ artifact) — never silently regenerated over it. If it has no
131
+ `manifest.json`, one is generated there once.
132
+ - Prints the URL to open, then serves until Ctrl-C. If the app is not
133
+ built, `canon dashboard` fails with the exact build command to run.
134
+
135
+ The dashboard renders SEVEN panels — trust matrix, session costs, role
136
+ memory, flywheel funnel, review burn-down, review rounds, review totals.
137
+ **Scope status
138
+ and subjects are exported to the snapshot but have no dashboard panel**;
139
+ for those two, read `.canon/REPORT.md` or query the Parquet directly.
140
+ Do not read a missing dashboard panel as an empty mart.
141
+
142
+ The dashboard's freshness banner shows the snapshot's
143
+ `generated_at`/`source_git_sha`/`source_digest` with a "scoped to this
144
+ snapshot, not necessarily the live checkout" note — treat "matches
145
+ `canon report`" as "matches the inputs that produced THIS snapshot".
146
+
147
+ ## Reading the ten panels
148
+
149
+ In `canon report`'s own render order. Each panel names the view that
150
+ computes it; nothing here is derived a second time.
151
+
152
+ - **Trust matrix** (`mart_trust_matrix`) — Change/task coverage × green × who.
153
+ `covered` means at least one `evidence_record` exists for this `task_id`,
154
+ NOT that policy's REQUIRED evidence is present — that is `canon gate
155
+ check`'s question, and this view deliberately does not answer it. `green`
156
+ is the plain `verdict = 'faithful'` proxy over the latest attestation, not
157
+ `canon-gate`'s multi-rung trust-ladder classifier, which the view refuses
158
+ to re-implement in SQL. `evidence_count` counts ATTESTATIONS, superseded
159
+ versions included, because a re-attestation can only arrive as a new
160
+ version; `green` and `who` are read off one whole winner row, so they can
161
+ never come from two different attestations.
162
+
163
+ - **Session costs** (`mart_session_costs`) — token/cost per
164
+ `(session, client, role, workspace_label)`. Every `run`/`session`/
165
+ `token_usage` record is folded to its latest version first, so
166
+ a re-ingested corrected cost REPLACES the superseded figure instead of being summed with it.
167
+ `workspace_label` is NOT a repo identity: it is the last non-empty path
168
+ segment of the `token_usage` event's workspace key and nothing more, so it
169
+ SPLITS one repo whose worktrees sit in differently-named directories and
170
+ MERGES two different repos sharing a directory name. Two stronger fields
171
+ exist and this mart reads neither — the event's own `workspace_key`, and
172
+ `Session.project_key`, which `canon-cli` stamps to the main worktree's key.
173
+
174
+ - **Role memory** (`mart_role_memory`) — per-`(role, regime_key)` strategy
175
+ counts. `hit_rate` is NOT a retrieval hit rate: it is the fraction of that
176
+ namespace's distilled strategies carrying no `demotion` flag, i.e.
177
+ exactly `active_count / strategy_count`.
178
+ `avg_source_trajectories` is the mean number of source trajectories a
179
+ strategy was distilled from — an explicitly-named stand-in, because
180
+ canon records no per-strategy reward or effect metric.
181
+ There is no effect column, and this panel measures no effect.
182
+
183
+ - **Flywheel funnel** (`mart_flywheel_funnel`) — verdicts → distilled →
184
+ retrieved → applied, per role, where
185
+ the last three stages count STRATEGIES, so the funnel narrows by construction.
186
+ `applied` is the retrieved set narrowed to strategies whose recipient run
187
+ has something recorded about how it ended, split into
188
+ `applied_attributed` (a resolved trajectory of the same role stamped with
189
+ that run id) and `applied_proxy` (only the run's own terminal status).
190
+ Both rules are CO-OCCURRENCE inside one run, never causation:
191
+ canon stores no edge from a strategy to a verdict,
192
+ so neither column can separate guidance that was followed from guidance
193
+ that was ignored. A large drop between two stages is the actionable
194
+ signal; `retrieved 0` means no run's recorded guidance names a strategy
195
+ that exists now.
196
+
197
+ - **Review burn-down** (`mart_review_burndown`) — a per-day trend over raw
198
+ `Divergence.status` events, with the `evidence_*` columns breaking the same
199
+ window down by `EvidenceRecord.verdict`. `divergence_open_running_total` is
200
+ a running `opened - resolved` event count, NOT the number open now —
201
+ the two differ whenever one `resolved` record closes several findings on a
202
+ scenario. For current state per scenario, run `canon divergence status`.
203
+
204
+ - **Scope status** (`mart_scope_status`) —
205
+ Every specified scenario × evidence-verified × plan-carried × spec-covered (`mart_scope_status`). Driven by the spec corpus, so a scenario no plan task declares still appears — with a NULL `task_id`, which is the worklist entry.
206
+ Grain is one row per authored `(project_id, scenario_id)`; the driving
207
+ side carries the project, so two spec roots authoring one scenario id
208
+ are two rows by construction. `evidence_covered`/`green` count either
209
+ side — the task the scenario is declared by, or scenario-keyed evidence
210
+ authored directly against it (`scenario_verdict`). `spec_covered IS
211
+ NULL` means NO coverage overlay row exists for that scenario — a
212
+ different state from an overlay row that exists and says
213
+ `spec_covered = false`. Read-only reporting; never a `canon gate` input.
214
+
215
+ - **Subjects** (`mart_subjects`) —
216
+ Per-domain subject rollup: status × scenario coverage.
217
+ One row per `subject` record, folded to its latest version, so a subject
218
+ that walked proposed → shipped is still one row. `scenario_count` is how
219
+ many scenarios the subject owns — those whose latest synced `Scenario`
220
+ generation carries `@subject:<id>` (run `canon inventory sync` after
221
+ tagging); `covered_scenarios` is how many of those carry a latest
222
+ NON-divergent evidence verdict (`faithful` or `not_applicable`), the same
223
+ join and last-wins fold the `verifying -> shipped` gate uses. Read-only
224
+ reporting; never a `canon gate` input. See `canon-subject`.
225
+
226
+ - **Review rounds** (`mart_review_rounds`) — one row per `(change_id, round)`
227
+ over `Finding` records (`canon finding add`, then `canon finding close` to
228
+ move a finding's disposition), folded to the latest version
229
+ of each `{change_id}__{round}__{seq}` finding first, so a finding raised
230
+ `open` and later closed is counted once and in one disposition bucket.
231
+ A round that found
232
+ nothing wrote no `Finding` and so has no row: the table
233
+ counts the rounds that FOUND something, never the rounds RUN
234
+ (s42's round 12 returned MERGEABLE with zero findings and is absent),
235
+ and canon has no record kind that marks a round as run.
236
+ `fix_of_fix` counts
237
+ findings whose SOURCED `introduced_by` equals the `resolution_sha` of a
238
+ finding earlier in the SAME change, ordered strictly by `(round, seq)`.
239
+ That is
240
+ a commit-id equality join over two recorded fields and nothing more:
241
+ it reads no git history, computes no blame,
242
+ and establishes no causal claim.
243
+ `introduced_by_unsourced` is the UNKNOWN bucket —
244
+ a finding with no sourced `introduced_by` is counted there and NEVER as
245
+ not-a-fix-of-fix.
246
+ What the count MEANS is one sentence, and every surface that reports it
247
+ states this sentence and no paraphrase of it:
248
+ `fix_of_fix` bounds NOTHING — not from below, not from above: it
249
+ UNDER-counts, because an unsourced finding is never counted and a fix in
250
+ one change that breaks something first found while reviewing a DIFFERENT
251
+ change is not counted at all; it OVER-counts, because a `resolution_sha`
252
+ commit may carry work BEYOND the fix and every finding recording that
253
+ commit is counted regardless; and for any individual match the data
254
+ cannot say whether the fix or the other work in that commit introduced
255
+ the defect.
256
+ Both directions are live in canon's own rows —
257
+ `f438c610` closed s42's round 8 AND shipped s42's whole feature, so
258
+ round 9's six matches cannot be attributed either way.
259
+ `reviewed_sha` is the greatest value any of
260
+ the round's findings recorded and reads `—` when none did; a round that
261
+ reviewed an uncommitted working tree is the usual reason, but the view
262
+ cannot distinguish that from findings that left the field unset.
263
+
264
+ - **Review totals** (`mart_review_totals`) — one row per `change_id`,
265
+ and every column a `sum()`, `count(*)` or `max()` over the
266
+ `mart_review_rounds` rows above. This is the panel a release note is
267
+ copied from: the per-round table left "N findings across R rounds" as
268
+ hand arithmetic, and hand arithmetic over a generated table is what
269
+ put four wrong figures into this release line's published notes, so
270
+ this panel exists so a release note is a COPY rather than a
271
+ computation. The view's only `FROM` is `mart_review_rounds`, and
272
+ every panel in one report — or one `--snapshot` export — is computed
273
+ in a single DuckDB process over one materialized read of the corpus,
274
+ so the total and the rows it totals are one computation over one
275
+ input and cannot disagree: a record written to the ledger mid-run
276
+ reaches BOTH tables or neither, never one and not the other — though the
277
+ digest header beside these panels is a SEPARATE read outside that pin,
278
+ taken before it in a report and after it in a `--snapshot`, so header
279
+ and panels can still describe the corpus a moment apart. (Both halves
280
+ matter. The SQL
281
+ argument alone held while `canon report` ran one `duckdb` process per
282
+ mart against a live ledger, and a finding written between two of them
283
+ reached one panel and not the other — s43 round 6's blocker.
284
+ `crates/canon-report/src/query.rs`'s `PIN_CORPUS_SQL` is the other
285
+ half.)
286
+ `rounds_recorded` is `count(*)` over those rows, and that view holds a
287
+ row only for a round that RECORDED a finding, so
288
+ this column counts the rounds that FOUND something, never the rounds RUN;
289
+ canon has no record kind for a review round, so the rounds-RUN number
290
+ is not derivable from this corpus at all. `highest_round` is
291
+ `max(round)`, and it witnesses nothing about rounds: above
292
+ `rounds_recorded` it says only that some round number below it has no
293
+ row, and canon cannot say why — `round` is author-supplied and
294
+ nothing requires a change's rounds to be numbered from 1 or without
295
+ gaps, so a change whose only finding is labelled round 7 shows the
296
+ same gap six silent rounds would. Neither the gap nor its absence
297
+ evidences a round that RAN. Neither column is the rounds-RUN count —
298
+ `highest_round` is a round NUMBER, not a count, and `round` is
299
+ author-supplied. The splits sum the per-round `FILTER` counts, so
300
+ `severity_* = findings`, `disposition_* = findings` and
301
+ `introduced_by_sourced + introduced_by_unsourced = findings` all hold
302
+ at this grain too. `fix_of_fix` is `sum(fix_of_fix)` over a column
303
+ nothing records, and it means exactly what it means one panel up:
304
+ `fix_of_fix` bounds NOTHING — not from below, not from above: it
305
+ UNDER-counts, because an unsourced finding is never counted and a fix in
306
+ one change that breaks something first found while reviewing a DIFFERENT
307
+ change is not counted at all; it OVER-counts, because a `resolution_sha`
308
+ commit may carry work BEYOND the fix and every finding recording that
309
+ commit is counted regardless; and for any individual match the data
310
+ cannot say whether the fix or the other work in that commit introduced
311
+ the defect.
312
+ There is no defect rate, no quality score and no comparison between changes here:
313
+ a row is one change's review history, and `findings` moves with how
314
+ much reviewing happened rather than with the change.
315
+
316
+ Two marts in `views.sql` are NOT report panels and are not exported by
317
+ `--snapshot`: `mart_records_by_kind` and `mart_session_run_handoff`.
318
+ Read them with DuckDB directly against `views.sql`.
319
+
320
+ - **Run observability** (`mart_run_observability`) — one row per folded
321
+ `run_id`, appended as the tenth report/snapshot table after the established
322
+ nine tables. It reports lifecycle identifiers/status/timestamps, latency
323
+ only when both timestamps exist, event and label counts, child-run count,
324
+ guidance count, lineage provider/model, and token/cost source totals. These
325
+ are bounded observations and proxies: event counts do not infer tool calls,
326
+ retries, or human interventions, and no field claims guidance caused an
327
+ outcome. `directive_event_count` and `guidance_count` are counts only;
328
+ directive and guidance text is never emitted. `*_unknown` columns explicitly
329
+ expose missing joins/coverage, including missing session/project/task/lineage,
330
+ end timestamps, event times, costs, and guidance. Workspace absolute paths
331
+ and verbatim directives/guidance are intentionally omitted. The adapter
332
+ contract is strict read-only validation; this panel does not execute
333
+ adapters or enforce provider sandbox capabilities. Privacy-safe omission of
334
+ paths and directive/guidance text is part of the current semantics.
335
+
336
+ ## What this skill does NOT cover
337
+
338
+ - The underlying view layer that computes the marts — see `canon-storage`.
339
+ - `canon gate`'s evidence-gated checkbox flip / trust ladder — see
340
+ `canon-gate`. The trust-matrix and scope-status panels READ the same
341
+ corpus `canon gate check` validates but never gate or flip anything.
342
+ - Strategy distillation/promotion — see `canon-learn`; the role-memory and
343
+ flywheel panels are read-only views over that store.
344
+ - Authoring subjects and their status lifecycle — see `canon-subject`; the
345
+ subjects panel is the read-only rollup over those records.
@@ -0,0 +1,123 @@
1
+ # canon-retrieve
2
+
3
+ `canon retrieve` surfaces a role's promoted strategy memory (see
4
+ `canon-learn`) at task-dispatch time. It is a fail-soft, advisory-only
5
+ read: it NEVER blocks a dispatch and NEVER gates reproducibility.
6
+
7
+ ## `canon retrieve --role <r> --regime <k> [--k <n>] [--repo <dir>] [--json]`
8
+
9
+ Prints the top-`k` retrievable strategies stored for `regime_key = <k>`
10
+ (active strategies plus backward-compatible legacy rows without a lifecycle;
11
+ guardrails are just strategies distilled from a failure-polarity verdict),
12
+ newest-distilled first, capped at `--k` (default 5). Demoted,
13
+ quarantined, rejected, and rolled-back rows are not retrievable.
14
+
15
+ ```bash
16
+ canon retrieve --role dev --regime dev/canon/join-spine/9c93d024b1a2
17
+ canon retrieve --role dev --regime dev/canon/join-spine/9c93d024b1a2 --k 3 --json
18
+ ```
19
+
20
+ - `--regime` is the FULL `regime_key` string
21
+ (`<role>/<repo>/<area>/<hash>`) — the same serialization the write side
22
+ produces.
23
+ - Assembling `--regime` in a script or hook? Use `canon regime-key
24
+ --role <r> --repo <r> --area <a> --hash <h>`: it runs canon's ONE
25
+ canonical `regime_key` serializer and prints the validated key, so a
26
+ caller routes through the exact normalizer the write path uses instead
27
+ of a drifting shell slug. Exits `2` with empty stdout on a malformed
28
+ result (empty segment / bad `<hash>`) — a clean fail-soft branch for
29
+ `|| exit 0` callers.
30
+ - `--role` MUST equal `--regime`'s leading segment — a mismatch is a
31
+ clean usage error, exit `2` (mirroring `canon gate check`'s
32
+ 0-clean/1-red/2-usage convention), never a panic or a silently-ignored
33
+ mismatch.
34
+ - `--repo` resolves through the same nearest-`canon.yaml`-ancestor walk
35
+ `canon context`/`canon format`/`canon gate` use. The resolved
36
+ `canon.yaml` `learn:` section names the store root (`.canon/learn` by
37
+ default); a repo with no `canon.yaml` still works, defaulting cleanly.
38
+
39
+ **FAIL-SOFT, always exit `0`** once `--role`/`--regime` agree: a store
40
+ outage, an empty/nonexistent store, or a malformed on-disk row all
41
+ degrade to an empty guidance list — never a nonzero exit or an error a
42
+ caller must handle.
43
+
44
+ ## The pre-dispatch hook (`scripts/canon-retrieve-pre-dispatch.sh`)
45
+
46
+ A generic, fail-soft, advisory-only shell script that fires at a
47
+ PreToolUse-equivalent point for a `task`-shaped dispatch (Claude Code's
48
+ `Task` tool / Codex's equivalent). It reads the tool's `subagent_type`
49
+ as `--role`, assembles `--regime` by routing its repo/area segments
50
+ through `canon regime-key` (so it never re-derives the join key with a
51
+ drifting shell slug), calls `canon retrieve --json`, and emits the
52
+ result as a `PreToolUse` `hookSpecificOutput.additionalContext` JSON
53
+ envelope on stdout — Claude Code's convention for surfacing advisory
54
+ context without a permission decision. It NEVER blocks: a missing
55
+ `canon`/`jq`, a non-`Task` tool call, or empty guidance are all silent
56
+ no-ops, exit `0`.
57
+
58
+ Segment sources and override env vars
59
+ (`CANON_RETRIEVE_ROLE`/`CANON_RETRIEVE_AREA`/`CANON_RETRIEVE_HASH`/
60
+ `CANON_RETRIEVE_REGIME`) are documented in the script's own header.
61
+
62
+ ### Wiring it
63
+
64
+ Place the script wherever your repo keeps materialized hook scripts
65
+ (e.g. `.claude/hooks/canon-retrieve-pre-dispatch.sh`), then register the
66
+ hook-seam entry with `canon gate install-hooks`:
67
+
68
+ ```bash
69
+ canon gate install-hooks --repo . --event PreToolUse \
70
+ --matcher Task --command "sh .claude/hooks/canon-retrieve-pre-dispatch.sh" \
71
+ --timeout 15
72
+ ```
73
+
74
+ This merges the standard `{matcher, hooks: [{type: "command", command,
75
+ timeout}]}` entry into BOTH `<repo>/.claude/settings.json` and
76
+ `<repo>/.codex/hooks.json`, additive and idempotent — see the
77
+ `canon-gate` skill for `install-hooks`'s full contract.
78
+
79
+ ## Replay vs. live retrieval
80
+
81
+ A run manifest records `injected_guidance` — a full SNAPSHOT (each entry
82
+ `{strategy_id, title, content}`) of what `canon retrieve` returned at
83
+ dispatch time, visible in `--json` output. It is never a live pointer, so
84
+ a later edit or demotion of the source strategy can never retroactively
85
+ change what a manifest already recorded.
86
+
87
+ - **New run:** dispatch calls `canon retrieve` (live), then records the
88
+ result into the run manifest's `injected_guidance`.
89
+ - **Replay:** reuse the manifest's recorded `injected_guidance` UNCHANGED
90
+ — never a fresh `canon retrieve` call. A replay MUST read the stored
91
+ guidance, so a live store change after the original run (a strategy
92
+ added, edited, demoted, or removed) can never perturb the replay.
93
+
94
+ `canon retrieve` and the pre-dispatch script work standalone today — a
95
+ caller can print retrieved guidance with zero manifest integration.
96
+
97
+ ## Lifecycle and legacy limitations
98
+
99
+ Retrieval is a read path, not an activation or evidence path. It serves only
100
+ exact role/regime matches and excludes `quarantined`, `rejected`,
101
+ `rolled_back`, and demoted strategies. Newly distilled strategies are
102
+ quarantined by default and therefore do not appear until the paired evaluation
103
+ and signed approval workflow in `canon-learn` activates them.
104
+
105
+ Rows written before lifecycle tracking (`lifecycle: null`) remain readable for
106
+ backward compatibility when they are not demoted. They have no current
107
+ quarantine, paired-evaluation, or SSH-approval provenance; retrieval MUST NOT
108
+ interpret them as proof of the signed workflow, upgrade them, or claim
109
+ measured uplift. The repository currently has no published measured uplift
110
+ result.
111
+
112
+ Role/regime retrieval is exact and advisory, not similarity search or a
113
+ quality guarantee. A live lookup is also not a replayable proof: dispatch
114
+ manifests snapshot the returned `{strategy_id, title, content}` entries, and
115
+ replay uses that snapshot rather than refreshing the store.
116
+
117
+ ## What this skill does NOT cover
118
+
119
+ - Strategy distillation/promotion/demotion — see the `canon-learn`
120
+ skill.
121
+ - `canon gate install-hooks`'s full contract — see the
122
+ `canon-gate` skill; this skill documents only the one entry it
123
+ installs for `canon retrieve`.