@erclx/canon 4.31.0 → 4.31.1

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.31.0",
4
+ "version": "4.31.1",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.31.0",
4
+ "version": "4.31.1",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env bash
2
+ set -e
3
+ set -o pipefail
4
+ shopt -s nullglob
5
+
6
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
+ PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
8
+
9
+ TOOLING_ROOT="$PROJECT_ROOT/tooling"
10
+ MARKER="canon-no-seed:"
11
+
12
+ # `find` writes to stderr and returns non-zero for a missing root, inside a
13
+ # process substitution whose status nothing reads. Without this the walk covers
14
+ # nothing and the check reports every capability seeded having never read one.
15
+ if [ ! -d "$TOOLING_ROOT" ]; then
16
+ echo "No tooling root at ${TOOLING_ROOT#"$PROJECT_ROOT/"}, capability seeding unverifiable." >&2
17
+ exit 1
18
+ fi
19
+
20
+ failures=""
21
+
22
+ # A comment naming the marker anywhere in the source file is the recorded
23
+ # reason a capability withholds itself from every destination it was compared
24
+ # against. The reason's own prose is free-form, so only the marker is tested.
25
+ has_reason() {
26
+ grep -q "$MARKER" "$1"
27
+ }
28
+
29
+ # Compares one source directory's files against the basenames already
30
+ # expanded from a set of destination paths. Presence on either side clears a
31
+ # name; a source file reaching neither the destination nor a marked reason is
32
+ # a capability answered on one side of the seed or config boundary and not
33
+ # the other.
34
+ check_capability() {
35
+ local label="$1" src_dir="$2"
36
+ shift 2
37
+
38
+ [ -d "$src_dir" ] || return 0
39
+
40
+ local dest_names=" "
41
+ local dest
42
+ for dest in "$@"; do
43
+ [ -f "$dest" ] || continue
44
+ dest_names="$dest_names$(basename "$dest") "
45
+ done
46
+
47
+ local src_file name
48
+ for src_file in "$src_dir"/*; do
49
+ [ -f "$src_file" ] || continue
50
+ name=$(basename "$src_file")
51
+ case "$dest_names" in
52
+ *" $name "*) continue ;;
53
+ esac
54
+ has_reason "$src_file" && continue
55
+ failures="$failures $label: ${src_file#"$PROJECT_ROOT/"} reaches no seed or config and carries no $MARKER reason"$'\n'
56
+ done
57
+ }
58
+
59
+ check_capability "Hooks" "$PROJECT_ROOT/.claude/hooks" \
60
+ "$PROJECT_ROOT"/tooling/claude/seeds/.claude/hooks/*
61
+
62
+ check_capability "Workflows" "$PROJECT_ROOT/.github/workflows" \
63
+ "$PROJECT_ROOT"/tooling/*/configs/.github/workflows/*
64
+
65
+ check_capability "Husky" "$PROJECT_ROOT/.husky" \
66
+ "$PROJECT_ROOT"/tooling/base/configs/.husky/*
67
+
68
+ # The reverse direction: a destination file whose source here is gone reaches
69
+ # neither check_capability above nor the wiring pass below, both of which read
70
+ # forward from the source, and it ships a target a hook, workflow, or husky
71
+ # script this repository has already deleted. A destination carrying the same
72
+ # marker clears just as a source does, which is the escape hatch a target-only
73
+ # capability needs, such as a stack-specific workflow with no root counterpart
74
+ # by design.
75
+ check_orphans() {
76
+ local label="$1" src_dir="$2" dest_dir="$3"
77
+
78
+ [ -d "$dest_dir" ] || return 0
79
+
80
+ local dest_file name
81
+ for dest_file in "$dest_dir"/*; do
82
+ [ -f "$dest_file" ] || continue
83
+ name=$(basename "$dest_file")
84
+ [ -f "$src_dir/$name" ] && continue
85
+ has_reason "$dest_file" && continue
86
+ failures="$failures $label: ${dest_file#"$PROJECT_ROOT/"} is seeded or configured with no source at ${src_dir#"$PROJECT_ROOT/"}/$name"$'\n'
87
+ done
88
+ }
89
+
90
+ check_orphans "Hooks" "$PROJECT_ROOT/.claude/hooks" \
91
+ "$PROJECT_ROOT/tooling/claude/seeds/.claude/hooks"
92
+
93
+ for dir in "$PROJECT_ROOT"/tooling/*/configs/.github/workflows; do
94
+ check_orphans "Workflows" "$PROJECT_ROOT/.github/workflows" "$dir"
95
+ done
96
+
97
+ check_orphans "Husky" "$PROJECT_ROOT/.husky" \
98
+ "$PROJECT_ROOT/tooling/base/configs/.husky"
99
+
100
+ # A hook that reached the seed tree with no wiring in the seeded settings.json
101
+ # is installed dead, which fails the same way an unseeded hook does.
102
+ SEED_HOOKS_DIR="$PROJECT_ROOT/tooling/claude/seeds/.claude/hooks"
103
+ SEED_SETTINGS="$PROJECT_ROOT/tooling/claude/seeds/.claude/settings.json"
104
+
105
+ if [ -d "$SEED_HOOKS_DIR" ]; then
106
+ if [ ! -f "$SEED_SETTINGS" ]; then
107
+ failures="$failures Seed settings: no settings.json at ${SEED_SETTINGS#"$PROJECT_ROOT/"} to confirm a seeded hook is wired"$'\n'
108
+ elif ! command -v jq >/dev/null 2>&1; then
109
+ failures="$failures Seed settings: jq is not installed, seeded-hook wiring unverifiable"$'\n'
110
+ else
111
+ # Every wired command as its own path segment set, rather than a substring
112
+ # search over the raw file, so a hook name that is a substring of another
113
+ # hook's filename (`log.sh` inside `pr-create-log.sh`) cannot pass on a
114
+ # neighbor's wiring.
115
+ wired=" $(jq -r '.. | .command? // empty' "$SEED_SETTINGS" | tr '/\n' ' ') "
116
+ for hook_file in "$SEED_HOOKS_DIR"/*.sh; do
117
+ [ -f "$hook_file" ] || continue
118
+ name=$(basename "$hook_file")
119
+ case "$wired" in
120
+ *" $name "*) continue ;;
121
+ esac
122
+ failures="$failures Seed settings: $name is seeded and wired into no command in ${SEED_SETTINGS#"$PROJECT_ROOT/"}"$'\n'
123
+ done
124
+ fi
125
+ fi
126
+
127
+ if [ -n "$failures" ]; then
128
+ echo "A capability reaches one side of the seed or config boundary and not the other:" >&2
129
+ printf '%s' "$failures" >&2
130
+ echo "Seed or configure the capability, or mark the source line with # $MARKER <reason>." >&2
131
+ exit 1
132
+ fi
@@ -112,6 +112,14 @@ ensure_sandbox_anchor_repo() {
112
112
  log_info "Created ${GITHUB_ORG}/${repo_name} as a private repository."
113
113
  }
114
114
 
115
+ # Mirrors the merge-base resolution the diff-baseline port already carries
116
+ # across five skill bodies, so a checkout whose local main trails origin/main
117
+ # does not pull in skill bodies other merged branches changed.
118
+ resolve_sandbox_skill_diff_base() {
119
+ git -C "$PROJECT_ROOT" merge-base HEAD origin/main 2>/dev/null ||
120
+ git -C "$PROJECT_ROOT" merge-base HEAD main 2>/dev/null
121
+ }
122
+
115
123
  # A remote is useless without an author, so the scenarios that reach one always
116
124
  # configure both. configure_sandbox_git_identity stays callable on its own for
117
125
  # the scenarios that never push. The probe runs first so an absent remote is
@@ -283,8 +283,12 @@ tag_sandbox_baseline() {
283
283
  }
284
284
 
285
285
  inject_changed_skills() {
286
+ local base
287
+ base=$(resolve_sandbox_skill_diff_base)
288
+ base="${base:-main}"
289
+
286
290
  local changed untracked
287
- changed=$(git -C "$PROJECT_ROOT" diff main --name-only -- 'claude/skills/**/SKILL.md' 2>/dev/null)
291
+ changed=$(git -C "$PROJECT_ROOT" diff "$base" --name-only -- 'claude/skills/**/SKILL.md' 2>/dev/null)
288
292
  untracked=$(git -C "$PROJECT_ROOT" ls-files --others --exclude-standard -- 'claude/skills/**/SKILL.md' 2>/dev/null)
289
293
 
290
294
  local combined
@@ -293,8 +297,8 @@ inject_changed_skills() {
293
297
  [ -z "$combined" ] && return
294
298
 
295
299
  while IFS= read -r skill_path; do
296
- # The diff against main lists a deleted skill alongside a changed one, and
297
- # there is nothing left to inject for a name this branch removed.
300
+ # The diff against the base lists a deleted skill alongside a changed one,
301
+ # and there is nothing left to inject for a name this branch removed.
298
302
  [ -f "$PROJECT_ROOT/$skill_path" ] || continue
299
303
 
300
304
  local skill_name
@@ -302,6 +306,9 @@ inject_changed_skills() {
302
306
  local target_dir="$SANDBOX/.claude/skills/$skill_name"
303
307
  mkdir -p "$target_dir"
304
308
  cp "$PROJECT_ROOT/$skill_path" "$target_dir/SKILL.md"
309
+ if [ -n "$SANDBOX_SKIP_AUTO_COMMIT" ]; then
310
+ echo ".claude/skills/$skill_name/SKILL.md" >>"$SANDBOX/.git/info/exclude"
311
+ fi
305
312
  log_info "Injected dev skill: $skill_name"
306
313
  done <<<"$combined"
307
314
  }
@@ -296,6 +296,20 @@ export const STAGES: readonly Stage[] = [
296
296
  ],
297
297
  success: 'Seed prose cites no toolkit CLI',
298
298
  },
299
+ {
300
+ // A hook, a workflow, or a husky script reaching one side of the seed or
301
+ // config boundary and not the other is a capability withheld with no
302
+ // recorded reason, per the criterion in .claude/ARCHITECTURE.md.
303
+ id: 'capability-seeding',
304
+ label: 'Capability seeding',
305
+ checks: [
306
+ script(
307
+ 'check-capability-seeding.sh',
308
+ 'A capability reaches one side of the seed or config boundary and not the other.',
309
+ ),
310
+ ],
311
+ success: 'Capability seeding clean',
312
+ },
299
313
  {
300
314
  // A stack entry naming a rule folder takes every rule in it, which is what
301
315
  // stops a new rule from needing a second edit to reach a target. The
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # Reminds a worker session of the pull-request announcement it owes and logs
4
+ # the creation as a denominator for the miss rate `claude-worker` cannot
5
+ # measure on its own. Follows path-form.sh's precedent: a PostToolUse hook
6
+ # returning hookSpecificOutput.additionalContext is the shipped route back
7
+ # into a session's own turn.
8
+ #
9
+ # The hook cannot know whether the announcement went out, only that a pull
10
+ # request now exists to announce. It states the obligation and counts the
11
+ # creation, and claims nothing about the send.
12
+
13
+ # Claude Code sends a payload and closes stdin. A bare read with nothing
14
+ # feeding it blocks forever and holds the session open, so the read is
15
+ # bounded. `read` rather than `timeout cat`, which macOS does not ship.
16
+ IFS= read -r -d '' -t 2 input
17
+ [ -n "$input" ] || {
18
+ printf '%s reads a Claude Code hook payload on stdin and cannot be run by hand.\n' "${0##*/}" >&2
19
+ exit 1
20
+ }
21
+
22
+ command=$(printf '%s' "$input" | jq -r '.tool_input.command // empty')
23
+ case "$command" in
24
+ *"gh pr create"*) ;;
25
+ *) exit 0 ;;
26
+ esac
27
+
28
+ # gh pr create prints the new pull request's URL to stdout on success, so
29
+ # matching it is what tells a creation apart from a failed or refused call.
30
+ stdout=$(printf '%s' "$input" | jq -r '.tool_response.stdout // empty')
31
+ url=$(printf '%s' "$stdout" | grep -Eo 'https://github\.com/[^[:space:]]+/pull/[0-9]+' | tail -1)
32
+ [ -n "$url" ] || exit 0
33
+
34
+ # CLAUDE_PROJECT_DIR is the session's own worktree rather than the main root,
35
+ # so a worker building in a linked worktree would log into a folder that dies
36
+ # with the worktree. The log is a denominator across a wave rather than a
37
+ # per-session record, so strip back to the main root the way tasks-index.sh
38
+ # and memory-index.sh already derive theirs, off a path suffix.
39
+ root="${CLAUDE_PROJECT_DIR:-.}"
40
+ case "$root" in
41
+ */.claude/worktrees/*) root="${root%/.claude/worktrees/*}" ;;
42
+ esac
43
+ # The log is scratch, so it follows the scratch folder to whichever record root
44
+ # the project carries rather than creating a second one beside it.
45
+ if [ -d "$root/.canon" ]; then
46
+ log_dir="$root/.canon/tmp/pr-create-log"
47
+ else
48
+ log_dir="$root/.claude/.tmp/pr-create-log"
49
+ fi
50
+ mkdir -p "$log_dir"
51
+ session=$(printf '%s' "$input" | jq -r '.session_id // "unknown"')
52
+ printf -- '- %s session=%s pr=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$session" "$url" >>"$log_dir/log.md"
53
+
54
+ msg="Pull request $url just opened. If this session holds a worker's channel obligation, announce it to the controller now, per claude-worker."
55
+ jq -nc --arg msg "$msg" '{hookSpecificOutput:{hookEventName:"PostToolUse",additionalContext:$msg}}'
@@ -41,6 +41,15 @@
41
41
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/path-form.sh"
42
42
  }
43
43
  ]
44
+ },
45
+ {
46
+ "matcher": "Bash",
47
+ "hooks": [
48
+ {
49
+ "type": "command",
50
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/pr-create-log.sh"
51
+ }
52
+ ]
44
53
  }
45
54
  ]
46
55
  }