@erclx/canon 4.31.0 → 4.32.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/claude-orchestrate/references/orchestrator-handoff.md +3 -1
- package/docs/agents/worktrees.md +5 -2
- 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/client-commands.ts +99 -0
- package/src/commands/worktrees.ts +24 -13
- package/src/exempt-marker.ts +1 -1
- package/src/gate/measures.ts +70 -0
- package/src/gate/stages.ts +22 -0
- package/src/worktrees/reclaim.ts +95 -25
- package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +55 -0
- package/tooling/claude/seeds/.claude/settings.json +9 -0
|
@@ -17,6 +17,8 @@ Name the planner as the case always in this set, and scope the act no narrower.
|
|
|
17
17
|
|
|
18
18
|
Retire what this session dispatched. Report a hand-launched session and leave the act to the operator, since the knowledge making the act safe is a delivery this session received and it received nothing from a session it never launched. The `worker-` and `planner-` name prefixes separate the two populations, per `orchestrator-dispatch.md`.
|
|
19
19
|
|
|
20
|
+
A capability gap sits beside the knowledge gap, and it holds even where delivery is somehow known. A hand-launched session is as often interactive as background, and `claude rm <id>` has no id to take for an interactive one at all, so the command cannot reach it whatever this session learned about its delivery.
|
|
21
|
+
|
|
20
22
|
### Telling a delivered session from a blocked one
|
|
21
23
|
|
|
22
24
|
No status field carries the difference. `status` reads `busy` or `waiting` straight off the client record, and both describe the last turn rather than the work, so `waiting` is the ordinary answer for a session that reported and stopped, which is what a correct delivery looks like. The `statusDwellMs` beside it separates a long idle from a short one and separates nothing else.
|
|
@@ -25,7 +27,7 @@ The read is the report this session is holding. Retire a session whose delivery
|
|
|
25
27
|
|
|
26
28
|
### The act, and the order it runs in
|
|
27
29
|
|
|
28
|
-
`claude rm <id>`
|
|
30
|
+
`claude rm <id>` is meant to remove a background session and its worktree together, though its own report is not reliable, so `canon worktrees list` is the reading to trust afterward. It takes one target per call, so a wave costs one call per session. The argument is the id rather than the name, which `claude agents --json` carries beside the name on every row, so a session read off a roster by name is matched to its id there before the call. Both readings are from 2026-09-02, where the name form answered `No job matching`.
|
|
29
31
|
|
|
30
32
|
The command belongs to the client rather than to this toolkit, so a target running another client performs whatever removal that client offers.
|
|
31
33
|
|
package/docs/agents/worktrees.md
CHANGED
|
@@ -89,11 +89,14 @@ The read is one `gh pr list --state merged` for the whole repository rather than
|
|
|
89
89
|
|
|
90
90
|
`route` names which one applies rather than choosing it, since picking wrong strands state.
|
|
91
91
|
|
|
92
|
-
- `session`: a live session holds the directory, and
|
|
92
|
+
- `session`: a live session holds the directory, and the `sessions` field carries `{name, kind, id}` per holder rather than a bare name, since `claude rm <id>` takes the id and never the name. `kind` reads `bg` or `interactive` off this repository's session registry.
|
|
93
|
+
- A resolved background holder: `id` carries the value `claude agents --json` reports, so `claude rm <id>` is the command to run. Its own report is not reliable: two runs under matched conditions on 2026-09-03 both printed `kept`, and one of them had already removed the worktree, so read `canon worktrees list` again afterward rather than trust what the command printed.
|
|
94
|
+
- An unresolved background holder: `id` is `null` because the binary is missing, fails, or has not reported that pid yet, so its id has to come from a manual read of `claude agents --json` instead.
|
|
95
|
+
- An interactive holder: `id` is always `null`, since no id exists for one at all, and nothing removes it until a person closes its terminal.
|
|
93
96
|
- `worktree`: the session has ended, and `canon worktrees reclaim` is what runs the remove and the branch delete.
|
|
94
97
|
- `null`: the main worktree, which no removal shape reaches.
|
|
95
98
|
|
|
96
|
-
A held worktree is refused rather than reported reclaimable, and its route is reported for whoever decides to act on it. Deleting a directory underneath a live session is the case that has to refuse.
|
|
99
|
+
A held worktree is refused rather than reported reclaimable, and its route is reported for whoever decides to act on it. Deleting a directory underneath a live session is the case that has to refuse, and an interactive holder is the one shape inside that refusal with no removal route at all.
|
|
97
100
|
|
|
98
101
|
## What an unreadable input does
|
|
99
102
|
|
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
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { escapeForPattern, isMarked } from '@/exempt-marker'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A client command this repository quotes, with the argument spelling every
|
|
5
|
+
* quotation of it has to carry.
|
|
6
|
+
*
|
|
7
|
+
* `source` is the fact grounding `canonicalArgument`, so a reader correcting a
|
|
8
|
+
* flagged line can see why the flagged form is wrong rather than taking the
|
|
9
|
+
* table on faith.
|
|
10
|
+
*/
|
|
11
|
+
export interface ClientCommand {
|
|
12
|
+
readonly command: string
|
|
13
|
+
readonly canonicalArgument: string
|
|
14
|
+
readonly source: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const CLIENT_COMMANDS: readonly ClientCommand[] = [
|
|
18
|
+
{
|
|
19
|
+
command: 'claude rm',
|
|
20
|
+
canonicalArgument: 'id',
|
|
21
|
+
source:
|
|
22
|
+
'`claude rm <id>` takes the session id `claude agents --json` carries beside a name, never the name itself, confirmed against `claude rm --help` on 2026-09-02.',
|
|
23
|
+
},
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
export const CLIENT_COMMAND_MARKER = 'canon-allow-client-command'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A bracketed placeholder or a template interpolation, each optionally
|
|
30
|
+
* single-quoted, which is the one call form this scanner reads. A sentence
|
|
31
|
+
* naming the command with no bracketed argument carries no placeholder at all
|
|
32
|
+
* and never matches, which is deliberate: the defect this closes is a wrong
|
|
33
|
+
* argument shown as an invocation, not a sentence that never showed one.
|
|
34
|
+
*
|
|
35
|
+
* The interpolation half admits a dotted path, since a property access is the
|
|
36
|
+
* ordinary shape a real call site interpolates, such as `${session.id}`, and a
|
|
37
|
+
* bare `${id}` is the rarer one that only a fixture writes. A capture stopping
|
|
38
|
+
* at the first `.` matched the command literal and missed the property access
|
|
39
|
+
* beside it entirely, which is how the check went unable to read the one call
|
|
40
|
+
* form this repository's own worktree listing writes.
|
|
41
|
+
*/
|
|
42
|
+
const PLACEHOLDER = `'?(?:<([\\w-]+)>|\\$\\{(\\w+(?:\\.\\w+)*)\\})'?`
|
|
43
|
+
|
|
44
|
+
export interface ClientCommandCitation {
|
|
45
|
+
readonly file: string
|
|
46
|
+
/** One-based, matching the `file:line` form a reader clicks. */
|
|
47
|
+
readonly line: number
|
|
48
|
+
readonly command: string
|
|
49
|
+
/** The invocation as written, so a report names the text to correct. */
|
|
50
|
+
readonly text: string
|
|
51
|
+
readonly argument: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Every quotation of a listed command in one file whose argument disagrees
|
|
56
|
+
* with the table, and no marker mutes.
|
|
57
|
+
*
|
|
58
|
+
* `commands` defaults to the shipped table and takes a narrower one only for
|
|
59
|
+
* the empty-table case a measure guards against, since production code never
|
|
60
|
+
* has a reason to check against anything else.
|
|
61
|
+
*/
|
|
62
|
+
export function clientCommandCitationsIn(
|
|
63
|
+
file: string,
|
|
64
|
+
text: string,
|
|
65
|
+
commands: readonly ClientCommand[] = CLIENT_COMMANDS,
|
|
66
|
+
): ClientCommandCitation[] {
|
|
67
|
+
const lines = text.split('\n')
|
|
68
|
+
const citations: ClientCommandCitation[] = []
|
|
69
|
+
|
|
70
|
+
for (const { command, canonicalArgument } of commands) {
|
|
71
|
+
const pattern = new RegExp(
|
|
72
|
+
`${escapeForPattern(command)}\\s+${PLACEHOLDER}`,
|
|
73
|
+
'g',
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
for (const [index, line] of lines.entries()) {
|
|
77
|
+
if (isMarked(lines, index, CLIENT_COMMAND_MARKER)) continue
|
|
78
|
+
|
|
79
|
+
for (const match of line.matchAll(pattern)) {
|
|
80
|
+
const dotted = match[2]
|
|
81
|
+
const argument =
|
|
82
|
+
dotted !== undefined
|
|
83
|
+
? (dotted.split('.').at(-1) ?? dotted)
|
|
84
|
+
: (match[1] ?? '')
|
|
85
|
+
if (argument === canonicalArgument) continue
|
|
86
|
+
|
|
87
|
+
citations.push({
|
|
88
|
+
file,
|
|
89
|
+
line: index + 1,
|
|
90
|
+
command,
|
|
91
|
+
text: match[0],
|
|
92
|
+
argument,
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return citations
|
|
99
|
+
}
|
|
@@ -145,10 +145,12 @@ export function register(program: Command): void {
|
|
|
145
145
|
...CONDITIONS,
|
|
146
146
|
'',
|
|
147
147
|
'This reports and removes nothing. "route" names which removal shape',
|
|
148
|
-
'applies: "session" when a live session holds the directory,
|
|
149
|
-
'`claude rm <id>`
|
|
150
|
-
'
|
|
151
|
-
'
|
|
148
|
+
'applies: "session" when a live session holds the directory,',
|
|
149
|
+
'where `claude rm <id>` is meant to remove it and its worktree',
|
|
150
|
+
'together, but its own report is not reliable, so run this command',
|
|
151
|
+
'again afterward to see what happened. "worktree" is the shape once',
|
|
152
|
+
'the session has ended, where `canon worktrees reclaim` is the pair',
|
|
153
|
+
'of a remove and a branch delete.',
|
|
152
154
|
'',
|
|
153
155
|
'Examples:',
|
|
154
156
|
' canon worktrees list',
|
|
@@ -185,8 +187,10 @@ export function register(program: Command): void {
|
|
|
185
187
|
'gone sweeps stale registrations once before the branch deletes.',
|
|
186
188
|
'',
|
|
187
189
|
'It deletes only what a reading called reclaimable. A worktree a live',
|
|
188
|
-
'session holds is refused rather than removed
|
|
189
|
-
'is
|
|
190
|
+
'session holds is refused rather than removed here. `claude rm <id>`',
|
|
191
|
+
'is meant to remove the session and its worktree together, but its',
|
|
192
|
+
'own report is not reliable, so run `canon worktrees list` again',
|
|
193
|
+
'afterward to see what happened.',
|
|
190
194
|
'',
|
|
191
195
|
'The stale-registration sweep is the one step that reaches wider, since',
|
|
192
196
|
'git takes no path to scope it. It clears the bookkeeping for every',
|
|
@@ -429,20 +433,27 @@ function reportWorktrees(worktrees: readonly WorktreeVerdict[]): void {
|
|
|
429
433
|
* what to remove, so a row phrased for the first alone leaves the second
|
|
430
434
|
* removing a directory on a conclusion it cannot check.
|
|
431
435
|
*/
|
|
432
|
-
function describe(verdict: WorktreeVerdict): string {
|
|
436
|
+
export function describe(verdict: WorktreeVerdict): string {
|
|
433
437
|
const held = verdict.branch ?? 'detached'
|
|
434
438
|
const head = `${verdict.path} ${held}`
|
|
435
439
|
|
|
436
440
|
if (!verdict.reclaimable) {
|
|
437
441
|
const reasons = verdict.refusals.map((refusal) => REFUSALS[refusal])
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
442
|
+
// Three cases rather than one line, since only a resolved background
|
|
443
|
+
// holder has a real command to print. An interactive holder has no id to
|
|
444
|
+
// look up at all, and an unresolved background one has an id somewhere in
|
|
445
|
+
// `claude agents --json` that this reading could not cross-reference.
|
|
441
446
|
const routes =
|
|
442
447
|
verdict.route === 'session'
|
|
443
|
-
? verdict.sessions.map(
|
|
444
|
-
(
|
|
445
|
-
|
|
448
|
+
? verdict.sessions.map((session) => {
|
|
449
|
+
if (session.kind === 'interactive') {
|
|
450
|
+
return `\n ${session.name}: interactive, so nothing removes it until its terminal closes.`
|
|
451
|
+
}
|
|
452
|
+
if (session.id === null) {
|
|
453
|
+
return `\n ${session.name}: match it against claude agents --json for its id, then claude rm '<id>'.`
|
|
454
|
+
}
|
|
455
|
+
return `\n Removal there goes through: claude rm '${session.id}'`
|
|
456
|
+
})
|
|
446
457
|
: []
|
|
447
458
|
return `${head}\n Refused: ${reasons.join('; ')}.${routes.join('')}`
|
|
448
459
|
}
|
package/src/exempt-marker.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* wrong lines and one holding a parenthesis throws, and neither failure is the
|
|
8
8
|
* caller's to anticipate.
|
|
9
9
|
*/
|
|
10
|
-
function escapeForPattern(token: string): string {
|
|
10
|
+
export function escapeForPattern(token: string): string {
|
|
11
11
|
return token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
12
12
|
}
|
|
13
13
|
|
package/src/gate/measures.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
|
+
import {
|
|
5
|
+
CLIENT_COMMAND_MARKER,
|
|
6
|
+
CLIENT_COMMANDS,
|
|
7
|
+
type ClientCommand,
|
|
8
|
+
clientCommandCitationsIn,
|
|
9
|
+
} from '@/client-commands'
|
|
10
|
+
import { listRepositoryFiles } from '@/git-files'
|
|
4
11
|
import {
|
|
5
12
|
isShippedCorpus,
|
|
6
13
|
REFERENCE_MARKER,
|
|
@@ -532,6 +539,69 @@ export const shippedReferences: Measure = async (ctx) => {
|
|
|
532
539
|
}
|
|
533
540
|
}
|
|
534
541
|
|
|
542
|
+
/**
|
|
543
|
+
* Every git-tracked file quoting a listed client command with the wrong
|
|
544
|
+
* argument, over the whole tree rather than one corpus, since a wrong
|
|
545
|
+
* quotation can land in any file this repository writes.
|
|
546
|
+
*
|
|
547
|
+
* `commands` defaults to the shipped table and takes a narrower one only to
|
|
548
|
+
* cover the case where that table ships empty, which is a broken check rather
|
|
549
|
+
* than a clean tree, exactly as an empty ban set is for `markdownBans`.
|
|
550
|
+
*/
|
|
551
|
+
export const clientCommandCitations = async (
|
|
552
|
+
ctx: MeasureContext,
|
|
553
|
+
commands: readonly ClientCommand[] = CLIENT_COMMANDS,
|
|
554
|
+
): Promise<MeasureReport> => {
|
|
555
|
+
if (commands.length === 0) {
|
|
556
|
+
return {
|
|
557
|
+
emissions: [],
|
|
558
|
+
failure:
|
|
559
|
+
'The client command table is empty, so the corpus was walked and nothing was looked for. Check src/client-commands.ts.',
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const files = await listRepositoryFiles(ctx.root)
|
|
564
|
+
if (files === undefined) {
|
|
565
|
+
return {
|
|
566
|
+
emissions: [],
|
|
567
|
+
unmeasured:
|
|
568
|
+
'The tracked file list could not be read, so no client command citation was checked.',
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const found = files.flatMap((file) => {
|
|
573
|
+
let text: string
|
|
574
|
+
try {
|
|
575
|
+
text = readFileSync(join(ctx.root, file), 'utf8')
|
|
576
|
+
} catch {
|
|
577
|
+
return []
|
|
578
|
+
}
|
|
579
|
+
return clientCommandCitationsIn(file, text, commands)
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
if (found.length === 0) {
|
|
583
|
+
return {
|
|
584
|
+
emissions: [
|
|
585
|
+
info(
|
|
586
|
+
`No client command carries a wrong argument across ${files.length} tracked files`,
|
|
587
|
+
),
|
|
588
|
+
],
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return {
|
|
593
|
+
emissions: found.map((citation) =>
|
|
594
|
+
warn(
|
|
595
|
+
`${citation.file}:${citation.line} carries ${citation.text}, which quotes \`${citation.command}\` with the wrong argument`,
|
|
596
|
+
),
|
|
597
|
+
),
|
|
598
|
+
failure:
|
|
599
|
+
found.length === 1
|
|
600
|
+
? `One tracked citation quotes a client command with the wrong argument. Match it against the canonical form in src/client-commands.ts, or mark the line ${CLIENT_COMMAND_MARKER}: <reason> where the argument differs on purpose.`
|
|
601
|
+
: `${found.length} tracked citations quote a client command with the wrong argument. Match each against the canonical form in src/client-commands.ts, or mark the line ${CLIENT_COMMAND_MARKER}: <reason> where the argument differs on purpose.`,
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
535
605
|
/**
|
|
536
606
|
* `canon sandbox coverage` moves only when a person runs it, so a scenario added
|
|
537
607
|
* with no expectation ships unnoticed.
|
package/src/gate/stages.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
auditSet,
|
|
3
3
|
captureStamps,
|
|
4
|
+
clientCommandCitations,
|
|
4
5
|
markdownBans,
|
|
5
6
|
type Measure,
|
|
6
7
|
pluginManifests,
|
|
@@ -296,6 +297,20 @@ export const STAGES: readonly Stage[] = [
|
|
|
296
297
|
],
|
|
297
298
|
success: 'Seed prose cites no toolkit CLI',
|
|
298
299
|
},
|
|
300
|
+
{
|
|
301
|
+
// A hook, a workflow, or a husky script reaching one side of the seed or
|
|
302
|
+
// config boundary and not the other is a capability withheld with no
|
|
303
|
+
// recorded reason, per the criterion in .claude/ARCHITECTURE.md.
|
|
304
|
+
id: 'capability-seeding',
|
|
305
|
+
label: 'Capability seeding',
|
|
306
|
+
checks: [
|
|
307
|
+
script(
|
|
308
|
+
'check-capability-seeding.sh',
|
|
309
|
+
'A capability reaches one side of the seed or config boundary and not the other.',
|
|
310
|
+
),
|
|
311
|
+
],
|
|
312
|
+
success: 'Capability seeding clean',
|
|
313
|
+
},
|
|
299
314
|
{
|
|
300
315
|
// A stack entry naming a rule folder takes every rule in it, which is what
|
|
301
316
|
// stops a new rule from needing a second edit to reach a target. The
|
|
@@ -356,6 +371,13 @@ export const STAGES: readonly Stage[] = [
|
|
|
356
371
|
label: 'Markdown bans',
|
|
357
372
|
checks: [{ kind: 'measure', measure: markdownBans }],
|
|
358
373
|
},
|
|
374
|
+
{
|
|
375
|
+
// Unscoped, since a wrong quotation of a client command can land in any
|
|
376
|
+
// tracked file rather than in one corpus.
|
|
377
|
+
id: 'client-command-citations',
|
|
378
|
+
label: 'Client command citations',
|
|
379
|
+
checks: [{ kind: 'measure', measure: clientCommandCitations }],
|
|
380
|
+
},
|
|
359
381
|
{
|
|
360
382
|
// Scoped to the corpora it reads rather than run unconditionally, so a
|
|
361
383
|
// branch touching only `src/` or `.claude/` skips it and says so. The
|
package/src/worktrees/reclaim.ts
CHANGED
|
@@ -16,6 +16,9 @@ import {
|
|
|
16
16
|
|
|
17
17
|
const GH_TIMEOUT_MS = 30_000
|
|
18
18
|
|
|
19
|
+
/** A local binary read, not a network round trip, so the bound is tighter. */
|
|
20
|
+
const CLAUDE_AGENTS_TIMEOUT_MS = 10_000
|
|
21
|
+
|
|
19
22
|
/**
|
|
20
23
|
* How many merged pull requests one read covers. A worktree older than this
|
|
21
24
|
* many merges reads as having none and is refused, which is the safe direction:
|
|
@@ -47,6 +50,21 @@ export type Route = 'session' | 'worktree'
|
|
|
47
50
|
/** No removal shape reaches the main worktree, which is what `null` says. */
|
|
48
51
|
export type RemovalRoute = Route | null
|
|
49
52
|
|
|
53
|
+
/**
|
|
54
|
+
* One live session holding a worktree, enriched with the id `claude rm` takes.
|
|
55
|
+
*
|
|
56
|
+
* `id` is null on two occasions a caller cannot tell apart from the name
|
|
57
|
+
* alone: an interactive holder, which carries no id at all since nothing but a
|
|
58
|
+
* person closing its terminal removes it, and a background holder whose id
|
|
59
|
+
* `claude agents --json` could not resolve, whether the binary is missing,
|
|
60
|
+
* failing, or simply has not reported that pid.
|
|
61
|
+
*/
|
|
62
|
+
export interface HeldSession {
|
|
63
|
+
readonly name: string
|
|
64
|
+
readonly kind: string
|
|
65
|
+
readonly id: string | null
|
|
66
|
+
}
|
|
67
|
+
|
|
50
68
|
export interface WorktreeVerdict {
|
|
51
69
|
readonly path: string
|
|
52
70
|
readonly branch: string | null
|
|
@@ -55,12 +73,8 @@ export interface WorktreeVerdict {
|
|
|
55
73
|
readonly refusals: readonly Refusal[]
|
|
56
74
|
/** The pull request that retired the branch, so a report can name what it read. */
|
|
57
75
|
readonly pullRequest: number | null
|
|
58
|
-
/**
|
|
59
|
-
|
|
60
|
-
* id rather than a name, so a caller acting on this matches each name to the
|
|
61
|
-
* id `claude agents --json` carries beside it.
|
|
62
|
-
*/
|
|
63
|
-
readonly sessions: readonly string[]
|
|
76
|
+
/** The live sessions holding this worktree. */
|
|
77
|
+
readonly sessions: readonly HeldSession[]
|
|
64
78
|
readonly route: RemovalRoute
|
|
65
79
|
/**
|
|
66
80
|
* True when the directory is already gone and only the registration remains,
|
|
@@ -107,6 +121,9 @@ export interface ReclaimOptions {
|
|
|
107
121
|
readonly mergedPullRequests?: (cwd: string) => Promise<MergedReport>
|
|
108
122
|
readonly worktreeStatus?: (path: string) => Promise<StatusReport>
|
|
109
123
|
readonly resolve?: () => Promise<SessionReport>
|
|
124
|
+
readonly claudeAgentIds?: (
|
|
125
|
+
cwd: string,
|
|
126
|
+
) => Promise<ReadonlyMap<number, string>>
|
|
110
127
|
}
|
|
111
128
|
|
|
112
129
|
/**
|
|
@@ -170,6 +187,45 @@ async function mergedPullRequests(cwd: string): Promise<MergedReport> {
|
|
|
170
187
|
}
|
|
171
188
|
}
|
|
172
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Reads every live agent's session id, keyed by its process id, from `claude
|
|
192
|
+
* agents --json`.
|
|
193
|
+
*
|
|
194
|
+
* Guarded like `mergedPullRequests`, reading the same way, but degrading
|
|
195
|
+
* rather than refusing: the id enriches a caller's report and is never an
|
|
196
|
+
* input the reclaim logic depends on, where the merge state decides whether a
|
|
197
|
+
* removal is safe at all. A missing or failing binary leaves every entry
|
|
198
|
+
* unresolved instead of refusing the whole reading.
|
|
199
|
+
*/
|
|
200
|
+
async function claudeAgentIds(
|
|
201
|
+
cwd: string,
|
|
202
|
+
): Promise<ReadonlyMap<number, string>> {
|
|
203
|
+
if (Bun.which('claude') === null) return new Map()
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const result = await execa('claude', ['agents', '--json'], {
|
|
207
|
+
cwd,
|
|
208
|
+
timeout: CLAUDE_AGENTS_TIMEOUT_MS,
|
|
209
|
+
env: gitEnv(),
|
|
210
|
+
extendEnv: false,
|
|
211
|
+
})
|
|
212
|
+
const rows = JSON.parse(result.stdout) as readonly {
|
|
213
|
+
pid?: unknown
|
|
214
|
+
id?: unknown
|
|
215
|
+
}[]
|
|
216
|
+
|
|
217
|
+
const ids = new Map<number, string>()
|
|
218
|
+
for (const row of rows) {
|
|
219
|
+
if (typeof row.pid === 'number' && typeof row.id === 'string') {
|
|
220
|
+
ids.set(row.pid, row.id)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return ids
|
|
224
|
+
} catch {
|
|
225
|
+
return new Map()
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
173
229
|
/**
|
|
174
230
|
* Reports whether a worktree holds work no history is behind.
|
|
175
231
|
*
|
|
@@ -214,16 +270,14 @@ function holders(
|
|
|
214
270
|
entry: WorktreeEntry,
|
|
215
271
|
sessions: readonly ResolvedSession[],
|
|
216
272
|
repository: string | null,
|
|
217
|
-
): readonly
|
|
218
|
-
return sessions
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
)
|
|
226
|
-
.map((candidate) => candidate.name)
|
|
273
|
+
): readonly ResolvedSession[] {
|
|
274
|
+
return sessions.filter(
|
|
275
|
+
(candidate) =>
|
|
276
|
+
candidate.worktree === entry.path ||
|
|
277
|
+
(entry.branch !== null &&
|
|
278
|
+
candidate.branch === entry.branch &&
|
|
279
|
+
candidate.repository === repository),
|
|
280
|
+
)
|
|
227
281
|
}
|
|
228
282
|
|
|
229
283
|
function verdict(
|
|
@@ -234,8 +288,20 @@ function verdict(
|
|
|
234
288
|
merged: ReadonlyMap<string, number>,
|
|
235
289
|
sessions: readonly ResolvedSession[],
|
|
236
290
|
repository: string | null,
|
|
291
|
+
agentIds: ReadonlyMap<number, string>,
|
|
237
292
|
): WorktreeVerdict {
|
|
238
293
|
const held = holders(entry, sessions, repository)
|
|
294
|
+
// An interactive holder carries no id to look up at all, so the branch is
|
|
295
|
+
// explicit rather than relying on `claude agents --json` never reporting one
|
|
296
|
+
// for a pid this repository's own registry marked interactive.
|
|
297
|
+
const heldSessions: HeldSession[] = held.map((session) => ({
|
|
298
|
+
name: session.name,
|
|
299
|
+
kind: session.kind,
|
|
300
|
+
id:
|
|
301
|
+
session.kind === 'interactive'
|
|
302
|
+
? null
|
|
303
|
+
: (agentIds.get(session.pid) ?? null),
|
|
304
|
+
}))
|
|
239
305
|
const pullRequest =
|
|
240
306
|
entry.branch === null ? null : (merged.get(entry.branch) ?? null)
|
|
241
307
|
const refusals: Refusal[] = []
|
|
@@ -262,7 +328,7 @@ function verdict(
|
|
|
262
328
|
reclaimable: refusals.length === 0,
|
|
263
329
|
refusals,
|
|
264
330
|
pullRequest,
|
|
265
|
-
sessions:
|
|
331
|
+
sessions: heldSessions,
|
|
266
332
|
// No removal shape reaches the main worktree, and reporting one there
|
|
267
333
|
// offers a command whose only effect is to break the checkout. Deciding it
|
|
268
334
|
// here rather than in the reporter keeps the record and the framed output
|
|
@@ -301,14 +367,17 @@ export async function reclaimReport(
|
|
|
301
367
|
const readMerged = opts.mergedPullRequests ?? mergedPullRequests
|
|
302
368
|
const readStatus = opts.worktreeStatus ?? worktreeStatus
|
|
303
369
|
const resolve = opts.resolve ?? resolveSessions
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
370
|
+
const readAgentIds = opts.claudeAgentIds ?? claudeAgentIds
|
|
371
|
+
|
|
372
|
+
const [entries, merged, sessions, repository, current, agentIds] =
|
|
373
|
+
await Promise.all([
|
|
374
|
+
listAll(cwd),
|
|
375
|
+
readMerged(cwd),
|
|
376
|
+
resolve(),
|
|
377
|
+
repositoryOf(cwd),
|
|
378
|
+
currentWorktreeRoot(cwd),
|
|
379
|
+
readAgentIds(cwd),
|
|
380
|
+
])
|
|
312
381
|
|
|
313
382
|
if (merged.kind === 'unreadable') {
|
|
314
383
|
return {
|
|
@@ -344,6 +413,7 @@ export async function reclaimReport(
|
|
|
344
413
|
byBranch,
|
|
345
414
|
sessions.sessions,
|
|
346
415
|
repository,
|
|
416
|
+
agentIds,
|
|
347
417
|
),
|
|
348
418
|
)
|
|
349
419
|
|
|
@@ -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
|
}
|