@erclx/canon 4.10.0 → 4.11.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,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.10.0",
4
+ "version": "4.11.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -17,28 +17,49 @@ Format a `## Toolkit feedback` block from the current session, then ship it to t
17
17
 
18
18
  From the conversation so far, identify:
19
19
 
20
- - Target project name or path
20
+ - Target project name or kind, never its full path. The path names a folder on one machine and says nothing a triage session can route on, where the project's own name does. Name a private project by its kind instead, such as `a Next.js app`, since the report leaves the machine.
21
21
  - Toolkit surface and its type (plugin skill, snippet, tooling config, governance rule, seed, or CLI)
22
22
  - Specific toolkit file or name when the session cites one
23
23
  - Observed behavior
24
24
  - Expected behavior, or `unclear`
25
25
  - Repro details already in context (commands run, files touched), or `none`
26
- - Proposed fix when the user stated one, or `open`
26
+ - Proposed fix, or `open` when the session settled on no direction
27
27
 
28
- Format as a single fenced markdown block:
28
+ Format as a single fenced markdown block, one `###` heading per field:
29
29
 
30
30
  ```markdown
31
31
  ## Toolkit feedback
32
32
 
33
- **From project:** <name or path>
34
- **Surface:** <type>, <file path or name>
35
- **Observed:** <one or two lines>
36
- **Expected:** <one or two lines, or "unclear">
37
- **Repro:** <commands or steps, or "none">
38
- **Proposed fix:** <one line, or "open">
33
+ ### From project
34
+
35
+ <name or kind>
36
+
37
+ ### Surface
38
+
39
+ <type>, <file path or name>
40
+
41
+ ### Observed
42
+
43
+ <one or two lines>
44
+
45
+ ### Expected
46
+
47
+ <one or two lines, or "unclear">
48
+
49
+ ### Repro
50
+
51
+ <commands or steps, or "none">
52
+
53
+ ### Proposed fix
54
+
55
+ <one line, or "open">
39
56
  ```
40
57
 
41
- Keep each field to one or two lines. Write the literal fallback shown above when a field has nothing.
58
+ `canon feedback` refuses a report missing `### Surface`, `### Observed`, or `### Proposed fix`, naming the one it did not find. The other three are optional, so write the literal fallback shown above rather than dropping the heading, which is what keeps a field the session cannot fill reading as absent instead of unreported.
59
+
60
+ Headings rather than bold labels, because a GitHub issue form renders a submitted field as `### <label>`. One shape reaches the toolkit whichever route a report takes, and the CLI parses both with one parser. The retired `**Surface:**` form is refused rather than accepted, so a block carrying it has to be rewritten.
61
+
62
+ Keep each field to one or two lines.
42
63
 
43
64
  ## Step 2: ship to the toolkit
44
65
 
@@ -60,7 +81,7 @@ EOF
60
81
 
61
82
  Report the printed path back to the user on its own line, in the form the project's instruction file sets under `## Output`.
62
83
 
63
- For a durable, cross-machine report instead of local scratch, add `--github`. The CLI opens a GitHub issue on the toolkit repo and prints the issue URL. It needs `gh` authenticated, and falls back to local scratch with a warning when `gh` is absent.
84
+ For a durable, cross-machine report instead of local scratch, add `--github`. The CLI opens a GitHub issue on the toolkit repo and prints the issue URL. It needs `gh` authenticated, and falls back to local scratch with the reason on stderr when the call produces no URL.
64
85
 
