@spardutti/claude-skills 2.56.1 → 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 +56 -6
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { basename, dirname, join, resolve } from "node:path";
|
|
5
5
|
import { git, parseReport, prSize } from "./ship-log-parse.mjs";
|
|
6
6
|
import { printReport, tally } from "./ship-log-report.mjs";
|
|
@@ -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) {
|
|
@@ -45,8 +45,8 @@ function summarize(record) {
|
|
|
45
45
|
return { minutes: Math.round(minutes * 10) / 10, gateRuns: record.gate.length, gateSeconds: record.gate.reduce((n, r) => n + (r.seconds ?? 0), 0), findings: by };
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
function finalize(dir, path, record, tag, status, reason) {
|
|
49
|
-
Object.assign(record, { status, reason: reason ?? null, finished: now() });
|
|
48
|
+
function finalize(dir, path, record, tag, status, reason, at) {
|
|
49
|
+
Object.assign(record, { status, reason: reason ?? null, finished: at ?? now() });
|
|
50
50
|
record.summary = summarize(record);
|
|
51
51
|
const stem = `${record.app}_${record.finished.slice(0, 10)}_${tag}`;
|
|
52
52
|
let out = join(dir, `${stem}.json`);
|
|
@@ -56,8 +56,25 @@ function finalize(dir, path, record, tag, status, reason) {
|
|
|
56
56
|
console.log(`ship-log: saved ${out}`);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
function prOf(branch) {
|
|
60
|
+
const r = spawnSync("gh", ["pr", "list", "--head", branch, "--state", "all", "--json", "number,createdAt", "--limit", "1"], { encoding: "utf8" });
|
|
61
|
+
try { return r.status === 0 ? JSON.parse(r.stdout)[0] : undefined; } catch { return undefined; }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Agents skip the `pr` call, so a log whose branch has a PR by now is closed at that PR.
|
|
65
|
+
function closeOpened(dir, app) {
|
|
66
|
+
const open = join(dir, "open");
|
|
67
|
+
for (const f of existsSync(open) ? readdirSync(open).filter((n) => n.startsWith(`${app}__`)) : []) {
|
|
68
|
+
const path = join(open, f), record = JSON.parse(readFileSync(path, "utf8"));
|
|
69
|
+
if (!record.gate.length && !record.findings.length) continue;
|
|
70
|
+
const pr = prOf(record.branch);
|
|
71
|
+
if (pr) finalize(dir, path, Object.assign(record, { pr: pr.number }), `pr${pr.number}`, "pr-opened", null, pr.createdAt);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
59
75
|
// A ship that never reached pr or end was abandoned; keep it, but never fold it into the next one.
|
|
60
76
|
function restart(dir, path, record, id) {
|
|
77
|
+
closeOpened(dir, id.app);
|
|
61
78
|
if (existsSync(path)) finalize(dir, path, record, id.branch.replace(/[^\w.-]+/g, "-"), "abandoned");
|
|
62
79
|
save(path, load(path, id));
|
|
63
80
|
}
|
|
@@ -89,6 +106,33 @@ function addStep(record, [name, edge]) {
|
|
|
89
106
|
if (step.start && step.end) step.seconds = Math.round((Date.parse(step.end) - Date.parse(step.start)) / 1000);
|
|
90
107
|
}
|
|
91
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
|
+
|
|
92
136
|
function run(mode, args) {
|
|
93
137
|
const c = conf();
|
|
94
138
|
if (mode === "report") return printReport(args[0] ?? c.dir);
|
|
@@ -96,6 +140,11 @@ function run(mode, args) {
|
|
|
96
140
|
if (!c.on) return 0;
|
|
97
141
|
const id = identity(c.root), path = openPath(c.dir, id), record = load(path, id);
|
|
98
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
|
+
}
|
|
99
148
|
// A gate run by hand or on a release is not a ship; only /ship's start opens one.
|
|
100
149
|
if (mode === "gate" && !existsSync(path)) return 0;
|
|
101
150
|
if (mode === "gate") addGateRun(record, args);
|
|
@@ -106,12 +155,13 @@ function run(mode, args) {
|
|
|
106
155
|
return finalize(c.dir, path, record, `pr${args[0]}`, "pr-opened"), 0;
|
|
107
156
|
}
|
|
108
157
|
else if (mode === "end") return finalize(c.dir, path, record, id.branch.replace(/[^\w.-]+/g, "-"), "stopped", args.join(" ") || null), 0;
|
|
109
|
-
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]");
|
|
110
159
|
save(path, record);
|
|
111
160
|
return 0;
|
|
112
161
|
}
|
|
113
162
|
|
|
114
163
|
try {
|
|
164
|
+
if (process.env.SHIP_LOG_ROOT) process.chdir(process.env.SHIP_LOG_ROOT);
|
|
115
165
|
process.exit(run(process.argv[2], process.argv.slice(3)) ?? 0);
|
|
116
166
|
} catch (err) {
|
|
117
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
|
|