@erclx/canon 4.6.1 → 4.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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-feedback-file/SKILL.md +3 -3
- package/claude/skills/canon-feedback-triage/SKILL.md +1 -1
- package/claude/skills/canon-rollout/SKILL.md +1 -1
- package/claude/skills/canon-screencast/SKILL.md +5 -5
- package/claude/skills/canon-slides-draft/SKILL.md +1 -1
- package/claude/skills/claude-address-review/SKILL.md +4 -4
- package/claude/skills/claude-autoship/SKILL.md +17 -17
- package/claude/skills/claude-design-extract/SKILL.md +2 -2
- package/claude/skills/claude-diagram/SKILL.md +13 -13
- package/claude/skills/claude-docs/SKILL.md +23 -23
- package/claude/skills/claude-feature/SKILL.md +6 -6
- package/claude/skills/claude-groundwork/SKILL.md +16 -16
- package/claude/skills/claude-intake/SKILL.md +8 -8
- package/claude/skills/claude-intake-answer/SKILL.md +1 -1
- package/claude/skills/claude-markdown-propose/REQUIREMENT.md +2 -2
- package/claude/skills/claude-markdown-propose/SKILL.md +8 -8
- package/claude/skills/claude-markdown-propose/references/format.md +3 -3
- package/claude/skills/claude-memory-capture/SKILL.md +13 -13
- package/claude/skills/claude-memory-review/SKILL.md +28 -28
- package/claude/skills/claude-memory-review/references/receipt-format.md +1 -1
- package/claude/skills/claude-orchestrate/SKILL.md +9 -9
- package/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +1 -1
- package/claude/skills/claude-orchestrate/references/orchestrator-parked.md +1 -1
- package/claude/skills/claude-orchestrate/references/orchestrator-poll.md +1 -1
- package/claude/skills/claude-orchestrate/references/orchestrator-resume.md +1 -1
- package/claude/skills/claude-orchestrate/scripts/poll.sh +1 -1
- package/claude/skills/claude-planner/REQUIREMENT.md +1 -1
- package/claude/skills/claude-planner/SKILL.md +6 -6
- package/claude/skills/claude-pr-review/SKILL.md +5 -5
- package/claude/skills/claude-review/SKILL.md +4 -4
- package/claude/skills/claude-seed-sync/SKILL.md +2 -2
- package/claude/skills/claude-tasks/SKILL.md +16 -16
- package/claude/skills/claude-teach/SKILL.md +9 -9
- package/claude/skills/claude-ui-test/SKILL.md +3 -3
- package/claude/skills/claude-ux-audit/SKILL.md +5 -5
- package/claude/skills/claude-ux-measure/SKILL.md +3 -3
- package/claude/skills/claude-worker/SKILL.md +2 -2
- package/claude/skills/claude-worktree/SKILL.md +3 -3
- package/claude/skills/git-issue/SKILL.md +4 -4
- package/claude/skills/git-pr/SKILL.md +6 -6
- package/claude/skills/git-ship/SKILL.md +3 -3
- package/claude/skills/git-split/REQUIREMENT.md +1 -1
- package/claude/skills/git-split/SKILL.md +20 -20
- package/claude/skills/migration-superseded/SKILL.md +4 -4
- package/claude/skills/session-map/SKILL.md +2 -2
- package/claude/skills/session-resume/SKILL.md +8 -8
- package/docs/agents/capture.md +17 -11
- package/docs/agents/commands.md +8 -5
- package/docs/agents/demo.md +2 -2
- package/docs/agents/driver.md +2 -2
- package/docs/agents/index.md +1 -1
- package/docs/agents/indexes.md +1 -1
- package/docs/agents/install-and-sync.md +1 -1
- package/docs/agents/intake.md +1 -1
- package/docs/agents/key-changes.md +1 -1
- package/docs/agents/records.md +12 -9
- package/docs/agents/restated.md +2 -2
- package/docs/agents/rule-citations.md +1 -1
- package/docs/agents/sandbox.md +1 -1
- package/docs/agents/state-scoped-risk.md +1 -1
- package/docs/agents/tasks.md +6 -6
- package/docs/agents/teach.md +4 -4
- package/docs/ai-workflow.md +26 -19
- package/docs/operating-model.md +3 -3
- package/docs/target-projects.md +22 -2
- package/docs/visual-design-workflow.md +1 -1
- package/governance/rules/claude/555-tasks.md +3 -3
- package/governance/rules/claude/556-groundwork.md +2 -2
- package/governance/rules/claude/557-intake.md +2 -2
- package/governance/rules/claude/558-plan.md +3 -3
- package/governance/rules/claude/559-memory.md +2 -2
- package/governance/rules/claude/560-diagrams.md +3 -3
- package/governance/rules/claude/561-teach.md +2 -2
- package/governance/rules/claude/562-session.md +2 -2
- package/governance/rules/core/035-tasks.md +2 -2
- package/governance/rules/core/045-memory.md +3 -3
- package/governance/rules/core/055-scratch.md +2 -1
- package/governance/rules/core/085-worktrees.md +1 -1
- package/package.json +1 -2
- package/scripts/core/check-ignore-parity.sh +24 -51
- package/scripts/core/install-check.sh +4 -4
- package/scripts/core/regen-hero.sh +6 -3
- package/scripts/core/snapshot.sh +1 -1
- package/scripts/manage-sandbox.sh +1 -1
- package/scripts/tooling/verify.sh +2 -2
- package/snippets/claude/decision-memo.md +1 -1
- package/snippets/claude/feature-recap.md +1 -1
- package/src/audits/catalog.ts +1 -1
- package/src/capture/render.ts +166 -0
- package/src/capture/sources.ts +42 -0
- package/src/capture/stamp.ts +52 -0
- package/src/claude/cases/all.ts +1 -1
- package/src/claude/seeds.ts +13 -2
- package/src/claude/skills-rank.ts +1 -1
- package/src/cli.ts +5 -5
- package/src/commands/capture.ts +52 -41
- package/src/commands/demo.ts +1 -1
- package/src/commands/design.ts +1 -1
- package/src/commands/feedback.ts +5 -5
- package/src/commands/gov.ts +17 -6
- package/src/commands/intake.ts +1 -1
- package/src/commands/migrate.ts +173 -2
- package/src/commands/pr.ts +1 -1
- package/src/commands/records.ts +1 -1
- package/src/commands/serve.ts +2 -2
- package/src/commands/slides.ts +1 -1
- package/src/commands/tasks.ts +5 -5
- package/src/commands/teach.ts +1 -1
- package/src/context/audit.ts +1 -1
- package/src/demo/compile.ts +1 -1
- package/src/demo/drive.ts +5 -8
- package/src/driver/drive.ts +7 -8
- package/src/gate/measures.ts +1 -1
- package/src/gate/stages.ts +1 -1
- package/src/gov/citations.ts +1 -1
- package/src/gov/restated.ts +3 -3
- package/src/inventory/walk.ts +3 -3
- package/src/migrate/apply.ts +67 -1
- package/src/migrate/records.ts +276 -0
- package/src/paths.ts +1 -1
- package/src/pr/paths.ts +1 -1
- package/src/record-root.ts +77 -27
- package/src/records/backup.ts +71 -17
- package/src/records/validate.ts +1 -1
- package/src/sync/layout.ts +11 -2
- package/src/tasks/answers.ts +1 -1
- package/src/tasks/archive.ts +20 -3
- package/standards/diagrams.md +4 -4
- package/standards/glossary.md +1 -1
- package/standards/groundwork.md +3 -3
- package/standards/index.md +2 -2
- package/standards/intake.md +3 -3
- package/standards/memory.md +2 -2
- package/standards/plan.md +3 -3
- package/standards/session.md +2 -2
- package/standards/standard.md +1 -1
- package/standards/tasks.md +9 -9
- package/standards/teach.md +2 -2
- package/standards/versioning.md +1 -1
- package/tooling/base/configs/.husky/post-merge +1 -1
- package/tooling/base/reference.md +1 -1
- package/tooling/claude/manifest.toml +13 -3
- package/tooling/claude/reference.md +28 -15
- package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +8 -8
|
@@ -9,22 +9,22 @@ GITIGNORE="$PROJECT_ROOT/.gitignore"
|
|
|
9
9
|
MANIFEST="$PROJECT_ROOT/tooling/claude/manifest.toml"
|
|
10
10
|
SECTION="# Claude"
|
|
11
11
|
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
12
|
+
# Parity is exact, with no exception list behind it, and the move to `.canon/`
|
|
13
|
+
# is what removed the one there was. Its two members each named a path inside
|
|
14
|
+
# `.claude/` that this repository ignored and the manifest withheld, so a target
|
|
15
|
+
# kept tracking it. A single root entry can withhold nothing inside itself: git
|
|
16
|
+
# does not descend into an excluded directory, so a re-inclusion like
|
|
17
|
+
# `!.canon/diagrams/` matches nothing. Retiring both is what buys the one line.
|
|
16
18
|
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
19
|
+
# The diagrams divergence is the one that cost something. A target used to track
|
|
20
|
+
# its diagrams and keep the context audit's default coverage of them, and it now
|
|
21
|
+
# ignores them with the rest of its records, so that coverage is gone unless the
|
|
22
|
+
# project names the folder itself. `.claude/context/tooling.md` carries the
|
|
23
|
+
# narrative.
|
|
20
24
|
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
SANCTIONED=(
|
|
25
|
-
".claude/diagrams|a target that tracks its diagrams keeps the context audit's default coverage of them, which ignoring the folder for every target would take away. This repository backs the folder through its records remote instead."
|
|
26
|
-
".claude/README.md|the landing page a records pull writes back into a repository that set up a records remote. A target has not, so it never receives the file."
|
|
27
|
-
)
|
|
25
|
+
# An exception mechanism is not kept against a future divergence, because an
|
|
26
|
+
# empty one cannot be exercised and a check nobody can test is a check nobody
|
|
27
|
+
# should trust. It comes back with its first real member or not at all.
|
|
28
28
|
|
|
29
29
|
if [ ! -f "$GITIGNORE" ]; then
|
|
30
30
|
echo "No .gitignore at ${GITIGNORE#"$PROJECT_ROOT/"}, ignore parity unverifiable." >&2
|
|
@@ -38,15 +38,15 @@ fi
|
|
|
38
38
|
|
|
39
39
|
# A gitignore pattern and a manifest entry describe the same folder whether or
|
|
40
40
|
# not either spells the trailing slash, so presence is compared with it dropped.
|
|
41
|
-
#
|
|
41
|
+
# No live pair differs that way now that both lists carry the same two entries,
|
|
42
|
+
# and the normalization stays because either side may be written without one.
|
|
42
43
|
normalize() {
|
|
43
44
|
printf '%s\n' "${1%/}"
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
# Every pattern the file carries, comments and blank lines dropped. The whole
|
|
47
|
-
# file rather than one header,
|
|
48
|
-
# its own
|
|
49
|
-
# list that carries it.
|
|
48
|
+
# file rather than one header, since a claude-scoped entry filed under a header
|
|
49
|
+
# of its own would otherwise read as missing from a list that carries it.
|
|
50
50
|
gitignore_patterns() {
|
|
51
51
|
awk '{ sub(/[[:space:]]+$/, "") } $0 ~ /^[[:space:]]*#/ || $0 == "" { next } { print }' "$GITIGNORE"
|
|
52
52
|
}
|
|
@@ -106,7 +106,6 @@ if [ ${#shipped[@]} -eq 0 ]; then
|
|
|
106
106
|
fi
|
|
107
107
|
|
|
108
108
|
failures=""
|
|
109
|
-
sanction_notes=""
|
|
110
109
|
|
|
111
110
|
# Entries the manifest ships that this repository does not ignore. A target is
|
|
112
111
|
# told to ignore a folder the toolkit itself tracks, which no decision sanctions,
|
|
@@ -121,48 +120,22 @@ done
|
|
|
121
120
|
# and says nothing about `node_modules/` or `.env`.
|
|
122
121
|
#
|
|
123
122
|
# `.canon` is read as a bare root as well as a prefix, since it is one line
|
|
124
|
-
# covering a whole tree where `.claude/`
|
|
125
|
-
#
|
|
126
|
-
#
|
|
127
|
-
#
|
|
123
|
+
# covering a whole tree where `.claude/` names a folder inside a root that also
|
|
124
|
+
# holds tracked content. Leaving it out is what would let a new entry sit outside
|
|
125
|
+
# the only stage comparing the two lists, which is the direction that goes
|
|
126
|
+
# silently blind.
|
|
128
127
|
for pattern in "${ignored[@]}"; do
|
|
129
128
|
case "$pattern" in
|
|
130
129
|
.claude/* | .canon | .canon/*) ;;
|
|
131
130
|
*) continue ;;
|
|
132
131
|
esac
|
|
133
132
|
contains "$pattern" "${shipped[@]}" && continue
|
|
134
|
-
|
|
135
|
-
matched=false
|
|
136
|
-
for sanction in "${SANCTIONED[@]}"; do
|
|
137
|
-
[ "${sanction%%|*}" = "$pattern" ] || continue
|
|
138
|
-
matched=true
|
|
139
|
-
sanction_notes="$sanction_notes $pattern stays out of the manifest: ${sanction#*|}"$'\n'
|
|
140
|
-
break
|
|
141
|
-
done
|
|
142
|
-
|
|
143
|
-
if [ "$matched" = false ]; then
|
|
144
|
-
failures="$failures $pattern is ignored here and absent from the manifest"$'\n'
|
|
145
|
-
fi
|
|
146
|
-
done
|
|
147
|
-
|
|
148
|
-
# A sanction naming a path that is no longer divergent, either because the
|
|
149
|
-
# manifest took it or because .gitignore dropped it.
|
|
150
|
-
for sanction in "${SANCTIONED[@]}"; do
|
|
151
|
-
entry="${sanction%%|*}"
|
|
152
|
-
if contains "$entry" "${ignored[@]}" && ! contains "$entry" "${shipped[@]}"; then
|
|
153
|
-
continue
|
|
154
|
-
fi
|
|
155
|
-
failures="$failures $entry is sanctioned as a divergence and is no longer one"$'\n'
|
|
133
|
+
failures="$failures $pattern is ignored here and absent from the manifest"$'\n'
|
|
156
134
|
done
|
|
157
135
|
|
|
158
136
|
if [ -n "$failures" ]; then
|
|
159
137
|
echo "The ignore set a target receives disagrees with this repository's own:" >&2
|
|
160
138
|
printf '%s' "$failures" >&2
|
|
161
|
-
echo "Add the entry to the \"$SECTION\" array in ${MANIFEST#"$PROJECT_ROOT/"} and to .gitignore
|
|
139
|
+
echo "Add the entry to the \"$SECTION\" array in ${MANIFEST#"$PROJECT_ROOT/"} and to .gitignore. The two lists are compared exactly, and nothing here records an exception." >&2
|
|
162
140
|
exit 1
|
|
163
141
|
fi
|
|
164
|
-
|
|
165
|
-
if [ -n "$sanction_notes" ]; then
|
|
166
|
-
echo "Sanctioned divergences from the ignore set a target receives:"
|
|
167
|
-
printf '%s' "$sanction_notes"
|
|
168
|
-
fi
|
|
@@ -7,7 +7,7 @@ PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
|
|
|
7
7
|
|
|
8
8
|
source "$PROJECT_ROOT/scripts/lib/ui.sh"
|
|
9
9
|
|
|
10
|
-
TMP_ROOT="$PROJECT_ROOT/.
|
|
10
|
+
TMP_ROOT="$PROJECT_ROOT/.canon/tmp/install-check"
|
|
11
11
|
CLONE_DIR="$TMP_ROOT/clone"
|
|
12
12
|
TARGET_DIR="$TMP_ROOT/target"
|
|
13
13
|
KEEP=0
|
|
@@ -20,10 +20,10 @@ for arg in "$@"; do
|
|
|
20
20
|
Usage: scripts/core/install-check.sh [--keep]
|
|
21
21
|
|
|
22
22
|
Verifies the README install path end to end:
|
|
23
|
-
1. Clones this repo into .
|
|
23
|
+
1. Clones this repo into .canon/tmp/install-check/clone
|
|
24
24
|
2. Runs bun install in the clone
|
|
25
25
|
3. Runs the CLI with --help to confirm it executes
|
|
26
|
-
4. Scaffolds a fresh project in .
|
|
26
|
+
4. Scaffolds a fresh project in .canon/tmp/install-check/target
|
|
27
27
|
5. Runs canon init and asserts a scaffold landed
|
|
28
28
|
|
|
29
29
|
Flags:
|
|
@@ -83,7 +83,7 @@ log_step "Assert scaffold"
|
|
|
83
83
|
# `@`-reference convention rule is the one snippets-domain file that still
|
|
84
84
|
# installs, since `base` carries `governance/rules/snippets/` as a
|
|
85
85
|
# folder-whole entry the same way it does `core` and `claude`.
|
|
86
|
-
for path in "CLAUDE.md" ".claude/wiki/index.md" ".claude" ".claude/context/index.md" ".claude/wireframes/index.md" ".
|
|
86
|
+
for path in "CLAUDE.md" ".claude/wiki/index.md" ".claude" ".claude/context/index.md" ".claude/wireframes/index.md" ".canon/diagrams/index.md" \
|
|
87
87
|
".prettierrc" ".editorconfig" ".lintstagedrc" ".husky/pre-commit" ".github/workflows/verify.yml" "scripts/verify.sh" \
|
|
88
88
|
".claude/rules/core/000-constitution.md" ".claude/rules/snippets/505-at-references.md"; do
|
|
89
89
|
if [ ! -e "$TARGET_DIR/$path" ]; then
|
|
@@ -4,15 +4,18 @@
|
|
|
4
4
|
# Only the HTML regenerates here. The PNG beside it is a chromium render whose
|
|
5
5
|
# bytes move with the browser version, so asserting it in verify.sh would fail
|
|
6
6
|
# on a machine whose chromium differs rather than on a stale count. Rebuild the
|
|
7
|
-
# image with `canon capture assets/hero.html` after this
|
|
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.
|
|
8
11
|
# That capture also writes assets/hero.stamp, which records the digest of the
|
|
9
12
|
# markup it rendered and is what the Hero stage compares, so all three files
|
|
10
13
|
# commit together. The frame carries no version. `package.json` is bumped on main by the release
|
|
11
14
|
# tooling, so embedding it drifts every open branch on the next release and the
|
|
12
15
|
# stage then fails for work that touched nothing.
|
|
13
16
|
#
|
|
14
|
-
# Clone-only. `
|
|
15
|
-
#
|
|
17
|
+
# Clone-only. `canon capture` ships now, but this script reads the repository's
|
|
18
|
+
# own catalogs, which a registry install does not carry.
|
|
16
19
|
set -e
|
|
17
20
|
set -o pipefail
|
|
18
21
|
|
package/scripts/core/snapshot.sh
CHANGED
|
@@ -10,7 +10,7 @@ source "$PROJECT_ROOT/scripts/lib/ui.sh"
|
|
|
10
10
|
|
|
11
11
|
NESTED="${VERIFY_NESTED:-false}"
|
|
12
12
|
|
|
13
|
-
OUTPUT_FILE=".
|
|
13
|
+
OUTPUT_FILE=".canon/tmp/project/PROJECT-SNAPSHOT.md"
|
|
14
14
|
|
|
15
15
|
check_dependencies() {
|
|
16
16
|
command -v find >/dev/null 2>&1 || log_error "find not installed"
|
|
@@ -16,7 +16,7 @@ show_help() {
|
|
|
16
16
|
echo -e "${GREY}│${NC} --keep ${GREY}# Keep tmp dir after run for inspection${NC}"
|
|
17
17
|
echo -e "${GREY}│${NC} -h, --help ${GREY}# Show this help message${NC}"
|
|
18
18
|
echo -e "${GREY}│${NC}"
|
|
19
|
-
echo -e "${GREY}│${NC} Scaffolds <stack> into .
|
|
19
|
+
echo -e "${GREY}│${NC} Scaffolds <stack> into .canon/tmp/verify-<stack>/, syncs the full"
|
|
20
20
|
echo -e "${GREY}│${NC} layer chain, and runs check + test:e2e + screenshot."
|
|
21
21
|
echo -e "${GREY}└${NC}"
|
|
22
22
|
exit 0
|
|
@@ -100,7 +100,7 @@ main() {
|
|
|
100
100
|
open_timeline "canon tooling verify $stack"
|
|
101
101
|
trap close_timeline EXIT
|
|
102
102
|
|
|
103
|
-
local tmp_root="$PROJECT_ROOT/.
|
|
103
|
+
local tmp_root="$PROJECT_ROOT/.canon/tmp"
|
|
104
104
|
local tmp_dir="$tmp_root/verify-$stack"
|
|
105
105
|
|
|
106
106
|
mkdir -p "$tmp_root"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Produce a decision memo for a research or should-we question. This is the decision-heavy counterpart to `claude-feature`, which plans a build. Write one file per independent concern under `.
|
|
1
|
+
Produce a decision memo for a research or should-we question. This is the decision-heavy counterpart to `claude-feature`, which plans a build. Write one file per independent concern under `.canon/plans/feature-<slug>.md`, or answer inline when the plan is small. Use the same plans folder as `claude-feature`.
|
|
2
2
|
|
|
3
3
|
Read the current state from the code and docs before recommending. Do not assume it.
|
|
4
4
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Recap the implementation this session completed. Read the plan file referenced in the session (usually `.
|
|
1
|
+
Recap the implementation this session completed. Read the plan file referenced in the session (usually `.canon/plans/feature-<slug>.md`) and produce a verification block.
|
|
2
2
|
|
|
3
3
|
Output this shape, nothing else:
|
|
4
4
|
|
package/src/audits/catalog.ts
CHANGED
|
@@ -488,7 +488,7 @@ export const AUDITS: readonly AuditSpec[] = [
|
|
|
488
488
|
corpus: 'tracked',
|
|
489
489
|
// The one reason this verb refuses for that is an absence rather than a
|
|
490
490
|
// break, on the same test the reach check and the skill audit take: no
|
|
491
|
-
// target adopts `.claude/context/`, `.
|
|
491
|
+
// target adopts `.claude/context/`, `.canon/diagrams/`, and
|
|
492
492
|
// `.claude/wireframes/` all at once, so without the allowance every such
|
|
493
493
|
// project reports the verb unmeasured on every run and never changes.
|
|
494
494
|
absentReasons: ['no-folders'] satisfies ContextAuditRefusal[],
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { basename, dirname } from 'node:path'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
import { chromium } from 'playwright-core'
|
|
5
|
+
import type { Browser, Page } from 'playwright-core'
|
|
6
|
+
import type { CaptureSource } from '@/capture/sources'
|
|
7
|
+
import { primaryFontFamily, resolveCaptureSources } from '@/capture/sources'
|
|
8
|
+
import { formatStamp, hashSource, stampPath } from '@/capture/stamp'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Every browser reference the capture command makes lives in this module, and
|
|
12
|
+
* `src/commands/capture.ts` reaches it through a dynamic import so `src/cli.ts`
|
|
13
|
+
* never resolves the engine at startup.
|
|
14
|
+
*
|
|
15
|
+
* It ships, like `@/demo/drive`, `@/inventory/walk`, and `@/driver/drive`.
|
|
16
|
+
* Regenerating this repository's own committed images was the reason it stayed
|
|
17
|
+
* behind, and it was a reason about one caller rather than about the mechanism:
|
|
18
|
+
* a target renders its own generated pages and proves its own fonts resolved,
|
|
19
|
+
* which is the whole of what this module does.
|
|
20
|
+
*
|
|
21
|
+
* It imports `playwright-core` rather than `@playwright/test` for the same
|
|
22
|
+
* reason the three siblings do. The test runner is a development dependency the
|
|
23
|
+
* published tarball never carries, so the earlier import resolved here and
|
|
24
|
+
* threw `ERR_MODULE_NOT_FOUND` in every target install.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const DEVICE_SCALE_FACTOR = 2
|
|
28
|
+
const FONT_PROBE_SIZE = 72
|
|
29
|
+
const FONT_PROBE_TEXT = 'canon capture 0123456789'
|
|
30
|
+
const ABSENT_FAMILY = '__canon_absent_family__'
|
|
31
|
+
|
|
32
|
+
export interface CaptureOptions {
|
|
33
|
+
selector: string
|
|
34
|
+
outDir?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type CaptureResult =
|
|
38
|
+
| {
|
|
39
|
+
status: 'rendered'
|
|
40
|
+
htmlPath: string
|
|
41
|
+
pngPath: string
|
|
42
|
+
width: number
|
|
43
|
+
height: number
|
|
44
|
+
}
|
|
45
|
+
| { status: 'failed'; htmlPath: string; reason: string }
|
|
46
|
+
|
|
47
|
+
export async function captureSources(
|
|
48
|
+
sourcePath: string,
|
|
49
|
+
options: CaptureOptions,
|
|
50
|
+
): Promise<CaptureResult[]> {
|
|
51
|
+
const sources = resolveCaptureSources(sourcePath, options.outDir)
|
|
52
|
+
if (!sources.length) return []
|
|
53
|
+
|
|
54
|
+
const browser = await chromium.launch()
|
|
55
|
+
try {
|
|
56
|
+
return await Promise.all(
|
|
57
|
+
sources.map((source) => captureOne(browser, source, options.selector)),
|
|
58
|
+
)
|
|
59
|
+
} finally {
|
|
60
|
+
await browser.close()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolves to a failed result rather than throwing, so one source that cannot
|
|
66
|
+
* render does not drop the rest of the batch.
|
|
67
|
+
*/
|
|
68
|
+
async function captureOne(
|
|
69
|
+
browser: Browser,
|
|
70
|
+
source: CaptureSource,
|
|
71
|
+
selector: string,
|
|
72
|
+
): Promise<CaptureResult> {
|
|
73
|
+
const page = await browser.newPage({
|
|
74
|
+
deviceScaleFactor: DEVICE_SCALE_FACTOR,
|
|
75
|
+
})
|
|
76
|
+
try {
|
|
77
|
+
await page.goto(pathToFileURL(source.htmlPath).href)
|
|
78
|
+
const element = page.locator(selector).first()
|
|
79
|
+
if ((await element.count()) === 0) {
|
|
80
|
+
return failed(source, `no element matched ${selector}`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
await page.evaluate(() => document.fonts.ready.then(() => undefined))
|
|
84
|
+
const family = primaryFontFamily(
|
|
85
|
+
await element.evaluate((node) => getComputedStyle(node).fontFamily),
|
|
86
|
+
)
|
|
87
|
+
if (family && !(await resolvesFont(page, family))) {
|
|
88
|
+
return failed(
|
|
89
|
+
source,
|
|
90
|
+
`${family} is not installed, so the capture would rewrap against a fallback`,
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
mkdirSync(dirname(source.pngPath), { recursive: true })
|
|
95
|
+
const png = await element.screenshot({ omitBackground: true })
|
|
96
|
+
writeFileSync(source.pngPath, png)
|
|
97
|
+
writeStamp(source, png)
|
|
98
|
+
return {
|
|
99
|
+
status: 'rendered',
|
|
100
|
+
htmlPath: source.htmlPath,
|
|
101
|
+
pngPath: source.pngPath,
|
|
102
|
+
width: png.readUInt32BE(16),
|
|
103
|
+
height: png.readUInt32BE(20),
|
|
104
|
+
}
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return failed(
|
|
107
|
+
source,
|
|
108
|
+
error instanceof Error ? error.message : String(error),
|
|
109
|
+
)
|
|
110
|
+
} finally {
|
|
111
|
+
await page.close()
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Runs inside the render rather than in a wrapper around it, so a capture
|
|
117
|
+
* cannot succeed and leave the provenance unrecorded. A throw here reaches the
|
|
118
|
+
* caller's catch and reports the source as failed, which is correct: a PNG
|
|
119
|
+
* whose stamp never landed is the state the verify stage exists to reject.
|
|
120
|
+
*
|
|
121
|
+
* The source is stored as a bare filename. An absolute path would record the
|
|
122
|
+
* machine that ran the capture into a tracked file and differ per checkout.
|
|
123
|
+
*
|
|
124
|
+
* The image digest is taken over the buffer the screenshot returned rather than
|
|
125
|
+
* by reading the file back, so the stamp describes the bytes this run wrote.
|
|
126
|
+
*/
|
|
127
|
+
function writeStamp(source: CaptureSource, png: Uint8Array): void {
|
|
128
|
+
writeFileSync(
|
|
129
|
+
stampPath(source.pngPath),
|
|
130
|
+
formatStamp({
|
|
131
|
+
source: basename(source.htmlPath),
|
|
132
|
+
sourceSha256: hashSource(readFileSync(source.htmlPath)),
|
|
133
|
+
imageSha256: hashSource(png),
|
|
134
|
+
}),
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function failed(source: CaptureSource, reason: string): CaptureResult {
|
|
139
|
+
return { status: 'failed', htmlPath: source.htmlPath, reason }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Compares the family's text metrics against a family that cannot exist. Equal
|
|
144
|
+
* widths mean the browser fell through to its default for both, which is the
|
|
145
|
+
* only signal available: `document.fonts.check` reports every system family as
|
|
146
|
+
* present, including invented ones.
|
|
147
|
+
*/
|
|
148
|
+
async function resolvesFont(page: Page, family: string): Promise<boolean> {
|
|
149
|
+
return page.evaluate(
|
|
150
|
+
({ family, absent, size, text }) => {
|
|
151
|
+
const context = document.createElement('canvas').getContext('2d')
|
|
152
|
+
if (!context) return false
|
|
153
|
+
const widthOf = (name: string): number => {
|
|
154
|
+
context.font = `${size}px "${name}"`
|
|
155
|
+
return context.measureText(text).width
|
|
156
|
+
}
|
|
157
|
+
return widthOf(family) !== widthOf(absent)
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
family,
|
|
161
|
+
absent: ABSENT_FAMILY,
|
|
162
|
+
size: FONT_PROBE_SIZE,
|
|
163
|
+
text: FONT_PROBE_TEXT,
|
|
164
|
+
},
|
|
165
|
+
)
|
|
166
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { basename, dirname, extname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export interface CaptureSource {
|
|
5
|
+
htmlPath: string
|
|
6
|
+
pngPath: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Pairs each HTML source with the PNG it renders to. A directory expands to
|
|
11
|
+
* every `.html` directly inside it, so adding a capture is a file drop rather
|
|
12
|
+
* than a flag. Omitting `outDir` writes the PNG beside its source.
|
|
13
|
+
*/
|
|
14
|
+
export function resolveCaptureSources(
|
|
15
|
+
sourcePath: string,
|
|
16
|
+
outDir?: string,
|
|
17
|
+
): CaptureSource[] {
|
|
18
|
+
const htmlPaths = statSync(sourcePath).isDirectory()
|
|
19
|
+
? readdirSync(sourcePath)
|
|
20
|
+
.filter((name) => extname(name) === '.html')
|
|
21
|
+
.sort()
|
|
22
|
+
.map((name) => join(sourcePath, name))
|
|
23
|
+
: [sourcePath]
|
|
24
|
+
|
|
25
|
+
return htmlPaths.map((htmlPath) => ({
|
|
26
|
+
htmlPath,
|
|
27
|
+
pngPath: join(
|
|
28
|
+
outDir ?? dirname(htmlPath),
|
|
29
|
+
`${basename(htmlPath, '.html')}.png`,
|
|
30
|
+
),
|
|
31
|
+
}))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Reads the first family from a computed `font-family` value, which is the font
|
|
36
|
+
* the source asks for and the one a capture has to prove resolved. Quoting is
|
|
37
|
+
* the source's choice, so both spellings arrive here.
|
|
38
|
+
*/
|
|
39
|
+
export function primaryFontFamily(declaration: string): string {
|
|
40
|
+
const first = declaration.split(',')[0]?.trim() ?? ''
|
|
41
|
+
return first.replace(/^['"]|['"]$/g, '')
|
|
42
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { basename, dirname, extname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const HASH_ALGORITHM = 'sha256'
|
|
5
|
+
const STAMP_EXTENSION = '.stamp'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Provenance for one rendered PNG: the markup it came from and a digest over
|
|
9
|
+
* each side of the pair. `assertStampField` in `src/gate/measures.ts` reads both
|
|
10
|
+
* digests back off the first whitespace-separated token, so the field names, the
|
|
11
|
+
* colon, and the space after it are a contract between the writer and the gate
|
|
12
|
+
* rather than formatting.
|
|
13
|
+
*
|
|
14
|
+
* Both sides are recorded because either can move alone. The markup digest
|
|
15
|
+
* catches an edit committed with no capture, and the image digest catches a PNG
|
|
16
|
+
* replaced under markup that never changed.
|
|
17
|
+
*/
|
|
18
|
+
export interface CaptureStamp {
|
|
19
|
+
readonly source: string
|
|
20
|
+
readonly sourceSha256: string
|
|
21
|
+
readonly imageSha256: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A capture writes its stamp beside its PNG rather than beside its HTML,
|
|
26
|
+
* because `--out` moves the image away from the source and the stamp answers a
|
|
27
|
+
* question about the image.
|
|
28
|
+
*/
|
|
29
|
+
export function stampPath(pngPath: string): string {
|
|
30
|
+
return join(
|
|
31
|
+
dirname(pngPath),
|
|
32
|
+
`${basename(pngPath, extname(pngPath))}${STAMP_EXTENSION}`,
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Digests the whole file rather than the counts inside it. A template edit
|
|
38
|
+
* changes what the image shows without moving any count, and the whole point of
|
|
39
|
+
* the stamp is that it needs no knowledge of what the markup renders.
|
|
40
|
+
*/
|
|
41
|
+
export function hashSource(bytes: Uint8Array): string {
|
|
42
|
+
return createHash(HASH_ALGORITHM).update(bytes).digest('hex')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function formatStamp(stamp: CaptureStamp): string {
|
|
46
|
+
return [
|
|
47
|
+
`source: ${stamp.source}`,
|
|
48
|
+
`source-sha256: ${stamp.sourceSha256}`,
|
|
49
|
+
`image-sha256: ${stamp.imageSha256}`,
|
|
50
|
+
'',
|
|
51
|
+
].join('\n')
|
|
52
|
+
}
|
package/src/claude/cases/all.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { SETUP_CASES } from '@/claude/cases/setup'
|
|
|
12
12
|
* Bootstrapped from each skill's own quoted trigger phrases and then
|
|
13
13
|
* rephrased and supplemented rather than shipped verbatim, since a positive
|
|
14
14
|
* lifted unchanged from a trigger passes by construction and never tests a
|
|
15
|
-
* person's own words. See `.
|
|
15
|
+
* person's own words. See `.canon/groundwork/42-ai-blueprint/08-spikes.md`
|
|
16
16
|
* for the extraction arm this corpus replaces as the shipped measure.
|
|
17
17
|
*/
|
|
18
18
|
export const SKILL_CASES: readonly SkillCase[] = [
|
package/src/claude/seeds.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'
|
|
|
2
2
|
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { dirname, join } from 'node:path'
|
|
4
4
|
import { copyPreservingMode } from '@/copy'
|
|
5
|
+
import { creationRel, isRecordEntry } from '@/record-root'
|
|
5
6
|
import { rewritesOnInstall, stripSeedMarker } from '@/seed-marker'
|
|
6
7
|
|
|
7
8
|
const SEEDS_DIR = join('tooling', 'claude', 'seeds')
|
|
@@ -81,13 +82,23 @@ export function planSeeds(root: string, target: string): SeedEntry[] {
|
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
for (const subdir of SUBDIRS) {
|
|
85
|
+
// The seed tree authors every subdirectory under `.claude/`, and three of
|
|
86
|
+
// them are record folders that install under the record root instead. A
|
|
87
|
+
// target that has not migrated resolves back to `.claude/`, so the same seed
|
|
88
|
+
// lands beside the records already there rather than opening a second root.
|
|
89
|
+
// Scaffolding one under `.claude/` now would also land it outside the single
|
|
90
|
+
// `.canon/` ignore entry a target receives, which tracks the memory pen.
|
|
91
|
+
const installRel = isRecordEntry(subdir)
|
|
92
|
+
? creationRel(target, subdir)
|
|
93
|
+
: join(CLAUDE_DIR, subdir)
|
|
94
|
+
|
|
84
95
|
for (const name of listLevel(join(source, subdir))) {
|
|
85
96
|
const rel = `${subdir}/${name}`
|
|
86
97
|
seeds.push({
|
|
87
98
|
src: join(source, subdir, name),
|
|
88
|
-
dest: join(
|
|
99
|
+
dest: join(target, installRel, name),
|
|
89
100
|
scanLabel: rel,
|
|
90
|
-
applyLabel: join(
|
|
101
|
+
applyLabel: join(installRel, name),
|
|
91
102
|
scope: 'claude',
|
|
92
103
|
executable: subdir === HOOKS,
|
|
93
104
|
})
|
|
@@ -8,7 +8,7 @@ import { listSkillsAt, resolveSkillsCorpus } from '@/claude/skills-list'
|
|
|
8
8
|
* asks whether the descriptions are separable by the words they use, and
|
|
9
9
|
* Claude Code does not route this way.
|
|
10
10
|
*
|
|
11
|
-
* Ported from `.
|
|
11
|
+
* Ported from `.canon/groundwork/42-ai-blueprint/evidence/rank.ts`, which
|
|
12
12
|
* ran once against this catalog and named the collisions this measure now
|
|
13
13
|
* tracks on a cadence.
|
|
14
14
|
*/
|
package/src/cli.ts
CHANGED
|
@@ -72,11 +72,11 @@ function showHelp(): void {
|
|
|
72
72
|
`${GREY}│${NC} demo [cmd] ${GREY}# Record a running app (compile, run)${NC}`,
|
|
73
73
|
`${GREY}│${NC} inventory [subj] ${GREY}# Report one computed property across every route${NC}`,
|
|
74
74
|
`${GREY}│${NC} drive <url> <run> ${GREY}# Walk a page through named interactions and measure each state${NC}`,
|
|
75
|
-
`${GREY}│${NC} feedback ${GREY}# Write toolkit feedback from stdin to .
|
|
75
|
+
`${GREY}│${NC} feedback ${GREY}# Write toolkit feedback from stdin to .canon/review/feedback/${NC}`,
|
|
76
76
|
`${GREY}│${NC} transcripts <url> ${GREY}# Fetch a YouTube transcript with metadata frontmatter${NC}`,
|
|
77
77
|
`${GREY}│${NC} tasks [cmd] ${GREY}# Task board commands (archive)${NC}`,
|
|
78
|
-
`${GREY}│${NC} intake [cmd] ${GREY}# Intake folders under .
|
|
79
|
-
`${GREY}│${NC} teach [cmd] ${GREY}# Learning workspaces under .
|
|
78
|
+
`${GREY}│${NC} intake [cmd] ${GREY}# Intake folders under .canon/intake/ (list, answer)${NC}`,
|
|
79
|
+
`${GREY}│${NC} teach [cmd] ${GREY}# Learning workspaces under .canon/teach/ (list, open, resource, glossary)${NC}`,
|
|
80
80
|
`${GREY}│${NC} comments [cmd] ${GREY}# Measure comment density and trend (scan)${NC}`,
|
|
81
81
|
`${GREY}│${NC} context [cmd] ${GREY}# Report context folder health (audit)${NC}`,
|
|
82
82
|
`${GREY}│${NC} markdown [cmd] ${GREY}# Report markdown against the attribute standards (audit)${NC}`,
|
|
@@ -120,8 +120,8 @@ function showHelp(): void {
|
|
|
120
120
|
`${GREY}│${NC} canon design render`,
|
|
121
121
|
`${GREY}│${NC} canon slides render`,
|
|
122
122
|
`${GREY}│${NC} canon slides list --json`,
|
|
123
|
-
`${GREY}│${NC} canon capture assets/install.html`,
|
|
124
|
-
`${GREY}│${NC} canon serve .
|
|
123
|
+
`${GREY}│${NC} canon capture assets/install.html --selector .window`,
|
|
124
|
+
`${GREY}│${NC} canon serve .canon/teach`,
|
|
125
125
|
`${GREY}│${NC} canon inventory focus --json`,
|
|
126
126
|
`${GREY}│${NC} canon drive http://localhost:4173 run.json --json`,
|
|
127
127
|
`${GREY}│${NC} pbpaste | canon feedback`,
|