65
86
  ```bash
66
87
  cat <<'EOF' | canon feedback --github
@@ -74,6 +95,15 @@ Default to local scratch for a quick note. Use `--github` for a report worth tra
74
95
 
75
96
  If `canon` is not on PATH, fall back: print the block in chat and tell the user `📋 Copy the block above into a toolkit-repo session.`
76
97
 
98
+ ## Step 3: read what the CLI said back
99
+
100
+ The command exits 1 and writes nothing on a report it refuses, so a non-zero run is a block to repair rather than a report that shipped. Two refusals reach this step:
101
+
102
+ - A named missing field. Add that heading with a real value or its stated fallback, then re-run. Do not report the defect as filed.
103
+ - `gh` absent or its call failed, on `--github` from a machine holding no toolkit source. The stderr names which, since installing `gh` and fixing an authenticated call are different repairs. Fall back to printing the block in chat.
104
+
105
+ A `--github` run from a toolkit checkout warns and writes local scratch instead, which is a report filed on the other route rather than a failure.
106
+
77
107
  ## Notes
78
108
 
79
109
  - `canon feedback` resolves the toolkit root from the running `canon` binary's source location. If multiple toolkit clones exist on the machine, the first `canon` on PATH wins.
@@ -15,9 +15,9 @@ canon capture assets --selector .window --out .canon/review/captures
15
15
 
16
16
  `--selector` is required and every example above passes it. The element a capture crops to belongs to the page, not to the command, so there is no value that could be right for an arbitrary project's markup. `.window` is what this repository's own two sources declare, and a project renders its own pages by naming whatever theirs declare.
17
17
 
18
- `assets/` here holds two sources, so one run over the folder rebuilds both. `install.html` is hand-authored and its text came from a real run. `hero.html` is generated by `scripts/core/regen-hero.sh` from the CLI catalogs and must not be edited by hand, since `bun run check` regenerates it and fails on the difference.
18
+ `assets/` here holds two sources, so one run over the folder rebuilds both. Neither is edited by hand. `scripts/core/regen-hero.sh` writes both from a template beside it, filling `hero.html.tmpl` from the CLI catalogs and both templates from the design source, and `bun run check` regenerates them and fails on the difference. The terminal text on `install.html` still comes from a real run, and it is the template that holds it.
19
19
 
20
- Only the HTML is asserted for drift. The PNG is a chromium render whose bytes move with the browser version, so rebuild it with `canon capture assets/hero.html --selector .window` when the check reports the HTML changed.
20
+ Only the HTML is asserted for drift. The PNG is a chromium render whose bytes move with the browser version, so rebuild it with `canon capture assets --selector .window` when the check reports the HTML changed.
21
21
 
22
22
  Every render writes a stamp beside its PNG, `hero.png` next to `hero.stamp`, holding the source filename, a `source-sha256` over the markup bytes it read, and an `image-sha256` over the image bytes it wrote. Both digests are what `bun run check` compares, so a markup edit committed without a capture and a PNG swapped under unchanged markup each fail. The stamp is tracked and commits alongside the pair. Nothing hand-edits it, and a capture that cannot write it reports that source as failed and exits 1, so an image whose stamp never landed is reported rather than passed over.
23
23
 
@@ -28,7 +28,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
28
28
  | `canon design sync` | Update a base stylesheet already installed under `.claude/design/` |
29
29
  | `canon slides render` | Render a `.claude/SLIDES.md` source into a PowerPoint deck, reporting any unrecognized layout name on stderr |
30
30
  | `canon slides list` | List the available slide layouts (`--json` for the catalog) |
31
- | `canon feedback` | Write toolkit feedback from stdin to `.canon/review/feedback/`, or open a GitHub issue with `--github` |
31
+ | `canon feedback` | Write toolkit feedback from stdin to `.canon/review/feedback/`, or open a GitHub issue with `--github`, refusing either when a required field is absent |
32
32
  | `canon transcripts <url>` | Fetch a YouTube transcript with metadata frontmatter (needs `yt-dlp`) |
33
33
  | `canon tasks archive` | Move a shipped task and its plan off the board, clear its ordering row, and regenerate the index |
34
34
  | `canon tasks pull-request` | Record a pull request number on the task a branch closes, by stem or `--plan` (`--json`) |
@@ -48,6 +48,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
48
48
  | `canon records pull` | Fetch the records remote and write it back, refusing rather than discarding unpushed records (`--json`) |
49
49
  | `canon migrate rename` | Rewrite every unprotected `aitk` token to `canon` and move the paths that carry the name, reporting the plan without `--write` (`--scope`, `--json`) |
50
50
  | `canon migrate records` | Move the gitignored session records to `.canon/` and repoint every tracked citation, reporting the plan without `--write` (`--root`, `--json`) |
51
+ | `canon migrate record-tree` | Repoint the old-root citations inside the records themselves, scoped to the live folders and reporting every line without `--write` (`--root`, `--json`) |
51
52
  | `canon sessions list` | Resolve live sessions to the worktree and branch each holds, filtered by `--branch` (`--json`) |
52
53
  | `canon worktrees list` | Report which worktrees are reclaimable, keyed on the pull request having merged, with every refusal and the removal route named (`--json`) |
53
54
  | `canon comments scan` | Measure comment density by language and comment kind, with a trend recomputed from git |
@@ -146,6 +147,10 @@ Common patterns:
146
147
 
147
148
  A record folder already present at the destination is a refusal rather than a merge, and a line carrying `canon-keep-record-root`, or the line below it, keeps the old spelling for prose that dates a decision. The records themselves are never swept: everything under `.canon/` and every `.claude/` record folder is passed over and reported as a count on its own line, which is what keeps the run that follows the ignore collapse touching the same files as one before it. Running it twice rewrites nothing, which is the check that the exclusions, the markers, and that skip all fired.
148
149
 
150
+ `migrate record-tree` is what reaches the records the sweep above passes over, and the two share no scope. That one enumerates through git, so it sees every tracked file and none of the records, which are gitignored by construction. This one walks `.canon/` itself and is scoped to the folders a session still follows a path into: `diagrams`, `memory`, `plans`, `proposals`, `review`, `tasks`, and `teach`, each minus its own `archive/` subtree. A closed groundwork or intake trail, the scratch folder, and the backup history are reported as counts and never rewritten, because a path inside a closed trail sits in a sentence about work that already ended. Run it after the move, since there is no new root to walk before one.
151
+
152
+ Every citation in scope is reported with its file, its line number, and the line text. The record tree is untracked, so a wrong rewrite has no git undo, and that report is what a reader judges before passing `--write`. Marking a line that has to keep the old spelling uses the same `canon-keep-record-root` comment, and a second run rewriting nothing is the idempotence check.
153
+
149
154
  ## Version skew
150
155
 
151
156
  `canon sync --check` and `canon claude skills drift` are the two moments a target
@@ -31,7 +31,7 @@ Project docs split across two roots at the project root, on one mechanical line:
31
31
  └── tmp/ ← deletable scratch, safe to remove without loss
32
32
  ```
