@spardutti/claude-skills 2.56.2 → 2.56.3
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/README.md +1 -1
- package/hooks/gate-config.sh +5 -0
- package/hooks/ship-gate-hook.sh +42 -11
- package/hooks/ship-log.mjs +36 -3
- package/hooks/skill-flags-record.sh +3 -1
- package/package.json +1 -1
package/README.md
CHANGED
package/hooks/gate-config.sh
CHANGED
|
@@ -18,6 +18,11 @@ load_gauntlet_conf() { # load_gauntlet_conf <project conf> <fallback conf>
|
|
|
18
18
|
if [ -f "$conf" ]; then . "$conf"; fi
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
json_field() { # json_field <json> <key> — a flat string value; \" is unescaped, \n is kept as two characters
|
|
22
|
+
printf '%s' "$1" | grep -oE "\"$2\"[[:space:]]*:[[:space:]]*\"([^\"\\\\]|\\\\.)*\"" | head -1 \
|
|
23
|
+
| sed -E 's/^[^:]*:[[:space:]]*"//; s/"$//; s/\\"/"/g'
|
|
24
|
+
}
|
|
25
|
+
|
|
21
26
|
# set -f: unquoted, the list is glob-expanded before it is split, and */components/ui/*.tsx
|
|
22
27
|
# expanded into the very path it should match.
|
|
23
28
|
is_ignored() {
|
package/hooks/ship-gate-hook.sh
CHANGED
|
@@ -19,9 +19,10 @@
|
|
|
19
19
|
# now: the cost is paid once, at the point work actually ships.
|
|
20
20
|
|
|
21
21
|
INPUT=$(cat)
|
|
22
|
+
. "$(cd "$(dirname "$0")" && pwd)/gate-config.sh" 2>/dev/null || exit 0
|
|
22
23
|
|
|
23
|
-
CMD=$(
|
|
24
|
-
|
|
24
|
+
CMD=$(json_field "$INPUT" command)
|
|
25
|
+
CWD=$(json_field "$INPUT" cwd)
|
|
25
26
|
|
|
26
27
|
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
27
28
|
cd "$PROJECT_DIR" 2>/dev/null || exit 0
|
|
@@ -30,16 +31,31 @@ cd "$PROJECT_DIR" 2>/dev/null || exit 0
|
|
|
30
31
|
# protected branch — that is a direct ship with no PR in front of it. The branch
|
|
31
32
|
# is read from git rather than parsed out of the command, because `git push` with
|
|
32
33
|
# no arguments names no branch at all.
|
|
34
|
+
push_dirs() { # one "<cd dir><TAB><-C dir>" line per push in the command
|
|
35
|
+
local dir=. seg re_c='^git -C +"?([^" ]+)"? +push( |$)' re_cd='^cd +"?([^" ]+)"?$' re_push='^git push( |$)'
|
|
36
|
+
while IFS= read -r seg; do
|
|
37
|
+
seg=$(printf '%s' "$seg" | sed -E 's/^[[:space:]]*(\$\(|\()?[[:space:]]*//; s/[[:space:]]*$//')
|
|
38
|
+
if [[ $seg =~ $re_cd ]]; then dir=${BASH_REMATCH[1]}
|
|
39
|
+
elif [[ $seg =~ $re_c ]]; then printf '%s\t%s\n' "$dir" "${BASH_REMATCH[1]}"
|
|
40
|
+
elif [[ $seg =~ $re_push ]]; then printf '%s\t\n' "$dir"
|
|
41
|
+
fi
|
|
42
|
+
done < <(printf '%s\n' "$CMD" | sed -E 's/\\n|&&|\|\||;|\|/\n/g')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
push_branch() { # push_branch <cd dir> <-C dir>; a folder git cannot read falls back to the main folder
|
|
46
|
+
git -C "${CWD:-.}" -C "$1" ${2:+-C "$2"} branch --show-current 2>/dev/null || git branch --show-current 2>/dev/null
|
|
47
|
+
}
|
|
48
|
+
|
|
33
49
|
case "$CMD" in
|
|
34
50
|
*"gh pr create"*|*"gh pr merge"*) ;;
|
|
35
|
-
*
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
51
|
+
*)
|
|
52
|
+
PUSHES=$(push_dirs); [ -n "$PUSHES" ] || exit 0
|
|
53
|
+
PROTECTED=""
|
|
54
|
+
while IFS=$'\t' read -r d c; do
|
|
55
|
+
case "$(push_branch "$d" "$c")" in main|master|develop|development|dev) PROTECTED=1 ;; esac
|
|
56
|
+
done <<< "$PUSHES"
|
|
57
|
+
[ -n "$PROTECTED" ] || exit 0
|
|
41
58
|
;;
|
|
42
|
-
*) exit 0 ;;
|
|
43
59
|
esac
|
|
44
60
|
|
|
45
61
|
json_escape() {
|
|
@@ -63,7 +79,7 @@ git rev-parse --git-dir >/dev/null 2>&1 || exit 0
|
|
|
63
79
|
|
|
64
80
|
pr_branch() {
|
|
65
81
|
case "$CMD" in
|
|
66
|
-
*"gh pr create"*) printf '%s\n' "$CMD" | sed -nE 's/.*(--head[ =]|-H )([^ ;&|]+).*/\2/p' ;;
|
|
82
|
+
*"gh pr create"*) printf '%s\n' "$CMD" | sed -nE 's/.*(--head[ =]|-H )"?([^ ;&|"]+).*/\2/p' ;;
|
|
67
83
|
*"gh pr merge"*) merge_branch ;;
|
|
68
84
|
esac
|
|
69
85
|
}
|
|
@@ -91,6 +107,9 @@ worktree_of() {
|
|
|
91
107
|
# A PR from a worktree ships that folder's diff, so its receipt is keyed there,
|
|
92
108
|
# not in the folder this session started in.
|
|
93
109
|
TARGET="$PROJECT_DIR"
|
|
110
|
+
case "$CMD" in *"gh pr create"*)
|
|
111
|
+
CWD_TOP=$(git -C "${CWD:-.}" rev-parse --show-toplevel 2>/dev/null) && [ -n "$CWD_TOP" ] && TARGET="$CWD_TOP" ;;
|
|
112
|
+
esac
|
|
94
113
|
BRANCH=$(pr_branch)
|
|
95
114
|
[ -n "$BRANCH" ] && WT=$(worktree_of "$BRANCH") && [ -n "$WT" ] && TARGET="$WT"
|
|
96
115
|
|
|
@@ -102,7 +121,7 @@ KEY=$(CLAUDE_PROJECT_DIR="$TARGET" bash "$GATE" --key 2>/dev/null)
|
|
|
102
121
|
# A gate run by hand skips /ship's audit and review, so the PR waits for /ship itself.
|
|
103
122
|
# A merge whose branch gh cannot name is left to the receipt alone.
|
|
104
123
|
MARKER="$PROJECT_DIR/.claude/hooks/ship-mark.sh"
|
|
105
|
-
case "$CMD" in *"gh pr create"*) MARK_BRANCH=${BRANCH:-$(git branch --show-current)} ;; *) MARK_BRANCH=$BRANCH ;; esac
|
|
124
|
+
case "$CMD" in *"gh pr create"*) MARK_BRANCH=${BRANCH:-$(git -C "$TARGET" branch --show-current)} ;; *) MARK_BRANCH=$BRANCH ;; esac
|
|
106
125
|
if [ -f "$MARKER" ] && [ -n "$MARK_BRANCH" ] && ! CLAUDE_PROJECT_DIR="$TARGET" bash "$MARKER" check "$MARK_BRANCH"; then
|
|
107
126
|
deny "BLOCKED: /ship has not run on branch $MARK_BRANCH.
|
|
108
127
|
|
|
@@ -111,8 +130,20 @@ by hand is not enough: /ship also runs the skills audit and the code review.
|
|
|
111
130
|
The gate's earlier pass is reused, so only those two steps cost time."
|
|
112
131
|
fi
|
|
113
132
|
|
|
133
|
+
SHIP_LOG="$PROJECT_DIR/.claude/hooks/ship-log.mjs"
|
|
134
|
+
log_ready() {
|
|
135
|
+
case "$CMD" in *"gh pr create"*) ;; *) return 0 ;; esac
|
|
136
|
+
[ -f "$SHIP_LOG" ] && command -v node >/dev/null || return 0
|
|
137
|
+
TODO=$(SHIP_LOG_ROOT="$TARGET" node "$SHIP_LOG" ready 2>/dev/null) || [ -z "$TODO" ] || deny "BLOCKED: the ship log is not filled in for this /ship. Do these, then create the PR again:
|
|
138
|
+
$TODO
|
|
139
|
+
|
|
140
|
+
A finding's shape is in The ship log section of /ship. Log each one with the verdict
|
|
141
|
+
you already reached; never log a finding you did not settle."
|
|
142
|
+
}
|
|
143
|
+
|
|
114
144
|
RECEIPT="/tmp/claude-shipgate-$KEY"
|
|
115
145
|
if [ -f "$RECEIPT" ]; then
|
|
146
|
+
log_ready
|
|
116
147
|
exit 0
|
|
117
148
|
fi
|
|
118
149
|
|
package/hooks/ship-log.mjs
CHANGED
|
@@ -12,10 +12,10 @@ const now = () => new Date().toISOString().replace(/\.\d+Z$/, "Z");
|
|
|
12
12
|
// gate-config.sh owns the config files and their order, so the gate and this agree on "on".
|
|
13
13
|
function conf() {
|
|
14
14
|
const root = git(["rev-parse", "--show-toplevel"]) || process.cwd();
|
|
15
|
-
const script = 'cd "$2" && . "$1/gate-config.sh" || exit 1; load_gauntlet_conf .claude/gauntlet.conf "$1/../gauntlet.conf"; printf "\\nSHIP_LOG_ON=%s\\nSHIP_LOG_DIR=%s\\n" "$GAUNTLET_SHIP_LOG" "$GAUNTLET_SHIP_LOG_DIR"';
|
|
15
|
+
const script = 'cd "$2" && . "$1/gate-config.sh" || exit 1; load_gauntlet_conf .claude/gauntlet.conf "$1/../gauntlet.conf"; printf "\\nSHIP_LOG_ON=%s\\nSHIP_LOG_DIR=%s\\nDIARY_DIR=%s\\n" "$GAUNTLET_SHIP_LOG" "$GAUNTLET_SHIP_LOG_DIR" "$(diary_dir "$1")"';
|
|
16
16
|
const out = spawnSync("bash", ["-c", script, "_", HERE, root], { encoding: "utf8" }).stdout;
|
|
17
17
|
const value = (key) => out.split("\n").findLast((l) => l.startsWith(`${key}=`))?.slice(key.length + 1) ?? "";
|
|
18
|
-
return { on: value("SHIP_LOG_ON") === "1", dir: value("SHIP_LOG_DIR") || join(process.env.HOME ?? "", ".claude", "ship-logs"), root };
|
|
18
|
+
return { on: value("SHIP_LOG_ON") === "1", dir: value("SHIP_LOG_DIR") || join(process.env.HOME ?? "", ".claude", "ship-logs"), diary: value("DIARY_DIR"), root };
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
function identity(root) {
|
|
@@ -106,6 +106,33 @@ function addStep(record, [name, edge]) {
|
|
|
106
106
|
if (step.start && step.end) step.seconds = Math.round((Date.parse(step.end) - Date.parse(step.start)) / 1000);
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// Worktrees share one diary, so only this branch's latest review since the ship started counts.
|
|
110
|
+
function lastReview(diary, branch, since) {
|
|
111
|
+
const file = join(diary, "review-findings.jsonl");
|
|
112
|
+
const rows = existsSync(file) ? readFileSync(file, "utf8").split("\n").filter(Boolean) : [];
|
|
113
|
+
const last = rows.map((l) => { try { return JSON.parse(l); } catch { return null; } })
|
|
114
|
+
.filter((r) => r?.branch === branch && r.ts >= since && typeof r.reply === "string").at(-1);
|
|
115
|
+
const json = last?.reply.match(/```json\s*([\s\S]*?)```/)?.[1];
|
|
116
|
+
try {
|
|
117
|
+
const list = json && JSON.parse(json);
|
|
118
|
+
return Array.isArray(list) ? { ts: last.ts, count: list.length } : null;
|
|
119
|
+
} catch { return null; }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Agents skipped the log's steps even when the text sat inside them, so the PR hook asks this.
|
|
123
|
+
// A ship with no review (a release, a forced run) is never held.
|
|
124
|
+
function missing(record, c) {
|
|
125
|
+
const review = lastReview(c.diary, record.branch, record.started);
|
|
126
|
+
if (!review) return [];
|
|
127
|
+
const quote = (s) => `'${s.replaceAll("'", "'\\''")}'`;
|
|
128
|
+
const cmd = (args) => `SHIP_LOG_ROOT=${quote(c.root)} node ${quote(join(HERE, "ship-log.mjs"))} ${args}`;
|
|
129
|
+
const out = [];
|
|
130
|
+
if (!record.steps.review?.end) out.push(cmd("step review end"));
|
|
131
|
+
const have = record.findings.filter((f) => f.by === "review" && f.ts >= review.ts).length;
|
|
132
|
+
if (have < review.count) out.push(`${cmd("finding '<json>'")} for every review finding: ${have} of ${review.count} logged; a duplicate is verdict "skipped", note "duplicate of <what>"`);
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
109
136
|
function run(mode, args) {
|
|
110
137
|
const c = conf();
|
|
111
138
|
if (mode === "report") return printReport(args[0] ?? c.dir);
|
|
@@ -113,6 +140,11 @@ function run(mode, args) {
|
|
|
113
140
|
if (!c.on) return 0;
|
|
114
141
|
const id = identity(c.root), path = openPath(c.dir, id), record = load(path, id);
|
|
115
142
|
if (mode === "start") return restart(c.dir, path, record, id), 0;
|
|
143
|
+
if (mode === "ready") {
|
|
144
|
+
const todo = existsSync(path) ? missing(record, c) : [];
|
|
145
|
+
if (todo.length) console.log(todo.map((t) => ` - ${t}`).join("\n"));
|
|
146
|
+
return todo.length ? 1 : 0;
|
|
147
|
+
}
|
|
116
148
|
// A gate run by hand or on a release is not a ship; only /ship's start opens one.
|
|
117
149
|
if (mode === "gate" && !existsSync(path)) return 0;
|
|
118
150
|
if (mode === "gate") addGateRun(record, args);
|
|
@@ -123,12 +155,13 @@ function run(mode, args) {
|
|
|
123
155
|
return finalize(c.dir, path, record, `pr${args[0]}`, "pr-opened"), 0;
|
|
124
156
|
}
|
|
125
157
|
else if (mode === "end") return finalize(c.dir, path, record, id.branch.replace(/[^\w.-]+/g, "-"), "stopped", args.join(" ") || null), 0;
|
|
126
|
-
else throw new Error("usage: ship-log.mjs on | start | gate <report> <exit> <seconds> <base> | step <name> start|end | finding '<json>' | pr <number> | end [reason] | report [dir]");
|
|
158
|
+
else throw new Error("usage: ship-log.mjs on | start | ready | gate <report> <exit> <seconds> <base> | step <name> start|end | finding '<json>' | pr <number> | end [reason] | report [dir]");
|
|
127
159
|
save(path, record);
|
|
128
160
|
return 0;
|
|
129
161
|
}
|
|
130
162
|
|
|
131
163
|
try {
|
|
164
|
+
if (process.env.SHIP_LOG_ROOT) process.chdir(process.env.SHIP_LOG_ROOT);
|
|
132
165
|
process.exit(run(process.argv[2], process.argv.slice(3)) ?? 0);
|
|
133
166
|
} catch (err) {
|
|
134
167
|
console.error(`ship-log: ${err.message}`);
|
|
@@ -20,7 +20,9 @@ MODEL=""
|
|
|
20
20
|
[ -f "$TRANSCRIPT" ] && MODEL=$(grep -o '"model":"[^"]*"' "$TRANSCRIPT" | tail -1 | sed 's/.*:"//; s/"$//')
|
|
21
21
|
|
|
22
22
|
. "$SCRIPT_DIR/gate-config.sh" 2>/dev/null || exit 0
|
|
23
|
-
|
|
23
|
+
CWD=$(json_field "$INPUT" cwd)
|
|
24
|
+
# A review run in a worktree belongs to the worktree's branch, not the main folder's.
|
|
25
|
+
BRANCH=$(git -C "${CWD:-$PROJECT_DIR}" branch --show-current 2>/dev/null | json_string)
|
|
24
26
|
printf '{"ts":"%s","branch":"%s","model":"%s","reply":"%s"}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$BRANCH" "$MODEL" "$REPLY" \
|
|
25
27
|
>> "$(diary_dir "$SCRIPT_DIR")/$LOG"
|
|
26
28
|
|