@journeykit/canon 0.7.0 → 0.8.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,102 @@
1
+ # canon-inventory
2
+
3
+ `canon inventory sync` is the FIRST stage of canon's unified spec loop:
4
+ it turns a hand-authored, `canon format`-validated `.feature` corpus into
5
+ the scenario ledger-INDEX records every downstream stage joins against.
6
+
7
+ ## The unified loop
8
+
9
+ ```
10
+ author .feature corpus → canon format --check (validate)
11
+ │
12
+ ▼
13
+ canon inventory sync # THIS skill — materializes scenario index records
14
+ │
15
+ ▼
16
+ canon review add # attests a review — see canon-inventory downstream / canon-gate
17
+ canon divergence {stage,promote,resolve,defer,status} # tracks/resolves a divergence
18
+ canon gate check [--release] # trust-ladder / staleness / coverage — see canon-gate
19
+ │
20
+ ▼
21
+ canon ingest artifacts # with artifacts.native_records: true in canon.yaml —
22
+ # feeds review/divergence verdicts into the flywheel
23
+ # (see canon-artifact-ingest)
24
+ ```
25
+
26
+ Every stage after `sync` joins on the SAME `(project_id, scenario_id)`
27
+ pair `sync` materializes — one identity scheme, no second one.
28
+
29
+ ## `canon inventory sync [--repo <dir>] [--spec-root <dir>]`
30
+
31
+ ```bash
32
+ canon inventory sync # every canon.yaml specs.roots[] entry
33
+ canon inventory sync --repo ../svc # a specific repo
34
+ canon inventory sync --spec-root ./specs # ad hoc: ignore canon.yaml entirely
35
+ ```
36
+
37
+ Per configured root, in order:
38
+
39
+ 1. **Validate** — runs `canon format --check` over the root. ANY violation
40
+ (missing provenance, layout-grammar, …) ABORTS THE WHOLE ROOT: zero
41
+ records written for it, the violation(s) reported. Never a partial sync.
42
+ 2. **Scan** — walks `<root>/features/**/*.feature`, pairing each
43
+ `@<area>.<surface>.<nn>` tag with its header label as `title` and a
44
+ `source_digest` (sha256 over the raw `.feature` bytes). `@subject:<id>`
45
+ → `subject_id`, `@lane:<v>` → `lane` (first tag wins; a second, a
46
+ malformed value, or a value outside a declared `lane` enum is dropped
47
+ with a counted, non-fatal diagnostic). Any other `@name:value`
48
+ namespace is counted as a diagnostic so a dropped classification is
49
+ visible; plain tags (`@p2`) are ignored. The index derives from the
50
+ `.feature` corpus alone. See `canon-authoring` for the organizing rule.
51
+ 3. **Materialize** — upserts ONE scenario index record per
52
+ `(project_id, scenario_id)` via the append-only git-tier write.
53
+ Logically idempotent: an unchanged `source_digest` is a no-op; a
54
+ changed `.feature` file appends exactly one new record. A `.feature`
55
+ edit re-materializes every scenario in that file (file granularity).
56
+ 4. **Duplicate guard** — a `scenario_id` scanned more than once WITHIN
57
+ one root's corpus aborts that root (0 writes), reported as a sync
58
+ error. Two DIFFERENT roots sharing a `scenario_id` stay distinct —
59
+ `project_id` isolates them.
60
+
61
+ The scenario index shape is deliberately general: `title` +
62
+ `source_digest`, nothing else. It carries no coverage field — coverage
63
+ stays `canon gate check`'s own authority (see `canon-gate`).
64
+
65
+ ## `specs.roots[]` config (`canon.yaml`)
66
+
67
+ ```yaml
68
+ specs:
69
+ roots:
70
+ - id: app-a # STABLE LITERAL — never the checkout directory name
71
+ root: apps/a/specs
72
+ - id: app-b
73
+ root: apps/b/specs
74
+ ```
75
+
76
+ - **Absent `specs:` key** → the single default root `{id: root, root: specs}`.
77
+ - **Present `specs:` with an empty/missing `roots[]`** → fails LOUD; a
78
+ present-but-incomplete config never silently resolves to zero roots.
79
+ - **A malformed entry** (missing `id`, `roots` not a list, `id` not a
80
+ valid project id) → fails LOUD, never a silent fallback to the default.
81
+ - `--spec-root <dir>` bypasses `specs.roots[]` entirely and syncs
82
+ exactly that one directory under the same stable `root` id the
83
+ absent-`specs:` default uses.
84
+
85
+ ## Sync-abort causes
86
+
87
+ - **A `canon format --check` validation violation** in the root's corpus →
88
+ whole root aborts, 0 writes, violations reported.
89
+ - **A duplicate `scenario_id`** within one root → that root aborts, 0
90
+ writes, reported as a sync error (a distinct lane from fmt violations).
91
+
92
+ ## How the index feeds the rest
93
+
94
+ - **Review** — `canon review add` attests a verdict against a synced
95
+ scenario. (See `canon-inventory` downstream commands and each command's `--help`.)
96
+ - **Divergence** — `canon divergence {stage,promote,resolve,defer,status}`
97
+ tracks/resolves divergences against synced scenarios.
98
+ - **Gate** — `canon gate check` enforces trust-ladder / staleness /
99
+ coverage over the corpus (see `canon-gate`).
100
+ - **Ingest** — `canon ingest artifacts` with `artifacts.native_records:
101
+ true` in `canon.yaml` feeds review/divergence verdicts into the
102
+ reward flywheel (see `canon-artifact-ingest`).
@@ -0,0 +1,245 @@
1
+ # canon-learn
2
+
3
+ canon's role-namespaced strategy memory turns accumulated verdict
4
+ trajectories into retrievable, promotable insights. Two tiers:
5
+
6
+ - **Trajectories (warm, local).** Raw verdict-keyed rows under
7
+ `<repo>/.canon/learn/trajectories/`, written by ingest. Rebuildable,
8
+ gitignored, never hand-committed.
9
+ - **Strategies (durable, git).** Distilled, reviewable
10
+ `<repo>/.canon/strategies/<role>/<id>.md` files — the tier a promotion
11
+ writes into and a demotion soft-flags.
12
+
13
+ ## Rebuilding strategy memory
14
+
15
+ Strategy memory is re-derived from the current trajectory set as part of
16
+ `canon ingest artifacts` (see `canon-artifact-ingest`): after it
17
+ persists a repo's verdict trajectories, it re-derives the distilled
18
+ strategies. This is NON-DESTRUCTIVE — a rebuild never drops a
19
+ hand-promoted strategy the trajectory rows don't touch. There is no
20
+ standalone `canon learn` rebuild command; the only subcommand is
21
+ `promote`.
22
+
23
+ # `canon learn promote <strategy_id> [--repo <dir>] [--evaluation <path> --approval <path>] [--signature <path>] [--dry-run]`
24
+
25
+ The production activation path is a signed, paired-evaluation workflow:
26
+
27
+ 1. Run `canon ingest artifacts`. Ingest resolves covering verdicts and rebuilds
28
+ newly distilled strategies as `lifecycle: quarantined`.
29
+ 2. Check eligibility with the configured `occurrence` or `crn` gate. This is
30
+ only a prerequisite; occurrence alone MUST NOT activate a strategy.
31
+ 3. An honest external evaluator writes a `PromotionEvaluation` JSON bundle.
32
+ It MUST bind the candidate's id, version, quarantined lifecycle, strategy
33
+ digest, source trajectory ids, baseline and candidate result digests, and
34
+ non-empty corpus/evaluator/context/policy/model/tool provenance.
35
+ 4. Require paired evidence: baseline and candidate digest arrays MUST be
36
+ non-empty, equal-length, disjoint, and duplicate-free. The evaluation's
37
+ finite `paired_metrics` MUST include `pairs`, `uplift`, and `regressions`;
38
+ `pairs` MUST equal the array length, `uplift` MUST be strictly positive,
39
+ `regressions` MUST be zero, `passed` MUST match those derived checks, and
40
+ `decision` MUST be `pass`, `promote`, or `approved`. Missing or unknown
41
+ metrics are rejected; the repository currently publishes **no measured
42
+ uplift result**, so this command is not evidence that retrieval improves
43
+ agent outcomes.
44
+ 5. Produce the unsigned approval payload:
45
+
46
+ ```bash
47
+ canon learn approve <strategy_id> --evaluation evaluation.json \
48
+ --principal alice --repo .
49
+ # `request` is an exact alias:
50
+ canon learn request <strategy_id> --evaluation evaluation.json \
51
+ --principal alice --repo .
52
+ ```
53
+
54
+ The JSON output contains the evaluation digest, RFC3339 `approved_at`,
55
+ subject `strategy:<strategy_id>`, namespace
56
+ `canon-learning-approval-v1`, and `payload_hex`. `approve`/`request` never
57
+ read a private key and never set `verified`.
58
+ 6. Decode `payload_hex` to the exact canonical JSON bytes. The signed object
59
+ is:
60
+
61
+ ```json
62
+ {
63
+ "namespace": "canon-learning-approval-v1",
64
+ "subject": "strategy:<strategy_id>",
65
+ "project": null,
66
+ "artifact_sha": "<evaluation_digest>",
67
+ "run_id": null,
68
+ "surface": [],
69
+ "effects": [],
70
+ "actor": "<principal>",
71
+ "timestamp": "<RFC3339 nanosecond timestamp>"
72
+ }
73
+ ```
74
+
75
+ For rollback, the corresponding signed object uses subject
76
+ `strategy:<id>:rollback:<sha256(reason)>`, `artifact_sha` equal to that
77
+ reason digest, and the rollback actor/timestamp. Sign the decoded bytes
78
+ externally with an SSH key using the `canon-learning-approval-v1`
79
+ namespace (for example, `ssh-keygen -Y sign -n canon-learning-approval-v1
80
+ -f <private-key>`). Put the armored detached signature in
81
+ `PromotionApproval.signature` and set `signer_key` to the same principal.
82
+ The approval JSON has exactly these fields: `schema_version`,
83
+ `candidate_strategy_id`, `evaluation_digest`, `approver_identity`,
84
+ `approver_role` (`human`), `verified` (non-authoritative display data),
85
+ `approved_at`, optional `signature`, optional `signer_key`, and
86
+ `integrity_digest`.
87
+ 7. Configure the verifier in the repository policy:
88
+
89
+ ```yaml
90
+ approval:
91
+ allowed_signers: .canon/allowed_signers
92
+ ```
93
+
94
+ The path is policy-pinned (relative paths resolve from the repository).
95
+ The verifier runs `ssh-keygen -Y verify` against that file; an absent,
96
+ unreadable, or non-matching signer fails closed. A detached signature is
97
+ authentication of the configured principal, not proof that the principal
98
+ is a human or is otherwise authorized outside this policy.
99
+ 8. Activate only with both evidence files:
100
+
101
+ ```bash
102
+ canon learn promote <strategy_id> --repo . \
103
+ --evaluation evaluation.json --approval approval.json
104
+ # Or attach an armored signature file and recompute the approval digest:
105
+ canon learn promote <strategy_id> --repo . \
106
+ --evaluation evaluation.json --approval approval.json \
107
+ --signature approval.sig
108
+ ```
109
+
110
+ `--evaluation` and `--approval` MUST be supplied together. A quarantined
111
+ candidate without valid paired evaluation, policy-pinned SSH verification,
112
+ and human approval remains quarantined. `--dry-run` previews the render but
113
+ does not activate or write.
114
+
115
+ The command resolves the repo via the nearest-`canon.yaml`-ancestor walk,
116
+ evaluates the role gate before any write, and runs advisory lints
117
+ (content-length ceiling and literal-absolute-path rejection). There is no
118
+ `--force`. Promotion re-renders the whole git-tier file with YAML front matter
119
+ (`status`, `regime_key`, `role`, `title`, `source_trajectory_ids`,
120
+ `recorded_at`) plus evaluation and approval digests; a re-promote does not
121
+ preserve manual edits.
122
+
123
+ An unknown `strategy_id` fails nonzero. A candidate's lifecycle defaults to
124
+ `quarantined` when newly distilled and quarantine is excluded from retrieval.
125
+ The old public writer is retained only for already-`active` items; it is not a
126
+ way to activate new or legacy candidates.
127
+
128
+ ### The eligibility gates: `crn` vs `occurrence`
129
+
130
+ A role's eligibility gate is a `canon.yaml` `learn:`-section choice:
131
+
132
+ ```yaml
133
+ # canon.yaml
134
+ learn:
135
+ promotion:
136
+ dev:
137
+ mode: occurrence
138
+ n_min: 8 # optional — defaults to 5
139
+ window_days: 14 # optional — defaults to 30
140
+ sim:
141
+ mode: crn
142
+ demotion:
143
+ hard_delete: false # optional — soft-flag is the default
144
+ strategies_root: .canon/strategies # optional
145
+ ```
146
+
147
+ A role with no explicit `promotion.<role>` entry defaults to
148
+ `mode: occurrence, n_min: 5, window_days: 30`.
149
+
150
+ - **`occurrence`** — for roles whose domain does NOT support deterministic
151
+ replay. Within the trailing window, at least `n_min` resolved `Success`
152
+ trajectories for the same `regime_key` are required and no `Failure` or
153
+ `RolledBack` trajectory may be present. A contradiction resets the count;
154
+ it is never averaged away. This eligibility does not replace paired
155
+ evaluation or signed approval.
156
+ - **`crn`** — for roles that CAN run a deterministic simulator. A paired
157
+ common-random-number statistical eligibility gate uses the same
158
+ `crn:config=<label>` and `crn:panel=<index>` panels across compared
159
+ configurations. Insufficient panels/noise evidence is not a pass. CRN
160
+ eligibility still does not replace the external `PromotionEvaluation` and
161
+ signature.
162
+
163
+ Both gates read resolved `verdict_record.outcome`, not raw verdict rows.
164
+ `Pending` trajectories neither corroborate nor contradict. `canon ingest
165
+ artifacts` is therefore always first:
166
+
167
+ ```bash
168
+ canon ingest artifacts
169
+ canon learn promote <id> --evaluation evaluation.json --approval approval.json
170
+ ```
171
+
172
+ ### Demotion versus authenticated rollback
173
+
174
+ A later ordinary contradictory `Failure`/`RolledBack` trajectory demotes an
175
+ already-active strategy when a non-dry-run promotion observes it. The default
176
+ is append-only soft-flagging of the existing git file with `status: demoted`
177
+ and `reason:`; `demotion.hard_delete: true` deletes that file instead. This
178
+ ordinary contradiction path is not an approval and does not create a rollback
179
+ record.
180
+
181
+ An operator-requested rollback is a distinct authenticated action:
182
+
183
+ ```bash
184
+ canon learn rollback <strategy_id> --reason "<reason>" --actor alice \
185
+ --signature-file rollback.sig --approved-at 2026-10-02T12:00:00Z \
186
+ [--contradicting-trajectory-id <trajectory_id>] --repo .
187
+ ```
188
+
189
+ `--signature-file` is an armored SSH detached signature over the
190
+ `canon-learning-approval-v1` payload bound to
191
+ `strategy:<id>:rollback:<sha256(reason)>`, actor, and `--approved-at`.
192
+ `actor` MUST be listed in policy-pinned `approval.allowed_signers`; verification
193
+ uses that file and `ssh-keygen -Y verify`. Successful rollback writes durable
194
+ provenance, sets lifecycle `rolled_back`, and records the optional contradicting
195
+ trajectory. Missing policy, signer, timestamp, or signature fails closed.
196
+
197
+ ### What retrieval can and cannot serve
198
+
199
+ Retrieval serves only exact role/regime matches and excludes demoted,
200
+ quarantined, rejected, and rolled-back strategies. Legacy rows with
201
+ `lifecycle: null` remain readable for backward compatibility, but they have no
202
+ quarantine/evaluation/signature provenance and MUST NOT be treated as proof of
203
+ the current activation workflow. Retrieval is fail-soft and advisory; it does
204
+ not upgrade legacy rows or establish measured uplift.
205
+
206
+ ## How verdicts feed scoring
207
+
208
+ Verdicts arrive from `canon ingest artifacts` (see
209
+ `canon-artifact-ingest`), persisted regime-keyed
210
+ (`<role>/<repo>/<area>/<hash>`). Each verdict scores its covering
211
+ trajectory into `Success` / `Failure` / `RolledBack` / `Pending`; the
212
+ eligibility gate reads a regime's accumulated resolved verdicts, while the
213
+ paired evaluation and signed approval decide whether a quarantined strategy
214
+ may become active. A contradicting trajectory for an already-promoted
215
+ strategy triggers ordinary demotion; an explicit rollback is separate and
216
+ authenticated.
217
+
218
+ ## Reading a demoted or rolled-back strategy
219
+
220
+ An ordinary contradicting trajectory demotes an active strategy append-only:
221
+ the default soft-flag merges `status: demoted` + `reason: <text>` into the
222
+ existing front matter and leaves the body byte-unchanged. Set
223
+ `demotion.hard_delete: true` to delete the file instead. A strategy demoted
224
+ before it reached the git tier has no file to flag — that is not an error.
225
+ `canon retrieve` skips demoted and rolled-back strategies. An authenticated
226
+ rollback is different: it records signed operator provenance and sets the
227
+ stored lifecycle to `rolled_back`.
228
+
229
+ ## The flywheel
230
+
231
+ ```
232
+ ingest → resolved trajectories → quarantined strategies
233
+ ↑ │
234
+ verdicts eligibility + paired evaluation + SSH approval
235
+ │ (activate)
236
+ ▼
237
+ active git strategies → advisory retrieve
238
+ ```
239
+
240
+ ## What this skill does NOT cover
241
+
242
+ - Retrieval at dispatch time (`canon retrieve`, the pre-dispatch hook) —
243
+ see the `canon-retrieve` skill.
244
+ - Producing the verdict trajectories promotion reads — see the
245
+ `canon-artifact-ingest` skill.
@@ -0,0 +1,143 @@
1
+ # canon-plan-import
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
6
+ imported, `canon query` cannot tell an imported row from a hand-authored
7
+ one, and neither can `canon gate`.
8
+
9
+ ## 1. Configure a `plans:` source
10
+
11
+ `canon.yaml`:
12
+
13
+ ```yaml
14
+ plans:
15
+ sources:
16
+ - dialect: openspec
17
+ root: . # resolved relative to canon.yaml's directory
18
+ ```
19
+
20
+ - An absent `plans:` section = zero sources (a clean no-op).
21
+ - A present section parses STRICTLY: a typo'd key, an unregistered
22
+ `dialect`, or a nonexistent `root` fails the command loud, naming the
23
+ offender.
24
+ - `root` is whatever the dialect's adapter scans — for `openspec`, a
25
+ repo root containing `openspec/changes/`, or that changes dir directly.
26
+
27
+ One-shot override (bypasses `canon.yaml` for a single ad hoc import;
28
+ either flag alone fails loud):
29
+
30
+ ```bash
31
+ canon ingest plans --dialect openspec --source ../other-repo
32
+ ```
33
+
34
+ ## 2. Run the import
35
+
36
+ ```bash
37
+ canon ingest plans # canon.yaml-driven, every configured source
38
+ canon ingest plans --json # machine-readable outcome
39
+ canon ingest plans --repo ../svc # a specific repo root
40
+ ```
41
+
42
+ Per source the summary reports `changes_parsed`/`tasks_parsed`,
43
+ `changes_persisted`/`tasks_persisted`, `changes_unwritten`/
44
+ `tasks_unwritten` (an unreachable tier — printed, never silently
45
+ dropped), `duplicate_change_id` (two sources producing the same
46
+ `change_id` this pass — the LATER is skipped and counted, never merged),
47
+ the adapter's `unmapped` drop counts, and `malformed` (structurally
48
+ broken constructs). A source whose content is byte-identical to its last
49
+ successful run is `skipped_unchanged` — re-importing writes zero records.
50
+
51
+ ## 3. Read the imported rows
52
+
53
+ ```bash
54
+ canon query --kind change [--json]
55
+ canon query --kind task [--json]
56
+ ```
57
+
58
+ No dialect/plugin filter exists or is needed — an imported `change`/
59
+ `task` is an ordinary core record.
60
+
61
+ ## The `openspec` dialect
62
+
63
+ Maps `openspec/changes/**` onto core kinds:
64
+
65
+ | openspec construct | Core mapping |
66
+ | --- | --- |
67
+ | change dir's basename | `change_id` (verbatim — no slug massaging) |
68
+ | `proposal.md`'s `## Why` first paragraph | `Change.summary` (absent heading → empty summary + a `proposal-missing-why` diagnostic) |
69
+ | archive location vs. checkbox tallies | `Change.status` (`archived` wins; else zero rows → `proposed`, all done → `completed`, mixed → `in_progress`) |
70
+ | each `tasks.md` checkbox row | `Task` (`task_id = <change_id>#<n>`, status verbatim, evidence_note from a ` — ✅ ` suffix or a `**DEFERRED**`/`**DROPPED**` annotation) |
71
+ | `specs/**/spec.md` `#### Scenario:` blocks | dropped, counted under `spec-delta-scenario` |
72
+ | `design.md` | dropped, counted under `design-doc` |
73
+
74
+ A missing/unreadable `proposal.md`, or a basename that isn't a valid
75
+ `change_id`, skips the WHOLE dir (counted `malformed`); siblings
76
+ unaffected. A proposal-only dir imports as `Change { status: proposed }`
77
+ with zero tasks and zero diagnostics.
78
+
79
+ ## The `superpowers` dialect
80
+
81
+ Maps one plan document (an `*.md` immediate child of the plans root),
82
+ following the superpowers `writing-plans` skill's grammar:
83
+
84
+ | `writing-plans` construct | Core mapping |
85
+ | --- | --- |
86
+ | the filename stem, slugified (lowercased, each `[^a-z0-9]+` run → one `-`, edge `-` trimmed; date prefix kept verbatim) | `change_id` |
87
+ | the `**Goal:**` line's remainder, whitespace-normalized | `Change.summary` (absent → empty summary + a `goal-missing` diagnostic) |
88
+ | checkbox tallies across every `### Task N:` section | `Change.status` (zero tasks → `proposed`, all done → `completed`, mixed → `in_progress`) |
89
+ | each `### Task N: <name>` section | `Task` (`task_id = <change_id>#<N>`, title = text after the colon; status `done` iff the section has ≥1 checkbox line and ALL are checked, else `open`) |
90
+ | steps, `**Architecture:**`/`**Tech Stack:**` prose, Global Constraints, non-task headings | never read, never a diagnostic |
91
+
92
+ Worked example — `2026-07-14-website-design.md`:
93
+
94
+ ```markdown
95
+ # Website Implementation Plan
96
+
97
+ **Goal:** Build the project website.
98
+
99
+ ### Task 1: Layout
100
+
101
+ - [x] **Step 1: scaffold the grid**
102
+ - [x] **Step 2: wire the nav**
103
+
104
+ ### Task 2: Copy
105
+
106
+ - [x] **Step 1: draft the homepage**
107
+ - [ ] **Step 2: proofread**
108
+ ```
109
+
110
+ imports as `Change { change_id: "2026-07-14-website-design", summary:
111
+ "Build the project website.", status: in_progress }`, plus
112
+ `Task { task_id: "…#1", title: "Layout", status: done }` and
113
+ `Task { task_id: "…#2", title: "Copy", status: open }`. The `**Step N:**`
114
+ bolding is NOT load-bearing — a plain `- [ ]`/`- [x]` row still counts.
115
+
116
+ An invalid task-number heading is skipped and named `malformed`; a
117
+ duplicate `Task N` heading keeps the first section and names the later one
118
+ `malformed`. A filename stem that fails to slug into a valid `change_id`
119
+ is malformed too. A markdown file with neither a `**Goal:**` line nor any
120
+ `### Task N:` heading (a stray `README.md`) is skipped with a
121
+ `not-a-plan-doc` diagnostic, not imported as garbage. Only immediate-child
122
+ `*.md` files are read, byte-lexically sorted; an absent/unreadable root
123
+ yields zero records, never an error.
124
+
125
+ ## The task_id join
126
+
127
+ An imported `Task.task_id` is byte-identical to the join key of the
128
+ openspec-task verdict events canon's artifact ingest derives from the same
129
+ checkbox tree — so `canon report`'s trust matrix joins the two sides on
130
+ `task_id` for its coverage columns, and `canon ingest plans` is that
131
+ join's bulk producer. `canon ingest plans` itself performs no join and
132
+ writes no verdict/evidence — joining plan state against evidence is a
133
+ downstream reader's job.
134
+
135
+ ## What this skill does NOT cover
136
+
137
+ - **Authoring an openspec change dir** (`proposal.md`/`tasks.md`
138
+ conventions) — see the openspec CLI's own docs; this skill covers
139
+ IMPORTING that shape, not producing it.
140
+ - **`canon gate check`/`canon gate task`** (the hand-authored one-task-at-
141
+ a-time path this complements at bulk) — see `canon-gate`.
142
+ - **Wiring an imported `Task`/`Change` into a `canon gate` decision** —
143
+ out of scope; needs its own reviewed change.
@@ -0,0 +1,131 @@
1
+ # canon-plugins
2
+
3
+ A plugin adds fields to a core record WITHOUT changing that record's
4
+ schema. It declares a NAMESPACED overlay kind (`<namespace>.<kind>`, e.g.
5
+ `porting.coverage`), writes overlay records into that namespace, and a
6
+ read-time JOIN projects the declared fields onto the core view. Core
7
+ records are NEVER rewritten by any step — the overlay write path is
8
+ append-only under a foreign namespace, and a manifest cannot alias a
9
+ core kind's on-disk path even by accident. Today overlays attach to the
10
+ `scenario` core kind only.
11
+
12
+ ## 1. Author a `plugin.yaml` manifest
13
+
14
+ `.canon/plugins/<id>/plugin.yaml`:
15
+
16
+ ```yaml
17
+ id: porting
18
+ namespace: porting
19
+ overlays:
20
+ - kind: coverage
21
+ attaches_to:
22
+ core_kind: scenario # `scenario` ONLY
23
+ join_key: [project_id, scenario_id]
24
+ fields:
25
+ - name: covered
26
+ type: bool
27
+ - name: surface_ref
28
+ type: { list: string }
29
+ ```
30
+
31
+ - `id`/`namespace`/`overlays[].kind` are REQUIRED — a missing field fails
32
+ the load outright, never defaulted.
33
+ - `namespace` and every overlay `kind` MUST match the kebab-token grammar
34
+ `[a-z0-9]+(-[a-z0-9]+)*` — else rejected with `E-PLUGIN-GRAMMAR`.
35
+ - `<namespace>.<kind>` MUST NOT collide with a core record kind name
36
+ (`E-PLUGIN-CORE-COLLISION`) and MUST be unique across installed plugins
37
+ (`E-PLUGIN-DUP-OVERLAY`).
38
+ - `attaches_to.core_kind` MUST be `scenario` — any other value fails with
39
+ `E-PLUGIN-CORE-KIND`.
40
+ - `type` is a bare scalar (`bool`/`number`/`string`), `{ enum: […] }`, or
41
+ `{ list: <type> }`.
42
+ - Two packages declaring the SAME `id`: the later (directory-sort order)
43
+ is dropped and diagnosed, never merged (`E-PLUGIN-DUP-ID`).
44
+ - An absent `.canon/plugins/` directory resolves an empty, valid snapshot —
45
+ never a panic.
46
+
47
+ ## 2. Validate + write overlay records
48
+
49
+ Drive it through the sync command (never hand-write records):
50
+
51
+ ```bash
52
+ canon plugin sync porting # every canon.yaml specs.roots[] entry
53
+ canon plugin sync porting --repo ../svc # a specific repo
54
+ canon plugin sync porting --spec-root ./specs # ad hoc: ignore canon.yaml
55
+ ```
56
+
57
+ `canon plugin sync <plugin-id>` resolves the manifest, then for each
58
+ candidate validates FIRST (envelope present + typed; every required
59
+ `join_key` field present + a string; every declared field present + type-
60
+ matching; no field outside envelope ∪ join-key ∪ declared), then writes.
61
+ The on-disk path is derived from the join-key field values in declared
62
+ order plus a body digest — a byte-identical resubmission dedupes to the
63
+ same path; a different body for the same join key appends at a new path,
64
+ never overwriting.
65
+
66
+ ## 3. Project a read
67
+
68
+ ```bash
69
+ canon query --kind scenario --plugin porting # human table, projected fields appended
70
+ canon query --kind scenario --plugin porting --json # merged record bodies
71
+ canon query --kind scenario # no --plugin: byte-identical to the core view
72
+ ```
73
+
74
+ `--plugin <id>` folds the plugin's overlay records (latest-by-
75
+ `(join_key, at)`) onto each queried core record. Fail-soft everywhere:
76
+
77
+ - No overlay record for a core key → that record projects UNMODIFIED (no
78
+ value invented).
79
+ - A malformed overlay record (fails validation against the CURRENT
80
+ manifest — e.g. the schema drifted after the record was written) →
81
+ SKIPPED + diagnosed; siblings still project.
82
+ - An absent plugin/manifest → the unmodified core view + a diagnostic,
83
+ never a process error.
84
+ - Omitting `--plugin` is byte-identical to `canon query`'s output without
85
+ plugins — it's a purely additive layer.
86
+
87
+ ## The `porting` worked example
88
+
89
+ `porting` moves the `covered`/`surface_ref` fields off the core
90
+ `scenario` record and into a plugin overlay:
91
+
92
+ 1. `.canon/plugins/porting/plugin.yaml` — the manifest above.
93
+ 2. Its overlay source reads a spec root's `inventory/**/*.yaml` (the same
94
+ inventory files `canon format --check` covers) and, for every
95
+ `(project_id, scenario_id)` `canon inventory sync` would index, emits
96
+ one candidate: `covered = true` iff that `scenario_id` appears in ANY
97
+ inventory entry's `covered_by` list; `surface_ref` = every inventory-
98
+ entry key whose `covered_by` contains it (empty when uncovered). The
99
+ candidate's envelope `at` is the source version (the max inventory-file
100
+ `at`), so an unchanged inventory re-syncs to a byte-identical body
101
+ (zero new files) and a genuine coverage edit supersedes the prior
102
+ overlay via latest-`at`-wins.
103
+ 3. `canon plugin sync porting` writes/dedupes those candidates.
104
+ 4. `canon query --kind scenario --plugin porting [--json]` projects
105
+ `covered`/`surface_ref` back onto each scenario.
106
+
107
+ `canon gate check`'s verdicts are byte-identical with and without a
108
+ `canon plugin sync porting` run — the gate reads NO plugin overlay. Wiring
109
+ an overlay field into a gate decision needs its own reviewed change
110
+ ("covered ≠ coverage").
111
+
112
+ ## Do not confuse the two `plugin.yaml` surfaces
113
+
114
+ The typed-authoring vocabulary (`.canon/vocab/<id>/plugin.yaml`: directives
115
+ + enums for task-atom authoring — see `canon-vocab`) is a
116
+ DIFFERENT directory and a DIFFERENT schema. A ledger-overlay manifest
117
+ misplaced under `.canon/vocab/<id>/` fails that loader (missing
118
+ `directives`/`enums`), not silently parsing as an authoring vocabulary.
119
+
120
+ ## What this skill does NOT cover
121
+
122
+ - **`canon inventory sync`** (the core index `porting`'s source reads) —
123
+ see `canon-inventory`.
124
+ - **`canon gate check`** — see `canon-gate`; the gate never reads a
125
+ plugin overlay.
126
+ - **`canon scenario new`/`canon feature new`** (corpus-authoring
127
+ scaffold, independent of the plugin machinery) — template generators
128
+ that write no ledger record.
129
+ - **A projection over a core kind other than `scenario`, or wiring an
130
+ overlay field into a `canon gate` decision** — out of scope; each needs
131
+ its own reviewed change.