33
33
 
34
- A project scaffolded before the move keeps its records under `.claude/`, and every command reads either root. `canon migrate records` moves one project across and repoints what cites it.
34
+ A project scaffolded before the move keeps its records under `.claude/`, and every command reads either root. `canon migrate records` moves one project across and repoints what cites it, and `canon migrate record-tree` follows it to reach the citations inside the records themselves, which the first verb passes over because it enumerates through git.
35
35
 
36
36
  Three tiers of context load with different cost: always-loaded (root `CLAUDE.md`, `.claude/REQUIREMENTS.md`, `.claude/ARCHITECTURE.md`), path-scoped lazy (`.claude/rules/<scope>.md` with `paths:` glob), and on-demand lookup (`.claude/context/<domain>.md`, or `.claude/context/<domain>/` once a domain outgrows one file, discovered via `.claude/context/index.md`). See [the context model](../.claude/context/context-model.md) for the full picture.
37
37
 
@@ -146,6 +146,17 @@ A tracked file that names an old record path on purpose, such as prose dating a
146
146
 
147
147
  Until the move runs, the project is exposed. The shipped ignore set no longer names the old record paths, so a project holding records at `.claude/` stops ignoring them on its next `canon tooling sync`, and the first sign is a memory file or a task board appearing in a commit. Every command reads either root, so nothing else breaks in the meantime, and running the move is what closes it.
