@erclx/canon 5.16.0 → 5.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/draft-figure/REQUIREMENT.md +8 -2
- package/claude/skills/draft-figure/SKILL.md +19 -4
- package/claude/skills/draft-wireframes/SKILL.md +1 -1
- package/claude/skills/teach-workspace/SKILL.md +1 -1
- package/claude/skills/teach-workspace/references/lesson-craft.md +1 -1
- package/docs/agents/demo.md +3 -1
- package/docs/agents/teach.md +2 -2
- package/docs/workflow/visual-design-workflow.md +8 -15
- package/governance/rules/claude/566-output.md +3 -5
- package/governance/rules/claude/575-hooks.md +1 -1
- package/package.json +1 -1
- package/src/commands/demo.ts +2 -0
- package/src/commands/teach.ts +3 -1
- package/src/demo/drive.ts +36 -7
- package/src/demo/timeline.ts +64 -0
- package/src/teach/workspace.ts +61 -6
- package/standards/figures.md +1 -0
- package/tooling/claude/reference.md +1 -1
- package/tooling/claude/seeds/.claude/hooks/path-form.sh +2 -4
- package/tooling/claude/seeds/.claude/hooks/search-reminder.sh +35 -0
- package/tooling/claude/seeds/.claude/settings.json +9 -0
|
@@ -9,12 +9,17 @@ description: Why a figure decides between Mermaid and freehand SVG before drawin
|
|
|
9
9
|
|
|
10
10
|
Without this skill, a session asked for a figure reaches for whichever path it thought of first. A graph-shaped subject gets model-authored SVG coordinates nobody checked against a render, drifting from the hand-drawn look every other figure in the project carries. A subject that is not graph-shaped gets forced through Mermaid's node-and-edge model, producing a diagram that answers a different question than the one asked. Either failure ships a figure a reader cannot trust, and a figure exists only to be trusted at a glance.
|
|
11
11
|
|
|
12
|
+
A teach lesson breaks the Mermaid path outright. The lesson letters its figures in a hand font the renderer cannot load, so the render sizes every label in a narrower fallback face and the lesson clips the text it actually shows. The check then reads the standalone image, rendered in that same fallback, and passes a figure the reader sees clipped. Both happened on one lesson, where every group title clipped mid-word and only a capture taken inside the lesson showed it.
|
|
13
|
+
|
|
12
14
|
The color failure is the quiet one. Mermaid's own theme writes literal hex values, and a figure carrying one stops re-coloring itself the moment the host page switches its palette, which the figure standard's own accessibility bar rules out.
|
|
13
15
|
|
|
14
16
|
## Must
|
|
15
17
|
|
|
16
18
|
- Apply the standard's earn-its-place bar before drawing anything
|
|
17
19
|
- Decide between the Mermaid path and the freehand path by whether the subject reduces to a graph, not by which is faster to produce
|
|
20
|
+
- Draw a figure bound for a teach lesson freehand whatever its shape, since the lesson's hand font is one the renderer cannot measure
|
|
21
|
+
- Verify a figure bound for a rendered page by capturing it inside that page, and fall back to the standalone render only where the destination has no page to load
|
|
22
|
+
- Keep the scoped overflow style as the documented fallback for a clipped group title wherever the Mermaid path still runs
|
|
18
23
|
- Render the Mermaid path with the hand-drawn look and the figure font stack, never with Mermaid's default look
|
|
19
24
|
- Rewrite every literal color the renderer wrote into the custom property the host stylesheet defines
|
|
20
25
|
- Read the Mermaid render back and judge it against what the figure means to say, the same verification `draft-diagram` runs for the architecture surface
|
|
@@ -22,7 +27,8 @@ The color failure is the quiet one. Mermaid's own theme writes literal hex value
|
|
|
22
27
|
|
|
23
28
|
## Must not
|
|
24
29
|
|
|
25
|
-
- Force a non-graph-shaped subject through Mermaid, or route a graph-shaped one through freehand SVG coordinates a model authored by hand
|
|
30
|
+
- Force a non-graph-shaped subject through Mermaid, or route a graph-shaped one through freehand SVG coordinates a model authored by hand outside a teach lesson
|
|
31
|
+
- Report a figure bound for a rendered page as verified from the standalone render alone
|
|
26
32
|
- Ship a literal hex color on either path
|
|
27
33
|
- Report a clean verification when the Mermaid render was skipped or a defect survived
|
|
28
34
|
- Loop past two correction passes on a surviving defect
|
|
@@ -39,4 +45,4 @@ The color failure is the quiet one. Mermaid's own theme writes literal hex value
|
|
|
39
45
|
- The toolkit's own `.canon/diagrams/` architecture surface, which shares no code, font, or visual language with this convention: `draft-diagram`
|
|
40
46
|
- Mermaid's own layout, budgets, and label rules for the fence itself: the mermaid standard, cited rather than restated here
|
|
41
47
|
- A UI wireframe or screen mockup: `draft-wireframes`
|
|
42
|
-
-
|
|
48
|
+
- Which lesson needs a figure and where it sits among the lesson's blocks: `teach-workspace`, which calls this skill to draw one
|
|
@@ -27,10 +27,11 @@ Apply the standard's bar: a relationship, a boundary, a path, or a before-and-af
|
|
|
27
27
|
|
|
28
28
|
## Step 3: decide the render path
|
|
29
29
|
|
|
30
|
+
- **A teach lesson**, meaning the destination is a lesson page in a learning workspace's `lessons/` folder: draw it through Step 5 whatever its shape. The lesson letters its figures in a hand font the renderer cannot load, so a Mermaid render measures its labels in a narrower face and the lesson clips them.
|
|
30
31
|
- **Graph-shaped**, meaning the relationship, boundary, or path is already what a flowchart or sequence diagram expresses: draw it through Step 4.
|
|
31
32
|
- **Not graph-shaped**, meaning the subject is a spatial or physical arrangement no flowchart reduces to: draw it through Step 5.
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
Take the first that matches, and state which path was picked and why in one sentence. The standard states this as a render-first policy rather than a ban on any tool-exported drawing, so outside a teach lesson a graph-shaped subject always routes through Mermaid even though that is itself an external renderer.
|
|
34
35
|
|
|
35
36
|
## Step 4: the Mermaid path
|
|
36
37
|
|
|
@@ -51,6 +52,8 @@ Render both formats in one pass: the SVG is what ships inside the `<figure>`, an
|
|
|
51
52
|
Use `bunx` when bun is available, falling back to `npx -y @mermaid-js/mermaid-cli ...` otherwise.
|
|
52
53
|
|
|
53
54
|
- Rewrite every stroke, fill, and text color the renderer wrote as a literal hex value into the custom property the host stylesheet defines, per the standard's color rule. Mermaid's own theme has no notion of a custom property, so this is a source edit made to the rendered SVG, not a config option.
|
|
55
|
+
- Expect a group title or a long label to clip once the destination shows it in the hand font, since the renderer sized its box in a fallback face.
|
|
56
|
+
- When the in-page check shows a clipped title, add a `<style>` inside the SVG, scoped to that SVG's own id, that sets `overflow: visible` on the clipped label's `foreignObject` and centers the label within it, so the wider text spills past both edges evenly. The style hides the mismatch for a title and does nothing for a node label crowding its box, which takes a shorter label instead.
|
|
54
57
|
- Check whether the project ran `canon design css --figures`. The figure and figcaption styling ships unconditionally either way, but that flag alone embeds the Virgil and Excalifont font files. Without it, the figure falls back to the browser's own generic `cursive` face rather than the intended hand-drawn font, and Step 7's output names that gap when the flag was not passed.
|
|
55
58
|
|
|
56
59
|
## Step 5: the freehand path
|
|
@@ -58,7 +61,7 @@ Use `bunx` when bun is available, falling back to `npx -y @mermaid-js/mermaid-cl
|
|
|
58
61
|
- Author the SVG by hand: plain shapes and lines authored directly in the markup.
|
|
59
62
|
- Follow the standard's `## Color and accessibility` section for every fill, stroke, and accessible name.
|
|
60
63
|
|
|
61
|
-
There is no external render to verify a freehand drawing against. The check here is the stroke-and-fill audit above and a read of the markup against what the subject means to show, done once rather than looped.
|
|
64
|
+
There is no external render to verify a freehand drawing against before it is written. The check here is the stroke-and-fill audit above and a read of the markup against what the subject means to show, done once rather than looped. Hand-placed coordinates can drift out of line where a layout engine would not, so a freehand figure bound for a rendered page also takes Step 7's in-page capture.
|
|
62
65
|
|
|
63
66
|
## Step 6: wrap, caption, and verify
|
|
64
67
|
|
|
@@ -66,12 +69,23 @@ There is no external render to verify a freehand drawing against. The check here
|
|
|
66
69
|
- On the Mermaid path, read the rendered PNG back and judge it against what the figure means to say, the same check `draft-diagram` Steps 5 and 6 run for the architecture surface. Fix the mermaid source and re-render on a defect. Stop after two correction passes. When a defect survives, keep the figure and name the defect in the output rather than reporting a false verification.
|
|
67
70
|
- On either path, confirm the figure still reads once every color and font it depends on is stripped to its fallback, per the standard's own working-figure bar.
|
|
68
71
|
- When the render fails for any reason, no browser engine, no network, no package manager, continue to Step 7 and name the skipped check. A missing renderer degrades the loop rather than failing it.
|
|
72
|
+
- When the destination is a rendered page, an HTML file a browser loads, the check that counts is the figure inside that page, which Step 7 runs once the figure is written. The standalone PNG is measured in the renderer's own fonts and can read clean while the page clips it. A markdown destination has no page to load, so the standalone check above is the whole check there.
|
|
69
73
|
|
|
70
74
|
## Step 7: confirm and write
|
|
71
75
|
|
|
72
76
|
Show the destination path, the decided render path, and the full `<figure>` markup before writing. Confirm with the user, since the graph-shaped decision and the render verdict are judgment calls with no diff to preview either against.
|
|
73
77
|
|
|
74
|
-
Write the figure into the destination document at the location the user named.
|
|
78
|
+
Write the figure into the destination document at the location the user named.
|
|
79
|
+
|
|
80
|
+
When the destination is a rendered page, capture the figure inside it and read the capture against what the figure means to say:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
canon capture <destination> --selector figure --out .canon/tmp/figures/
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Narrow the selector to this figure, such as by an id on its `<figure>`, when the page carries more than one. Fix a clipped label, a misaligned shape, or an arrow that misses its target in the destination and capture again, within the same two correction passes Step 6 allows. A clipped group title on the Mermaid path takes the scoped overflow style Step 4 states. When the capture fails, keep the figure and name the skipped check in the output, the same way Step 6 treats a failed render.
|
|
87
|
+
|
|
88
|
+
Renders and captures under `.canon/tmp/figures/` are verification artifacts, and the scratch PNGs, the stamps the capture writes beside them, and the JSON config are deleted once the checks confirm, since only the SVG ships.
|
|
75
89
|
|
|
76
90
|
## Output
|
|
77
91
|
|
|
@@ -88,6 +102,7 @@ Write the figure into the destination document at the location the user named. R
|
|
|
88
102
|
|
|
89
103
|
```plaintext
|
|
90
104
|
✅ Drafted figure in <path>
|
|
91
|
-
|
|
105
|
+
Render path: <mermaid | freehand>
|
|
106
|
+
Verified <in page | as a standalone render>. <defect or skipped check, one line, omitted when clean>
|
|
92
107
|
<Font not embedded: run `canon design css --figures` to ship Virgil and Excalifont. Omitted when the project already ran it.>
|
|
93
108
|
```
|
|
@@ -24,7 +24,7 @@ This skill stays fully independent of `context-fold`'s wireframe coverage sweep,
|
|
|
24
24
|
|
|
25
25
|
## Tier detection
|
|
26
26
|
|
|
27
|
-
- Read `canon/DESIGN.md` and every existing `canon/wireframes/` file for a tier signal:
|
|
27
|
+
- Read `canon/DESIGN.md` and every existing `canon/wireframes/` file for a tier signal: an Excalidraw or Figma reference, or a marker naming one of them.
|
|
28
28
|
- State the detected tier at the confirm step. Always draft the regions-and-states shape regardless of what is detected, since that is the only shape this skill or any other shipped mechanism produces, adding the ASCII sketch fence only when the layout is not built yet. Report a higher tier rather than attempting a companion render for it.
|
|
29
29
|
- Default silently to tier 0 when nothing is detected.
|
|
30
30
|
|
|
@@ -69,7 +69,7 @@ canon teach resource <topic> --json \
|
|
|
69
69
|
--lead "<title>=<url>"
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
Each flag repeats, and the pair splits on the first `=` so a URL carrying one survives. A URL
|
|
72
|
+
Each flag repeats, and the pair splits on the first `=` so a URL carrying one survives. A listed URL is refused, but `--read` moves an opened lead.
|
|
73
73
|
|
|
74
74
|
A claim nothing was read for is the failure this step exists against. Where no source is reachable, say so in the lesson and mark what rests on recall.
|
|
75
75
|
|
|
@@ -40,7 +40,7 @@ Decide the figure once per lesson, before writing the body.
|
|
|
40
40
|
|
|
41
41
|
1. Name the one relationship, boundary, path, or before-and-after the lesson is about. A lesson about none ends here, with `Figure: none, <why it names no structure>` on the reply.
|
|
42
42
|
2. Hold it against the bar in `${CLAUDE_SKILL_DIR}/../../standards/figures.md`, which also carries the drawing rules.
|
|
43
|
-
3. Draw it through `canon:draft-figure` into a `raw` block
|
|
43
|
+
3. Draw it hand-written through `canon:draft-figure` into a `raw` block before the quiz, never inside it or after its feedback, which breaks the stepper's gating. Report it, never draw it yourself, if the skill does not resolve.
|
|
44
44
|
4. Or write one line on why prose carries it better, such as a structure an earlier lesson gave.
|
|
45
45
|
|
|
46
46
|
A structure lesson takes step 3 or 4, never neither. Put the outcome on the reply's `Figure:` line, off the lesson page. Never draw to satisfy the step, or on every section (`## Restraint`).
|
package/docs/agents/demo.md
CHANGED
|
@@ -26,7 +26,7 @@ Each demo owns a folder, `demos/<slug>/`, and `compile` writes the plan into it.
|
|
|
26
26
|
| `plan.json` | yes | The compiled plan, with its hand-tuned timing |
|
|
27
27
|
| `index.html` | yes | The HyperFrames composition, when the demo is wrapped |
|
|
28
28
|
| `demo.gif` | yes | The one deliverable a README embeds |
|
|
29
|
-
| `take/` | no | The raw recording
|
|
29
|
+
| `take/` | no | The raw recording, its still, and its timeline |
|
|
30
30
|
| `renders/` | no | The composed mp4, written by the render |
|
|
31
31
|
|
|
32
32
|
A project carries `demos/*/take/` and `demos/*/renders/` in its `.gitignore`, since both are one command away and a webm is large. A plan compiled before this layout, at `demos/<slug>.json`, still runs by path, because only the default moved.
|
|
@@ -58,6 +58,8 @@ A recording that has to match both themes takes one plan per scheme, committed s
|
|
|
58
58
|
|
|
59
59
|
A step's caption from the draft renders as an overlay while its hold plays, so the narration a person wrote is what shows on screen rather than the name of the action the engine performed.
|
|
60
60
|
|
|
61
|
+
Beside the webm, the run writes `<video-basename>.timeline.json`, so a composition can aim a zoom at the moment and the place of an action rather than adding up holds by hand. It holds an `entries` array with one entry per step, each carrying the step's `index`, its `kind`, and `startMs` and `endMs` counted from the recording's first frame. A step ends once its hold has played. A click, fill, or hover entry also carries `box`, the target's `x`, `y`, `width`, and `height` in viewport pixels, which are the video frame's own coordinates. A navigate, scroll, wait, or hold entry carries no `box`, and the file is still written. A run under `--no-video` writes no timeline, since there is no video for it to sit beside. The `--json` record names the file under `timeline`, and `null` when none was written.
|
|
62
|
+
|
|
61
63
|
When `ffmpeg` is on PATH, the run also writes an mp4 beside the webm, since webm plays in a `<video>` tag but nothing else accepts it. A target without `ffmpeg` still gets the webm and a line naming what to install, and the run does not fail over the missing converter.
|
|
62
64
|
|
|
63
65
|
`--gif` adds a third file from the same converter, and it is opt-in where the mp4 is not. A gif runs several times the size of the webm it derives from, and the destination that needs one is a README on a host that strips `<video>`, which GitHub does. The filter generates a palette from the source and then applies it, rather than quantizing per frame, since a per-frame palette is what makes a recording of flat interface colors band and shimmer. It applies that palette without dithering, because the command records applications rather than photographs and the dither writes noise the encoder then stores. One recording measured 3,193,715 bytes dithered against 2,701,941 without, with the same text region cropped from both and read as identical. A missing converter is reported the same way it is for the mp4, so an absent `ffmpeg` costs the gif and not the run.
|
package/docs/agents/teach.md
CHANGED
|
@@ -83,7 +83,7 @@ canon teach resource regular-expressions \
|
|
|
83
83
|
|
|
84
84
|
The pair splits on the first `=`, so a URL carrying its own separator survives intact. Say in the title which claims rest on the source, since the entry is the only place a later session reads that from. Which sources qualify is the source ladder in `canon standards teach`, and a source below its first two rungs also names why no higher one covers the claim.
|
|
85
85
|
|
|
86
|
-
A URL either heading already lists is refused rather than written twice. Two entries for one source split what rests on it across two lines, and a reader checking a claim then finds half of the answer.
|
|
86
|
+
A URL either heading already lists is refused rather than written twice. Two entries for one source split what rests on it across two lines, and a reader checking a claim then finds half of the answer. The exception is a `--read` URL already under `## Leads`, which moves to `## Read` under the title the call carries, since opening a lead is how it becomes read. A `--read` URL already under `## Read`, and any `--lead` URL already listed, stays refused, and a refusal leaves the whole batch unwritten.
|
|
87
87
|
|
|
88
88
|
A URL a file displays inside a fence does not count as listed, which is how a workspace quoting the entry format in its own prose is read as the sample it is.
|
|
89
89
|
|
|
@@ -142,7 +142,7 @@ Read `url` off the record rather than building one from the port that was asked
|
|
|
142
142
|
| `exists` | A workspace already covers the topic an open names |
|
|
143
143
|
| `no-file` | The workspace carries no file the verb writes into |
|
|
144
144
|
| `no-section` | `RESOURCES.md` carries no heading the entries belong under |
|
|
145
|
-
| `listed` | A URL is already
|
|
145
|
+
| `listed` | A URL is already read, or a lead is offered as listed |
|
|
146
146
|
| `defined` | A term already carries a glossary entry |
|
|
147
147
|
| `bad-input` | The command line is malformed, before any folder is read |
|
|
148
148
|
|
|
@@ -8,9 +8,9 @@ category: Workflow
|
|
|
8
8
|
|
|
9
9
|
Three tiers cover the range from prose-only design docs to a fully graphical design source of truth. Pick one per project based on how UI-heavy the work is, whether stakeholders review visuals, and whether a designer is involved. Tiers stack, so moving up does not invalidate work done at a lower tier.
|
|
10
10
|
|
|
11
|
-
The tier framework sits alongside [Claude Design](../../wiki/claude/design.md), the one catalog page in this range.
|
|
11
|
+
The tier framework sits alongside [Claude Design](../../wiki/claude/design.md), the one catalog page in this range. Excalidraw and the surrounding MCP and skill ecosystem are named inline below rather than catalogued separately. This page decides when to reach for what.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
[Claude Design](../../wiki/claude/design.md), released 2026-04-17 and priced inside Claude subscriptions, is the ceiling tool for tier 1 and tier 2. Reserve it for codebase extraction and the richly annotated handoff bundle.
|
|
14
14
|
|
|
15
15
|
## Tier 0: prose only
|
|
16
16
|
|
|
@@ -56,21 +56,19 @@ A cell no source anchors ends in `? verify`, and the preview shows that marker b
|
|
|
56
56
|
|
|
57
57
|
ASCII and prose stay as source of truth. Add a visual render as a feedback surface for the agent and for human review.
|
|
58
58
|
|
|
59
|
-
- Stitch via MCP handles agent-driven generation of prototypes and design systems
|
|
60
59
|
- Excalidraw handles agent-driven wireframes when the round-trip canvas loop matters
|
|
61
60
|
- Claude Design joins only when codebase extraction or the annotated handoff bundle is worth the ceiling cost
|
|
62
61
|
|
|
63
|
-
|
|
62
|
+
Both produce derived artifacts, so human edits are review annotations rather than source changes.
|
|
64
63
|
|
|
65
64
|
### Seed shape
|
|
66
65
|
|
|
67
|
-
Same as tier 0 with
|
|
66
|
+
Same as tier 0 with one addition. `WIREFRAMES.md` opts into Excalidraw rendering via a top-of-file marker like `<!-- excalidraw: WIREFRAMES.excalidraw -->`. `DESIGN.md` stays human-maintained.
|
|
68
67
|
|
|
69
|
-
Impeccable, if installed, keeps its own root `DESIGN.md` and `PRODUCT.md` in
|
|
68
|
+
Impeccable, if installed, keeps its own root `DESIGN.md` and `PRODUCT.md` in its own format. These are separate files from the toolkit's `canon/DESIGN.md` and are not synced with it. Treat impeccable's pair as its own source of truth for its commands, and the toolkit's `canon/DESIGN.md` as the source other toolkit skills read.
|
|
70
69
|
|
|
71
70
|
### Tools
|
|
72
71
|
|
|
73
|
-
- Stitch via MCP at `stitch.googleapis.com/mcp`, Google's Gemini-powered design product. Agent-addressable through `generate_screen_from_text`, `edit_screens`, and `generate_variants`. Default pick for agent-driven visual generation. Free tier of 400 daily credits.
|
|
74
72
|
- Excalidraw canvas server on localhost plus the community [`yctimlin/mcp_excalidraw`](https://github.com/yctimlin/mcp_excalidraw) MCP shim, for projects that need an agent to draw, read back, and revise a canvas. MIT-licensed, plaintext JSON scene format. `describe_scene` and `get_canvas_screenshot` let the agent verify its own layout before claiming it is correct.
|
|
75
73
|
- Playwright MCP ([`microsoft/playwright-mcp`](https://github.com/microsoft/playwright-mcp)) for browser-side verification, driven off the accessibility tree rather than screenshots.
|
|
76
74
|
- Chrome DevTools MCP ([`ChromeDevTools/chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp)) for live frontend debugging: performance traces, network inspection, and DOM and CSS inspection.
|
|
@@ -88,17 +86,15 @@ Impeccable, if installed, keeps its own root `DESIGN.md` and `PRODUCT.md` in the
|
|
|
88
86
|
- Design decisions benefit from visual inspection
|
|
89
87
|
- A single contributor owns both design and implementation
|
|
90
88
|
|
|
91
|
-
###
|
|
89
|
+
### Excalidraw vs Claude Design in tier 1
|
|
92
90
|
|
|
93
|
-
The
|
|
94
|
-
|
|
95
|
-
Stitch via MCP generates polished prototypes from text and a provisioned design system, scripted from a Claude Code session. Output is human-facing and lives in Stitch. Free daily quota makes it the right pick for frequent iteration.
|
|
91
|
+
The two tools solve non-overlapping halves of the visual companion problem.
|
|
96
92
|
|
|
97
93
|
Excalidraw gives the agent a canvas it can read back over MCP. Output is agent-facing and persists as a JSON file in the repo. Pick it when Claude Code needs to iterate on wireframes autonomously, not when a human needs polished review artifacts.
|
|
98
94
|
|
|
99
95
|
Claude Design reads the raw codebase without prose curation and produces a polished handoff bundle with chat history. Weekly quota makes it expensive, so reserve it for the extraction pass or the final handoff moment, not daily iteration.
|
|
100
96
|
|
|
101
|
-
Most tier 1 projects pick one. Projects with a messy codebase and no curated `DESIGN.md` may want Claude Design once for the initial extraction
|
|
97
|
+
Most tier 1 projects pick one. Projects with a messy codebase and no curated `DESIGN.md` may want Claude Design once for the initial extraction.
|
|
102
98
|
|
|
103
99
|
## Tier 2: visual as source of truth
|
|
104
100
|
|
|
@@ -112,7 +108,6 @@ Design happens in a graphical tool. `canon/DESIGN.md` either regenerates from th
|
|
|
112
108
|
|
|
113
109
|
- Figma desktop app with the official [Figma Dev Mode MCP](https://www.figma.com/blog/introducing-claude-code-to-figma/) for teams with a dedicated designer already on Figma. Bidirectional sync and Code to Canvas capture of a running Claude Code UI into editable Figma frames.
|
|
114
110
|
- Claude Design with its Claude Code handoff bundle for teams without an existing Figma investment and for solo founders or PMs driving design themselves. One-way handoff, no bidirectional sync. See [Claude Design](../../wiki/claude/design.md).
|
|
115
|
-
- Stitch via MCP as a low-cost complement to either, used for bulk screen generation driven by Claude Code.
|
|
116
111
|
- Playwright and Chrome DevTools MCPs as in tier 1
|
|
117
112
|
|
|
118
113
|
### Skills
|
|
@@ -132,8 +127,6 @@ Figma wins when a dedicated designer already owns a Figma file, external collabo
|
|
|
132
127
|
|
|
133
128
|
Claude Design wins when nobody on the team has a Figma workflow yet and the handoff bundle with chat history is the intended implementation path. Claude Design's handoff is one-way, and its quota limits daily iteration, so it is most useful for the initial extraction pass and the final handoff moment rather than day-to-day design work.
|
|
134
129
|
|
|
135
|
-
Either of the above pairs cleanly with Stitch via MCP for agent-driven screen generation between the two anchor moments.
|
|
136
|
-
|
|
137
130
|
## Decision guide
|
|
138
131
|
|
|
139
132
|
Four questions. Each yes weighs toward a higher tier.
|
|
@@ -7,12 +7,10 @@ description: Report file paths correctly for the reading surface and group them
|
|
|
7
7
|
## Reporting paths
|
|
8
8
|
|
|
9
9
|
- After creating or modifying a file, include its path on its own line so the reader can open it. Do not paraphrase paths into prose ("the seeds folder", "your CLAUDE.md").
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
- On every other value, including unset, emit the path bare. A terminal emulator makes it clickable through its own path detection, and link markup defeats that.
|
|
13
|
-
- Both forms govern a path emitted in a response. A path written into a markdown file follows the markdown standard instead, which your toolkit resolves by name, and which backticks a file reference and never repeats it as a link label.
|
|
10
|
+
- Emit a path in the form the surface's own instructions give, such as a markdown link where the harness asks for one, and bare where it gives none. A terminal makes a bare path clickable through its own path detection, and link markup defeats that.
|
|
11
|
+
- That form governs a path emitted in a response. A path written into a markdown file follows the markdown standard instead, which your toolkit resolves by name, and which backticks a file reference and never repeats it as a link label.
|
|
14
12
|
- In a linked worktree (under `.claude/worktrees/<name>/`), emit the absolute path, since the editor is rooted at the main project root and a path relative to the worktree resolves nowhere there. Prefer the form a `PostToolUse` hook hands back after a write where one is installed.
|
|
15
13
|
|
|
16
14
|
## Grouping multiple files
|
|
17
15
|
|
|
18
|
-
- When the response covers multiple files, group paths under headers: `**Created:**`, `**Modified:**`, `**Deleted:**`. Every path under them takes
|
|
16
|
+
- When the response covers multiple files, group paths under headers: `**Created:**`, `**Modified:**`, `**Deleted:**`. Every path under them takes that form, not the first alone. For single-file changes, the path on its own line is enough.
|
|
@@ -9,7 +9,7 @@ paths:
|
|
|
9
9
|
|
|
10
10
|
## Reading a payload
|
|
11
11
|
|
|
12
|
-
- Open a hook that reads a payload with a bounded `IFS= read -r -d '' -t 2 input`, exiting non-zero on an empty payload. An unbounded `cat` blocks forever against a hand run or an open socket, holding the session open with it.
|
|
12
|
+
- Open a hook that reads a payload with a bounded `IFS= read -r -d '' -t 2 input`, exiting non-zero on an empty payload. An unbounded `cat` blocks forever against a hand run or an open socket, holding the session open with it.
|
|
13
13
|
|
|
14
14
|
## Silencing output
|
|
15
15
|
|
package/package.json
CHANGED
package/src/commands/demo.ts
CHANGED
|
@@ -333,6 +333,7 @@ async function runDrive(planPath: string, opts: RunOptions): Promise<number> {
|
|
|
333
333
|
|
|
334
334
|
if (result.videoPath) logInfo(display(result.videoPath))
|
|
335
335
|
if (result.stillPath) logInfo(`${display(result.stillPath)} still`)
|
|
336
|
+
if (result.timelinePath) logInfo(`${display(result.timelinePath)} timeline`)
|
|
336
337
|
logInfo(
|
|
337
338
|
`${result.steps} steps in ${Math.round(result.durationMs / 100) / 10}s`,
|
|
338
339
|
)
|
|
@@ -380,6 +381,7 @@ async function runDrive(planPath: string, opts: RunOptions): Promise<number> {
|
|
|
380
381
|
mp4: mp4Path ?? null,
|
|
381
382
|
gif: gifPath ?? null,
|
|
382
383
|
still: result.stillPath ?? null,
|
|
384
|
+
timeline: result.timelinePath ?? null,
|
|
383
385
|
steps: result.steps,
|
|
384
386
|
durationMs: result.durationMs,
|
|
385
387
|
...(mp4Reason ? { mp4Reason } : {}),
|
package/src/commands/teach.ts
CHANGED
|
@@ -237,7 +237,9 @@ export function register(program: Command): void {
|
|
|
237
237
|
'only place a later session reads that from.',
|
|
238
238
|
'',
|
|
239
239
|
'A URL already listed under either heading is refused rather than',
|
|
240
|
-
'repeated, because two entries split what rests on one source.',
|
|
240
|
+
'repeated, because two entries split what rests on one source. The one',
|
|
241
|
+
'exception is a --read URL listed under Leads, which moves to Read under',
|
|
242
|
+
'its new title.',
|
|
241
243
|
'',
|
|
242
244
|
'Examples:',
|
|
243
245
|
' canon teach resource regular-expressions --read "MDN regular expressions=https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions"',
|
package/src/demo/drive.ts
CHANGED
|
@@ -8,6 +8,8 @@ import { deriveSteps } from '@/demo/compile'
|
|
|
8
8
|
import type { DemoPlan, DemoStep } from '@/demo/compile'
|
|
9
9
|
import type { CursorSet } from '@/demo/pointer'
|
|
10
10
|
import { pointerSource } from '@/demo/pointer'
|
|
11
|
+
import type { StepTiming, TargetBox } from '@/demo/timeline'
|
|
12
|
+
import { timelineEntry, timelinePath, writeTimeline } from '@/demo/timeline'
|
|
11
13
|
|
|
12
14
|
declare global {
|
|
13
15
|
interface Window {
|
|
@@ -84,6 +86,7 @@ export type DriveResult =
|
|
|
84
86
|
status: 'recorded'
|
|
85
87
|
videoPath?: string
|
|
86
88
|
stillPath?: string
|
|
89
|
+
timelinePath?: string
|
|
87
90
|
steps: number
|
|
88
91
|
durationMs: number
|
|
89
92
|
}
|
|
@@ -150,6 +153,7 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
|
|
|
150
153
|
|
|
151
154
|
let stillPath: string | undefined
|
|
152
155
|
let videoPath: string | undefined
|
|
156
|
+
let timeline: string | undefined
|
|
153
157
|
|
|
154
158
|
try {
|
|
155
159
|
await context.addInitScript({
|
|
@@ -157,7 +161,12 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
|
|
|
157
161
|
})
|
|
158
162
|
await context.addInitScript({ content: captionInitScript() })
|
|
159
163
|
const page = await context.newPage()
|
|
164
|
+
// The recording begins with the page, so this is the instant a frame of
|
|
165
|
+
// the take is zero. Taking it at the first step instead would drift every
|
|
166
|
+
// entry by the opening page load.
|
|
167
|
+
const recordingStart = performance.now()
|
|
160
168
|
const video = page.video()
|
|
169
|
+
const timings: StepTiming[] = []
|
|
161
170
|
const pace: PointerPace = {}
|
|
162
171
|
|
|
163
172
|
// The opening navigate is skipped when the plan already starts with one,
|
|
@@ -170,8 +179,16 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
|
|
|
170
179
|
}
|
|
171
180
|
}
|
|
172
181
|
|
|
173
|
-
for (const step of plan.steps) {
|
|
174
|
-
|
|
182
|
+
for (const [index, step] of plan.steps.entries()) {
|
|
183
|
+
const startedAt = performance.now()
|
|
184
|
+
const box = await runStep(page, plan, step, pace)
|
|
185
|
+
timings.push({
|
|
186
|
+
index,
|
|
187
|
+
kind: step.kind,
|
|
188
|
+
startedAt,
|
|
189
|
+
endedAt: performance.now(),
|
|
190
|
+
...(box ? { box } : {}),
|
|
191
|
+
})
|
|
175
192
|
// The first marked step wins. One file holds one frame, so a plan a
|
|
176
193
|
// person edited to mark several would otherwise write each over the last
|
|
177
194
|
// and keep whichever ran last, with nothing saying so.
|
|
@@ -191,6 +208,11 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
|
|
|
191
208
|
// The engine keeps the auto-named recording beside the saved copy, so a
|
|
192
209
|
// run that skipped this would leave two files for every demo.
|
|
193
210
|
await video.delete()
|
|
211
|
+
timeline = timelinePath(videoPath)
|
|
212
|
+
writeTimeline(
|
|
213
|
+
timeline,
|
|
214
|
+
timings.map((timing) => timelineEntry(timing, recordingStart)),
|
|
215
|
+
)
|
|
194
216
|
}
|
|
195
217
|
} catch (error) {
|
|
196
218
|
return failed('drive-failed', error)
|
|
@@ -203,6 +225,7 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
|
|
|
203
225
|
status: 'recorded',
|
|
204
226
|
...(videoPath ? { videoPath } : {}),
|
|
205
227
|
...(stillPath ? { stillPath } : {}),
|
|
228
|
+
...(timeline ? { timelinePath: timeline } : {}),
|
|
206
229
|
steps: plan.steps.length,
|
|
207
230
|
durationMs: Date.now() - started,
|
|
208
231
|
}
|
|
@@ -237,13 +260,17 @@ export interface PointerPace {
|
|
|
237
260
|
* Exported so `drive.e2e.test.ts` can drive one real step against a real
|
|
238
261
|
* caption and read it back, which is the integration a full `drive()` call
|
|
239
262
|
* cannot assert without decoding the video it writes.
|
|
263
|
+
*
|
|
264
|
+
* Returns the box the pointer moved to, for the timeline, and nothing for
|
|
265
|
+
* a step that points at no target.
|
|
240
266
|
*/
|
|
241
267
|
export async function runStep(
|
|
242
268
|
page: Page,
|
|
243
269
|
plan: DemoPlan,
|
|
244
270
|
step: DemoStep,
|
|
245
271
|
pace: PointerPace,
|
|
246
|
-
): Promise<
|
|
272
|
+
): Promise<TargetBox | undefined> {
|
|
273
|
+
let box: TargetBox | undefined
|
|
247
274
|
switch (step.kind) {
|
|
248
275
|
case 'navigate':
|
|
249
276
|
await page.goto(step.target || plan.url)
|
|
@@ -252,18 +279,18 @@ export async function runStep(
|
|
|
252
279
|
}
|
|
253
280
|
break
|
|
254
281
|
case 'click':
|
|
255
|
-
await moveTo(page, plan, step, pace)
|
|
282
|
+
box = await moveTo(page, plan, step, pace)
|
|
256
283
|
await page.mouse.down()
|
|
257
284
|
await page.mouse.up()
|
|
258
285
|
break
|
|
259
286
|
case 'fill':
|
|
260
|
-
await moveTo(page, plan, step, pace)
|
|
287
|
+
box = await moveTo(page, plan, step, pace)
|
|
261
288
|
await page.mouse.down()
|
|
262
289
|
await page.mouse.up()
|
|
263
290
|
await page.keyboard.type(step.text, { delay: plan.pointer.typeDelayMs })
|
|
264
291
|
break
|
|
265
292
|
case 'hover':
|
|
266
|
-
await moveTo(page, plan, step, pace)
|
|
293
|
+
box = await moveTo(page, plan, step, pace)
|
|
267
294
|
break
|
|
268
295
|
case 'scroll':
|
|
269
296
|
// Centred rather than `scrollIntoViewIfNeeded`, which scrolls the least
|
|
@@ -307,6 +334,7 @@ export async function runStep(
|
|
|
307
334
|
await setCaption(page, step.caption)
|
|
308
335
|
if (step.waitFor) await page.locator(step.waitFor).first().waitFor()
|
|
309
336
|
await page.waitForTimeout(step.holdMs)
|
|
337
|
+
return box
|
|
310
338
|
}
|
|
311
339
|
|
|
312
340
|
/**
|
|
@@ -333,7 +361,7 @@ async function moveTo(
|
|
|
333
361
|
plan: DemoPlan,
|
|
334
362
|
step: DemoStep,
|
|
335
363
|
pace: PointerPace,
|
|
336
|
-
): Promise<
|
|
364
|
+
): Promise<TargetBox> {
|
|
337
365
|
pace.roundTripMs ??= await calibrateRoundTrip(page)
|
|
338
366
|
const locator = page.locator(step.target).first()
|
|
339
367
|
await locator.waitFor()
|
|
@@ -342,6 +370,7 @@ async function moveTo(
|
|
|
342
370
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, {
|
|
343
371
|
steps: deriveSteps(plan.pointer.travelMs, pace.roundTripMs),
|
|
344
372
|
})
|
|
373
|
+
return box
|
|
345
374
|
}
|
|
346
375
|
|
|
347
376
|
/**
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { basename, dirname, extname, join } from 'node:path'
|
|
3
|
+
import type { DemoStep } from '@/demo/compile'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A step's target in viewport pixels, which are the video frame's own
|
|
7
|
+
* coordinates because the recording is sized to the viewport.
|
|
8
|
+
*/
|
|
9
|
+
export interface TargetBox {
|
|
10
|
+
readonly x: number
|
|
11
|
+
readonly y: number
|
|
12
|
+
readonly width: number
|
|
13
|
+
readonly height: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** One step as the run clocked it, in wall-clock milliseconds. */
|
|
17
|
+
export interface StepTiming {
|
|
18
|
+
readonly index: number
|
|
19
|
+
readonly kind: DemoStep['kind']
|
|
20
|
+
readonly startedAt: number
|
|
21
|
+
readonly endedAt: number
|
|
22
|
+
readonly box?: TargetBox
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Times count from the recording's start rather than the first step, since
|
|
27
|
+
* that is the clock a composition places the take on. A step ends once its
|
|
28
|
+
* hold has played, so the span covers what the viewer sees of it.
|
|
29
|
+
*/
|
|
30
|
+
export interface TimelineEntry {
|
|
31
|
+
readonly index: number
|
|
32
|
+
readonly kind: DemoStep['kind']
|
|
33
|
+
readonly startMs: number
|
|
34
|
+
readonly endMs: number
|
|
35
|
+
readonly box?: TargetBox
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function timelineEntry(
|
|
39
|
+
timing: StepTiming,
|
|
40
|
+
recordingStart: number,
|
|
41
|
+
): TimelineEntry {
|
|
42
|
+
return {
|
|
43
|
+
index: timing.index,
|
|
44
|
+
kind: timing.kind,
|
|
45
|
+
startMs: Math.round(timing.startedAt - recordingStart),
|
|
46
|
+
endMs: Math.round(timing.endedAt - recordingStart),
|
|
47
|
+
...(timing.box ? { box: timing.box } : {}),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function timelinePath(videoPath: string): string {
|
|
52
|
+
return join(
|
|
53
|
+
dirname(videoPath),
|
|
54
|
+
`${basename(videoPath, extname(videoPath))}.timeline.json`,
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function writeTimeline(
|
|
59
|
+
path: string,
|
|
60
|
+
entries: readonly TimelineEntry[],
|
|
61
|
+
): void {
|
|
62
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
63
|
+
writeFileSync(path, `${JSON.stringify({ entries }, null, 2)}\n`)
|
|
64
|
+
}
|
package/src/teach/workspace.ts
CHANGED
|
@@ -885,12 +885,60 @@ async function openFile(
|
|
|
885
885
|
return path
|
|
886
886
|
}
|
|
887
887
|
|
|
888
|
+
/** The entry lines of the file, or of one heading's section. */
|
|
889
|
+
function listedEntries(text: string, heading?: string): string[] {
|
|
890
|
+
const body = unfenced(text)
|
|
891
|
+
const section = heading
|
|
892
|
+
? sectionRange(body, heading, text.split('\n').length)
|
|
893
|
+
: undefined
|
|
894
|
+
if (heading && !section) return []
|
|
895
|
+
|
|
896
|
+
return body
|
|
897
|
+
.filter(
|
|
898
|
+
(line) =>
|
|
899
|
+
isEntry(line.text) &&
|
|
900
|
+
(!section ||
|
|
901
|
+
(line.number - 1 >= section.start && line.number - 1 < section.end)),
|
|
902
|
+
)
|
|
903
|
+
.map((line) => line.text)
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function lists(entries: readonly string[], source: Source): boolean {
|
|
907
|
+
return entries.some((line) => line.includes(`(${source.url})`))
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Drops the lead entries naming the given URLs, wrapped continuation lines
|
|
912
|
+
* included, and restores the placeholder when the section empties.
|
|
913
|
+
*/
|
|
914
|
+
function withoutLeads(text: string, urls: ReadonlySet<string>): string {
|
|
915
|
+
const lines = text.split('\n')
|
|
916
|
+
const section = sectionRange(unfenced(text), LEADS_HEADING, lines.length)
|
|
917
|
+
if (!section) return text
|
|
918
|
+
|
|
919
|
+
const bullets = bulletRange(unfenced(text), section)
|
|
920
|
+
if (!bullets) return text
|
|
921
|
+
|
|
922
|
+
const kept = bulletBlocks(lines.slice(bullets.start, bullets.end)).filter(
|
|
923
|
+
(block) => ![...urls].some((url) => block[0].includes(`(${url})`)),
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
return [
|
|
927
|
+
...lines.slice(0, bullets.start),
|
|
928
|
+
...(kept.length > 0 ? kept.flat() : [PLACEHOLDER]),
|
|
929
|
+
...lines.slice(bullets.end),
|
|
930
|
+
].join('\n')
|
|
931
|
+
}
|
|
932
|
+
|
|
888
933
|
/**
|
|
889
934
|
* Appends sources to `RESOURCES.md`, keeping what was read apart from what was
|
|
890
935
|
* only found.
|
|
891
936
|
*
|
|
892
937
|
* A URL already listed under either heading is refused rather than repeated,
|
|
893
938
|
* since a second entry for one source splits what rests on it across two lines.
|
|
939
|
+
* The one exception is a read source found under `## Leads`, which moves to
|
|
940
|
+
* `## Read` under its new title, since opening a lead is how it becomes read.
|
|
941
|
+
* Any refusal leaves the file untouched, so a batch lands whole or not at all.
|
|
894
942
|
*/
|
|
895
943
|
export async function recordSources(
|
|
896
944
|
root: string,
|
|
@@ -905,13 +953,16 @@ export async function recordSources(
|
|
|
905
953
|
if (typeof path !== 'string') return path
|
|
906
954
|
|
|
907
955
|
let text = await readFile(path, 'utf8')
|
|
908
|
-
const listed =
|
|
909
|
-
|
|
910
|
-
.filter(isEntry)
|
|
956
|
+
const listed = listedEntries(text)
|
|
957
|
+
const leadEntries = listedEntries(text, LEADS_HEADING)
|
|
911
958
|
|
|
912
|
-
const
|
|
913
|
-
|
|
914
|
-
|
|
959
|
+
const isPromotion = (source: Source) => lists(leadEntries, source)
|
|
960
|
+
const promoted = read.filter(isPromotion)
|
|
961
|
+
|
|
962
|
+
const repeated = [
|
|
963
|
+
...read.filter((source) => !isPromotion(source)),
|
|
964
|
+
...leads,
|
|
965
|
+
].filter((source) => lists(listed, source))
|
|
915
966
|
|
|
916
967
|
if (repeated.length > 0) {
|
|
917
968
|
return refuse(
|
|
@@ -921,6 +972,10 @@ export async function recordSources(
|
|
|
921
972
|
)
|
|
922
973
|
}
|
|
923
974
|
|
|
975
|
+
if (promoted.length > 0) {
|
|
976
|
+
text = withoutLeads(text, new Set(promoted.map((source) => source.url)))
|
|
977
|
+
}
|
|
978
|
+
|
|
924
979
|
for (const [heading, sources] of [
|
|
925
980
|
[READ_HEADING, read],
|
|
926
981
|
[LEADS_HEADING, leads],
|
package/standards/figures.md
CHANGED
|
@@ -38,6 +38,7 @@ A figure failing these is non-conforming even when it satisfies every shape rule
|
|
|
38
38
|
|
|
39
39
|
- Route a graph-shaped subject, one whose relationship, boundary, or path a flowchart already expresses, through Mermaid's own hand-drawn look rather than through model-authored SVG coordinates. `mermaid.md` governs the fence itself.
|
|
40
40
|
- Reserve freehand inline SVG, plain shapes and lines authored directly in the markup, for a subject that is not graph-shaped. A figure that cannot express its subject through the deterministic path is a worse failure than an inconsistent one drawn by hand, so the hatch stays open rather than forcing every figure through the renderer.
|
|
41
|
+
- Draw a figure in a teach lesson freehand whatever its shape. A lesson letters its figures in a hand font the renderer cannot load, so Mermaid measures every label in a narrower fallback face, and the page then clips the wider text it actually shows.
|
|
41
42
|
- State this as a render-first policy rather than a ban on a tool-exported drawing. Routing a graph-shaped figure through Mermaid is itself running it through an external tool, so a rule banning any diagram "exported from a diagramming tool" would forbid the path this policy requires.
|
|
42
43
|
|
|
43
44
|
## Wrapping and captioning
|
|
@@ -91,6 +91,6 @@ The first entry covers the whole record root, so a record folder added later nee
|
|
|
91
91
|
|
|
92
92
|
## settings.json
|
|
93
93
|
|
|
94
|
-
- Project seed at `tooling/claude/seeds/.claude/settings.json` ships
|
|
94
|
+
- Project seed at `tooling/claude/seeds/.claude/settings.json` ships three PreToolUse hooks (pairing with `.claude/hooks/index-reminder.sh`, `.claude/hooks/search-reminder.sh`, and `.claude/hooks/scratch-guard.sh`) and a PostToolUse hook (pairs with `.claude/hooks/standards-audit.sh`). Project settings layer over user settings, so per-project files only carry what is genuinely project-specific.
|
|
95
95
|
- User-level template at `tooling/claude/user/settings.template.json` carries `attribution.commit` and `attribution.pr` (empty strings to suppress co-author tags), `permissions.allow` with `Bash(bun run *)`, and `permissions.deny` with `Read(**/.env)` and `Read(**/.env.*)` to block accidental secret exposure across any project. Installed once per machine via `canon claude setup`.
|
|
96
96
|
- The deprecated `includeCoAuthoredBy` key is not used.
|
|
@@ -8,10 +8,8 @@
|
|
|
8
8
|
# and no error appears. This computes the fact a session was asking itself
|
|
9
9
|
# to infer on every response and hands the answer back instead.
|
|
10
10
|
#
|
|
11
|
-
# The
|
|
12
|
-
#
|
|
13
|
-
# the environment variables Claude Code documents as passed to a hook
|
|
14
|
-
# subprocess, unlike CLAUDE_PROJECT_DIR, so a hook cannot read it reliably.
|
|
11
|
+
# The link form a response uses comes from the harness's own instruction for
|
|
12
|
+
# its surface, not from this hook or the output rule.
|
|
15
13
|
#
|
|
16
14
|
# The worktree branch comes from the path rather than from `git rev-parse`,
|
|
17
15
|
# the way `tasks-index.sh` and `memory-index.sh` already derive their main
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
# Claude Code sends a payload and closes stdin. A bare read with nothing feeding
|
|
4
|
+
# it blocks forever and holds the session open, so the read is bounded. `read`
|
|
5
|
+
# rather than `timeout cat`, which macOS does not ship.
|
|
6
|
+
IFS= read -r -d '' -t 2 input
|
|
7
|
+
[ -n "$input" ] || {
|
|
8
|
+
printf '%s reads a Claude Code hook payload on stdin and cannot be run by hand.\n' "${0##*/}" >&2
|
|
9
|
+
exit 1
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
tool=$(printf '%s' "$input" | jq -r '.tool_name // empty')
|
|
13
|
+
[ "$tool" = "WebSearch" ] || exit 0
|
|
14
|
+
|
|
15
|
+
# Without a session id there is no stable key to dedupe on, and a shared marker
|
|
16
|
+
# would silence every later session, so stay quiet rather than guess one.
|
|
17
|
+
session=$(printf '%s' "$input" | jq -r '.session_id // empty')
|
|
18
|
+
[ -n "$session" ] || exit 0
|
|
19
|
+
|
|
20
|
+
key=$(printf '%s' "$session" | tr -c 'A-Za-z0-9' '_')
|
|
21
|
+
# The marker is scratch, so it follows the scratch folder to whichever record
|
|
22
|
+
# root the project carries rather than creating a second one beside it.
|
|
23
|
+
project="${CLAUDE_PROJECT_DIR:-.}"
|
|
24
|
+
if [ -d "$project/.canon" ]; then
|
|
25
|
+
marker_dir="$project/.canon/tmp/hooks/search-reminder"
|
|
26
|
+
else
|
|
27
|
+
marker_dir="$project/.claude/.tmp/hooks/search-reminder"
|
|
28
|
+
fi
|
|
29
|
+
marker="$marker_dir/$key"
|
|
30
|
+
[ -f "$marker" ] && exit 0
|
|
31
|
+
mkdir -p "$marker_dir"
|
|
32
|
+
: >"$marker"
|
|
33
|
+
|
|
34
|
+
msg='Load the canon:search-craft skill before this web search. Report it rather than proceeding silently when the skill does not resolve.'
|
|
35
|
+
jq -nc --arg msg "$msg" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$msg}}'
|