@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/scripts/core/check-capability-seeding.sh +132 -0
- package/scripts/lib/sandbox-git.sh +8 -0
- package/scripts/manage-sandbox.sh +10 -3
- package/src/gate/stages.ts +14 -0
- package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +55 -0
- package/tooling/claude/seeds/.claude/settings.json +9 -0
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
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
|
}
|
package/src/gate/stages.ts
CHANGED
|
@@ -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
|
}
|