148
148
 
149
+ Then sweep the records themselves, which the three lines above never reach. They enumerate through git, and the records are gitignored by construction, so a task still naming its plan at the old root resolves nothing once the folders move.
150
+
151
+ ```bash
152
+ canon migrate record-tree --json
153
+ canon migrate record-tree --write --json
154
+ ```
155
+
156
+ This is a separate step rather than a fourth line in the block above because the move has to land before there is a new root to walk. The scope is the folders a session still follows a path into, being `diagrams`, `memory`, `plans`, `proposals`, `review`, `tasks`, and `teach`, each minus its own `archive/` subtree. A closed groundwork or intake trail, the scratch folder, and the backup history are counted and left alone, since a path inside one of those sits in a sentence about work that already ended.
157
+
158
+ Read the report before `--write` here more carefully than above. The record tree is untracked, so a wrong rewrite has no git undo, and the report names every citation with its line number and the line text for exactly that reason. The same `canon-keep-record-root` marker protects a line that has to keep the old spelling.
159
+
149
160
  ### Check first
150
161
 
151
162
  `canon sync --check <path>` reports what has drifted without writing anything. It splits each difference by cause, which is the question that decides what to do next.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.10.0",
4
+ "version": "4.11.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -1,16 +1,30 @@
1
1
  #!/usr/bin/env bash
2
- # Fills assets/hero.html.tmpl from the CLI catalogs and writes assets/hero.html.
2
+ # Fills every assets/*.html.tmpl from the CLI catalogs and the design source,
3
+ # writing the .html beside each one.
4
+ #
5
+ # The name says hero because the hero was the only template when it was written
6
+ # and every citation of it across docs/, .claude/context/, and src/ spells that
7
+ # name. Renaming the file is a sweep across roughly twenty surfaces for a
8
+ # cosmetic gain, so the file keeps its name and the loop below covers whatever
9
+ # templates the folder holds.
10
+ #
11
+ # The palette arrives the same way the counts do. `canon design css
12
+ # --no-components` emits the custom properties from src/design/tokens.ts and
13
+ # they land on the {{TOKENS}} placeholder, so neither frame carries its own copy
14
+ # of a hex value and a token moved at the source moves both captures. The
15
+ # component half is left out on purpose: it is a scrollbar and a status marker,
16
+ # and a static capture frame renders neither.
3
17
  #
4
18
  # Only the HTML regenerates here. The PNG beside it is a chromium render whose
5
19
  # bytes move with the browser version, so asserting it in verify.sh would fail
6
20
  # on a machine whose chromium differs rather than on a stale count. Rebuild the
7
- # image with `canon capture assets/hero.html --selector .window` after this
8
- # script reports a change. The selector has no default, since the element a
9
- # capture crops to belongs to the page rather than to the command, and `.window`
10
- # is the class this repository's own two sources declare.
11
- # That capture also writes assets/hero.stamp, which records the digest of the
12
- # markup it rendered and is what the Hero stage compares, so all three files
13
- # commit together. The frame carries no version. `package.json` is bumped on main by the release
21
+ # images with `canon capture assets --selector .window` after this script
22
+ # reports a change. The selector has no default, since the element a capture
23
+ # crops to belongs to the page rather than to the command, and `.window` is the
24
+ # class this repository's own two sources declare.
25
+ # That capture also writes a .stamp beside each PNG, which records the digest of
26
+ # the markup it rendered and is what the Hero stage compares, so a frame's three
27
+ # files commit together. The frame carries no version. `package.json` is bumped on main by the release
14
28
  # tooling, so embedding it drifts every open branch on the next release and the
15
29
  # stage then fails for work that touched nothing.
16
30
  #
@@ -22,8 +36,7 @@ set -o pipefail
22
36
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
23
37
  PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
24
38
 
