@journeykit/canon 0.6.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,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.
@@ -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.