25
- TEMPLATE="$PROJECT_ROOT/assets/hero.html.tmpl"
26
- OUTPUT="$PROJECT_ROOT/assets/hero.html"
39
+ ASSET_DIR="$PROJECT_ROOT/assets"
27
40
  LISTED=10
28
41
 
29
42
  # `bun src/cli.ts` rather than `canon`, since a globally linked binary resolves to
@@ -32,8 +45,17 @@ catalog() {
32
45
  (cd "$PROJECT_ROOT" && CANON_NON_INTERACTIVE=1 bun src/cli.ts "$@" --json 2>/dev/null)
33
46
  }
34
47
 
35
- if [ ! -f "$TEMPLATE" ]; then
36
- echo "regen-hero: missing template at $TEMPLATE" >&2
48
+ TEMPLATES=("$ASSET_DIR"/*.html.tmpl)
49
+ if [ ! -f "${TEMPLATES[0]}" ]; then
50
+ echo "regen-hero: no templates under $ASSET_DIR" >&2
51
+ exit 1
52
+ fi
53
+
54
+ # Not routed through `catalog`, since the design emitter writes CSS on stdout
55
+ # rather than a `--json` record.
56
+ TOKEN_CSS="$(cd "$PROJECT_ROOT" && CANON_NON_INTERACTIVE=1 bun src/cli.ts design css --no-components)"
57
+ if [ -z "$TOKEN_CSS" ]; then
58
+ echo "regen-hero: the design source emitted nothing, refusing to write an unstyled frame" >&2
37
59
  exit 1
38
60
  fi
39
61
 
@@ -73,17 +95,26 @@ printf '%s' "$GOV_JSON" >"$PAYLOAD_DIR/gov.json"
73
95
  printf '%s' "$STANDARDS_JSON" >"$PAYLOAD_DIR/standards.json"
74
96
  printf '%s' "$SNIPPETS_JSON" >"$PAYLOAD_DIR/snippets.json"
75
97
  printf '%s' "$TOOLING_JSON" >"$PAYLOAD_DIR/tooling.json"
98
+ printf '%s' "$TOKEN_CSS" >"$PAYLOAD_DIR/tokens.css"
76
99
 
77
100
  export PAYLOAD_DIR
78
- export TEMPLATE OUTPUT LISTED PROJECT_ROOT
101
+ export ASSET_DIR LISTED PROJECT_ROOT
79
102
 
80
103
  bun --eval '
81
- const { readFileSync } = require("node:fs")
104
+ const { readFileSync, readdirSync } = require("node:fs")
82
105
 
83
- const { PAYLOAD_DIR, TEMPLATE, OUTPUT, LISTED, PROJECT_ROOT } = process.env
106
+ const { PAYLOAD_DIR, ASSET_DIR, LISTED, PROJECT_ROOT } = process.env
84
107
 
85
108
  const payload = (name) => readFileSync(PAYLOAD_DIR + "/" + name + ".json", "utf8")
86
109
 
110
+ // Indented to the depth a rule inside the `<style>` block sits at, so the
111
+ // emitted file passes the same formatter every other committed asset does.
112
+ const tokenCss = readFileSync(PAYLOAD_DIR + "/tokens.css", "utf8")
113
+ .trimEnd()
114
+ .split("\n")
115
+ .map((line) => (line === "" ? "" : " " + line))
116
+ .join("\n")
117
+
87
118
  const COUNTS_JSON = payload("counts")
88
119
  const SKILLS_JSON = payload("skills")
89
120
  const GOV_JSON = payload("gov")
@@ -190,6 +221,7 @@ for (const [label, list] of [
190
221
  }
191
222
 
192
223
  const values = {
224
+ TOKENS: tokenCss,
193
225
  SKILL_COUNT: String(skills.length),
194
226
  RULE_COUNT: String(rules.length),
195
227
  STANDARD_COUNT: String(standards.length),
@@ -205,16 +237,30 @@ const values = {
205
237
  STANDARD_MORE: remaining(standards),
206
238
  }
207
239
 
208
- let html = await Bun.file(TEMPLATE).text()
209
- for (const [key, value] of Object.entries(values)) {
210
- html = html.replaceAll(`{{${key}}}`, value)
211
- }
240
+ // Every template takes the same value map, so one carrying no count placeholder
241
+ // simply resolves none of them. What a template must not do is name a
242
+ // placeholder nobody fills, which the unresolved check below catches per file.
243
+ const templates = readdirSync(ASSET_DIR)
244
+ .filter((name) => name.endsWith(".html.tmpl"))
245
+ .sort()
212
246
 
213
- const unresolved = html.match(/{{[A-Z_]+}}/g)
214
- if (unresolved) {
215
- console.error(`regen-hero: unresolved placeholders ${[...new Set(unresolved)].join(", ")}`)
247
+ if (templates.length === 0) {
248
+ console.error(`regen-hero: no templates under ${ASSET_DIR}`)
216
249
  process.exit(1)
217
250
  }
218
251
 
219
- await Bun.write(OUTPUT, html)
252
+ for (const template of templates) {
253
+ let html = await Bun.file(ASSET_DIR + "/" + template).text()
254
+ for (const [key, value] of Object.entries(values)) {
255
+ html = html.replaceAll(`{{${key}}}`, value)
256
+ }
257
+
258
+ const unresolved = html.match(/{{[A-Z_]+}}/g)
259
+ if (unresolved) {
260
+ console.error(`regen-hero: ${template} carries unresolved placeholders ${[...new Set(unresolved)].join(", ")}`)
261
+ process.exit(1)
262
+ }
263
+
264
+ await Bun.write(ASSET_DIR + "/" + template.replace(/\.tmpl$/, ""), html)
265
+ }
220
266
  '
@@ -2,8 +2,81 @@ const SLUG_FALLBACK = 'general'
2
2
  const SLUG_MAX_LENGTH = 40
3
3
  const TITLE_MAX_LENGTH = 72
4
4
 
5
+ /**
6
+ * The one place the report's field list is spelled. The GitHub issue form is
7
+ * the other writer of this shape and enforces nothing a producer passes
8
+ * through, so validation sits here, on the path every filed report crosses.
9
+ */
10
+ export const REQUIRED_FIELDS = ['Surface', 'Observed', 'Proposed fix'] as const
11
+
12
+ /**
13
+ * Fields are `###` headings under the report's own `## Toolkit feedback` title,
14
+ * because that is the level a GitHub issue form emits for a field label. One
15
+ * parser therefore reads a report the CLI wrote and one a person submitted
16
+ * through the web form.
17
+ *
18
+ * The retired `**Surface:**` bold form is not accepted. Reading both would
19
+ * leave a producer free to keep emitting the shape this parser exists to
20
+ * retire, and the validator would then enforce nothing.
21
+ */
22
+ const FIELD_HEADING = /^###\s+(.+?)\s*$/
23
+ const FENCE = /^\s*(```|~~~)/
24
+
25
+ export function parseSections(body: string): Map<string, string> {
26
+ const sections = new Map<string, string>()
27
+ let heading: string | undefined
28
+ let lines: string[] = []
29
+ let fenced = false
30
+
31
+ const flush = (): void => {
32
+ if (heading === undefined) return
33
+ sections.set(heading.toLowerCase(), lines.join('\n').trim())
34
+ }
35
+
36
+ for (const line of body.split('\n')) {
37
+ // A Repro field routinely carries a fenced block, and a `###` comment
38
+ // inside one is shell rather than the next field. Reading it as a heading
39
+ // splits the section and drops everything under it.
40
+ if (FENCE.test(line)) fenced = !fenced
41
+ const match = fenced ? null : line.match(FIELD_HEADING)
42
+ if (match?.[1] === undefined) {
43
+ lines.push(line)
44
+ continue
45
+ }
46
+ flush()
47
+ heading = match[1]
48
+ lines = []
49
+ }
50
+ flush()
51
+
52
+ return sections
53
+ }
54
+
55
+ export function readField(body: string, field: string): string | undefined {
56
+ const value = parseSections(body).get(field.toLowerCase())
57
+ return value ? value : undefined
58
+ }
59
+
60
+ /**
61
+ * The first required field the report does not carry, or `undefined` when it
62
+ * carries them all. One name rather than a list, because a reporter repairs one
63
+ * field at a time and the next run names the next gap.
64
+ */
65
+ export function missingField(body: string): string | undefined {
66
+ const sections = parseSections(body)
67
+ for (const field of REQUIRED_FIELDS) {
68
+ const value = sections.get(field.toLowerCase())
69
+ if (!value) return field
70
+ }
71
+ return undefined
72
+ }
73
+
74
+ export function missingFieldMessage(field: string): string {
75
+ return `Feedback report is missing its "### ${field}" section. Every report needs ${REQUIRED_FIELDS.map((name) => `### ${name}`).join(', ')}.`
76
+ }
77
+
5
78
  function surfaceField(body: string): string | undefined {
6
- return body.match(/\*\*Surface:\*\*\s*([^\n]+)/i)?.[1]?.trim()
79
+ return readField(body, 'Surface')?.split('\n')[0]?.trim()
7
80
  }
8
81
 
9
82
  export function deriveSlug(body: string): string {
@@ -1,10 +1,16 @@
1
1
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import type { Command } from 'commander'
4
- import { deriveSlug, deriveTitle } from '@/commands/feedback-format'
4
+ import {
5
+ deriveSlug,
6
+ deriveTitle,
7
+ missingField,
8
+ missingFieldMessage,
9
+ } from '@/commands/feedback-format'
5
10
  import { PROJECT_ROOT } from '@/project-root'
6
11
  import { creationRel } from '@/record-root'
7
12
  import { createGithubIssue } from '@/github'
13
+ import { issueFailureMessage } from '@/github-format'
8
14
  import { frameError, frameSuccess, palette } from '@/ui'
9
15
 
10
16
  function readStdin(): Promise<string> {
@@ -78,27 +84,35 @@ export function register(program: Command): void {
78
84
  return
79
85
  }
80
86
 
87
+ // Both write paths, not `--github` alone. A report missing its surface is
88
+ // no more useful sitting in the triage queue on disk than in the tracker.
89
+ const absent = missingField(body)
90
+ if (absent) {
91
+ frameError(missingFieldMessage(absent))
92
+ process.exitCode = 1
93
+ return
94
+ }
95
+
81
96
  if (opts.github) {
82
- const url = await createGithubIssue({
97
+ const result = await createGithubIssue({
83
98
  title: deriveTitle(body),
84
99
  body,
85
100
  labels: ['feedback'],
86
101
  })
87
- if (url) {
88
- frameSuccess('canon feedback', url)
89
- process.stdout.write(`${url}\n`)
102
+ if (result.ok) {
103
+ frameSuccess('canon feedback', result.url)
104
+ process.stdout.write(`${result.url}\n`)
90
105
  return
91
106
  }
107
+ const reason = issueFailureMessage(result)
92
108
  if (!isToolkitSource()) {
93
- frameError(
94
- 'gh unavailable and no toolkit source to fall back to. Install gh, or file it at https://github.com/erclx/canon/issues/new',
95
- )
109
+ frameError(`${reason} No toolkit source to fall back to.`)
96
110
  process.exitCode = 1
97
111
  return
98
112
  }
99
113
  const { NC, YELLOW } = palette(process.stderr)
100
114
  process.stderr.write(
101
- `${YELLOW}! gh unavailable, wrote local scratch instead${NC}\n`,
115
+ `${YELLOW}! ${reason} Wrote local scratch instead.${NC}\n`,
102
116
  )
103
117
  }
104
118