@spardutti/claude-skills 2.56.2 → 2.57.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/README.md +1 -1
- package/hooks/gate-config.sh +24 -0
- package/hooks/ship-gate-hook.sh +42 -11
- package/hooks/ship-gate-projects.sh +19 -0
- package/hooks/ship-gate.sh +3 -4
- package/hooks/ship-log.mjs +38 -5
- package/hooks/ship-prep.sh +156 -0
- package/hooks/skill-flags-record.sh +6 -1
- package/hooks/skill-gate.sh +6 -16
- package/lib/setup-hook.mjs +3 -0
- package/package.json +2 -2
package/README.md
CHANGED
package/hooks/gate-config.sh
CHANGED
|
@@ -18,6 +18,30 @@ load_gauntlet_conf() { # load_gauntlet_conf <project conf> <fallback conf>
|
|
|
18
18
|
if [ -f "$conf" ]; then . "$conf"; fi
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
skill_field() { # skill_field <SKILL.md> <key pattern> — its first value, quotes stripped
|
|
22
|
+
sed -n "s/^$2:[[:space:]]*//p" "$1" | head -1 | sed "s/^[\"']//; s/[\"']\$//"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# Claude Code writes gate-paths gitignore-style. A case glob has no **, but * already crosses /
|
|
26
|
+
# here, so dropping the **/ prefix is equivalent. set -f keeps the globs from matching real files.
|
|
27
|
+
skill_hits() { # skill_hits <gate-paths> <files...> — the files those globs match
|
|
28
|
+
local g t globs
|
|
29
|
+
set -f
|
|
30
|
+
globs=$(printf '%s' "$1" | tr ',' ' ' | sed 's!\*\*/!!g')
|
|
31
|
+
for t in "${@:2}"; do
|
|
32
|
+
for g in $globs; do
|
|
33
|
+
case "$g" in \**|/*) ;; *) g="*$g" ;; esac
|
|
34
|
+
case "$t" in $g) printf '%s\n' "$t"; break ;; esac
|
|
35
|
+
done
|
|
36
|
+
done
|
|
37
|
+
set +f
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
json_field() { # json_field <json> <key> — a flat string value; \" is unescaped, \n is kept as two characters
|
|
41
|
+
printf '%s' "$1" | grep -oE "\"$2\"[[:space:]]*:[[:space:]]*\"([^\"\\\\]|\\\\.)*\"" | head -1 \
|
|
42
|
+
| sed -E 's/^[^:]*:[[:space:]]*"//; s/"$//; s/\\"/"/g'
|
|
43
|
+
}
|
|
44
|
+
|
|
21
45
|
# set -f: unquoted, the list is glob-expanded before it is split, and */components/ui/*.tsx
|
|
22
46
|
# expanded into the very path it should match.
|
|
23
47
|
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
|
|
|
@@ -89,6 +89,25 @@ changed_files() {
|
|
|
89
89
|
git ls-files --others --exclude-standard 2>/dev/null; } | sort -u | grep -vE '(^|/)\.claude/'
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
file_hunks() { # file_hunks <base> <file> — its new-side line ranges, one "start-end" a line
|
|
93
|
+
local h
|
|
94
|
+
h=$(git diff -U0 "$1" -- "$2" 2>/dev/null | grep -oE '^@@ -[0-9,]+ \+[0-9]+(,[0-9]+)?' | sed 's/.*+//' \
|
|
95
|
+
| while IFS=, read -r s l; do l=${l:-1}; [ "$l" -gt 0 ] && echo "$s-$((s+l-1))"; done)
|
|
96
|
+
if [ -n "$h" ]; then printf '%s\n' "$h"; else echo "1-$(wc -l < "$2" | tr -d ' ')"; fi
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# The audit's scope: every changed text file, minus generated, vendored and lock files. Tab-separated: paths may hold spaces.
|
|
100
|
+
scope_report() {
|
|
101
|
+
local f r new
|
|
102
|
+
echo "base $BASE"
|
|
103
|
+
printf '%s\n' "$CHANGED" | grep -vE '(^|/)(package-lock\.json|pnpm-lock\.yaml|[^/]*\.lock)$' | while IFS= read -r f; do
|
|
104
|
+
[ -f "$f" ] && ! is_ignored "$f" && grep -qI . "$f" || continue
|
|
105
|
+
r=$(file_hunks "$BASE" "$f" | sed -E 's/^([0-9]+)-\1$/\1/' | paste -sd, -)
|
|
106
|
+
new=""; git cat-file -e "$BASE:$f" 2>/dev/null || new=" (new)"
|
|
107
|
+
printf '%s\t%s%s\n' "$f" "$r" "$new"
|
|
108
|
+
done
|
|
109
|
+
}
|
|
110
|
+
|
|
92
111
|
# Ignored extensions are left out; see the comment where ship-gate.sh first calls this.
|
|
93
112
|
receipt_key() {
|
|
94
113
|
{ printf '%s\n' "$PROJECT_DIR"
|
package/hooks/ship-gate.sh
CHANGED
|
@@ -52,6 +52,7 @@ HERE=$(cd "$(dirname "$0")" && pwd)
|
|
|
52
52
|
MODE=""
|
|
53
53
|
case "${1:-}" in
|
|
54
54
|
--key) MODE=key; shift ;;
|
|
55
|
+
--scope) MODE=scope; shift ;;
|
|
55
56
|
--force) MODE=force; shift ;;
|
|
56
57
|
--baseline) MODE=baseline; shift ;;
|
|
57
58
|
esac
|
|
@@ -81,6 +82,7 @@ for B in ${GAUNTLET_BASE:-} develop dev main; do [ -n "$BASE" ] || BASE=$(git me
|
|
|
81
82
|
[ -n "$BASE" ] || BASE=$(git rev-list --max-parents=0 HEAD | head -1)
|
|
82
83
|
|
|
83
84
|
CHANGED=$(changed_files)
|
|
85
|
+
[ "$MODE" = scope ] && { scope_report; exit 0; }
|
|
84
86
|
|
|
85
87
|
# Only the files this gate can actually read go into the key. It used to be every
|
|
86
88
|
# changed file, so a version bump in package.json or a line in a changelog threw
|
|
@@ -205,10 +207,7 @@ for owner in $OWNERS; do
|
|
|
205
207
|
FLAGS=""
|
|
206
208
|
GLOBBED=","
|
|
207
209
|
while IFS= read -r f; do
|
|
208
|
-
hunks=$(
|
|
209
|
-
| grep -oE '^@@ -[0-9,]+ \+[0-9]+(,[0-9]+)?' | sed 's/.*+//' \
|
|
210
|
-
| while IFS=, read -r s l; do l=${l:-1}; [ "$l" -gt 0 ] && echo "$s-$((s+l-1))"; done)
|
|
211
|
-
[ -z "$hunks" ] && hunks="1-$(wc -l < "$f")"
|
|
210
|
+
hunks=$(file_hunks "$BASE" "$f")
|
|
212
211
|
rel=${f#$owner/}
|
|
213
212
|
# A Next.js route folder — [id], [[...slug]] — is a glob expression to
|
|
214
213
|
# Stryker, which refuses "Cannot combine a glob expression with a mutation
|
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,8 +140,13 @@ 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;
|
|
116
|
-
|
|
117
|
-
|
|
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
|
+
}
|
|
148
|
+
// A gate run or a review outside /ship is not a ship; only /ship's start opens one.
|
|
149
|
+
if ((mode === "gate" || mode === "step") && !existsSync(path)) return 0;
|
|
118
150
|
if (mode === "gate") addGateRun(record, args);
|
|
119
151
|
else if (mode === "finding") addFinding(record, args[0]);
|
|
120
152
|
else if (mode === "step") addStep(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}`);
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
HERE=$(cd "$(dirname "$0")" && pwd)
|
|
3
|
+
. "$HERE/gate-config.sh" || { echo "ship-prep: cannot read $HERE/gate-config.sh"; exit 1; }
|
|
4
|
+
cd "${CLAUDE_PROJECT_DIR:-$PWD}" 2>/dev/null || { echo "ship-prep: cannot read ${CLAUDE_PROJECT_DIR:-$PWD}"; exit 1; }
|
|
5
|
+
|
|
6
|
+
stop() { echo "ship-prep: STOP — $1"; exit 1; }
|
|
7
|
+
|
|
8
|
+
first_remote() { # first_remote <branch...> — the first one origin has
|
|
9
|
+
local b
|
|
10
|
+
for b in "$@"; do git rev-parse -q --verify "origin/$b" >/dev/null && { echo "$b"; return; }; done
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
base_branch() { local dev; dev=$(first_remote develop development dev); echo "${dev:-$(first_remote main master)}"; }
|
|
14
|
+
|
|
15
|
+
start() {
|
|
16
|
+
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || stop "not a git repo."
|
|
17
|
+
local branch base dirty behind ahead
|
|
18
|
+
branch=$(git branch --show-current)
|
|
19
|
+
[ -n "$branch" ] || stop "Detached HEAD — check out a branch first."
|
|
20
|
+
if git remote | grep -qx origin; then git fetch -q origin 2>/dev/null; else echo "remote none: pr, merge and release cannot run"; fi
|
|
21
|
+
base=$(base_branch)
|
|
22
|
+
echo "branch $branch"
|
|
23
|
+
echo "base ${base:-none} (main $(first_remote main master))"
|
|
24
|
+
if ! git rev-parse -q --verify HEAD >/dev/null; then echo "commits none"
|
|
25
|
+
elif [ -n "$base" ]; then
|
|
26
|
+
read -r behind ahead < <(git rev-list --left-right --count "origin/$base...HEAD")
|
|
27
|
+
echo "ahead $ahead, behind $behind of origin/$base"
|
|
28
|
+
fi
|
|
29
|
+
dirty=$(git status --porcelain)
|
|
30
|
+
echo "uncommitted $(printf '%s' "$dirty" | grep -c .)"
|
|
31
|
+
[ -n "$dirty" ] && printf '%s\n' "$dirty" | sed 's/^/ /'
|
|
32
|
+
if gh auth status >/dev/null 2>&1; then
|
|
33
|
+
echo "gh logged in"
|
|
34
|
+
echo "pr $(gh pr view --json state,number,url -q '.state+" #"+(.number|tostring)+" "+.url' 2>/dev/null || echo none)"
|
|
35
|
+
else
|
|
36
|
+
echo "gh NOT logged in: pr, merge and release cannot run"
|
|
37
|
+
fi
|
|
38
|
+
bash "$HERE/ship-mark.sh" begin || stop "ship-mark.sh begin failed."
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
skills_report() { # skills_report <files...>
|
|
42
|
+
local f name paths hits
|
|
43
|
+
for f in "$(diary_dir "$HERE")"/skills/*/SKILL.md; do
|
|
44
|
+
[ -f "$f" ] || continue
|
|
45
|
+
name=$(skill_field "$f" name); paths=$(skill_field "$f" '[[:space:]]*gate-paths')
|
|
46
|
+
if [ -z "$paths" ]; then echo "skill $name: decide from its description"; continue; fi
|
|
47
|
+
hits=$(skill_hits "$paths" "$@" | paste -sd' ' -)
|
|
48
|
+
if [ -n "$hits" ]; then echo "skill $name: audit $hits"; else echo "skill $name: skip, no changed file matches $paths"; fi
|
|
49
|
+
done
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
scope() {
|
|
53
|
+
local branch scope base files=() diff f
|
|
54
|
+
cd "$(git rev-parse --show-toplevel)" || stop "cannot find the repo root."
|
|
55
|
+
branch=$(git branch --show-current)
|
|
56
|
+
scope=$(bash "$HERE/ship-gate.sh" --scope) || stop "ship-gate.sh --scope failed: $scope"
|
|
57
|
+
printf '%s\n' "$scope"
|
|
58
|
+
base=$(printf '%s\n' "$scope" | sed -n '1s/^base //p')
|
|
59
|
+
while IFS=$'\t' read -r f _; do [ -n "$f" ] && files+=("$f"); done < <(printf '%s\n' "$scope" | sed '1d')
|
|
60
|
+
skills_report "${files[@]}"
|
|
61
|
+
diff="/tmp/claude-ship-$(printf '%s %s' "$PWD" "$branch" | cksum | cut -d' ' -f1).diff"
|
|
62
|
+
: > "$diff"
|
|
63
|
+
if [ ${#files[@]} -gt 0 ]; then
|
|
64
|
+
git diff "$base" -- "${files[@]}" >> "$diff"
|
|
65
|
+
for f in "${files[@]}"; do
|
|
66
|
+
git ls-files --error-unmatch "$f" >/dev/null 2>&1 || git diff --no-index /dev/null "$f" >> "$diff"
|
|
67
|
+
done
|
|
68
|
+
fi
|
|
69
|
+
echo "diff $diff"
|
|
70
|
+
echo "review /code-review medium origin/$(base_branch)...$branch"
|
|
71
|
+
node "$HERE/ship-log.mjs" step audit start >/dev/null 2>&1
|
|
72
|
+
node "$HERE/ship-log.mjs" step review start >/dev/null 2>&1
|
|
73
|
+
return 0
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
secrets() {
|
|
77
|
+
local branch names hits
|
|
78
|
+
branch=$(git branch --show-current)
|
|
79
|
+
case "$branch" in main|master|develop|development|dev) stop "on protected branch $branch — commit on a feature or hotfix/<desc> branch." ;; esac
|
|
80
|
+
git status --short
|
|
81
|
+
names=$({ git diff HEAD --name-only --diff-filter=d; git ls-files --others --exclude-standard; } 2>/dev/null \
|
|
82
|
+
| grep -iE '(^|/)(\.env(\..*)?|[^/]*\.pem|[^/]*credentials[^/]*|[^/]*\.key)$' | grep -viE '\.(example|sample|template)$')
|
|
83
|
+
hits=$({ git diff HEAD; git ls-files --others --exclude-standard -z | xargs -0 -r -I{} git diff --no-index /dev/null {}; } 2>/dev/null \
|
|
84
|
+
| grep -E '^\+' | grep -E 'AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY-----|xox[abprs]-[A-Za-z0-9-]{10,}|[Bb]earer [A-Za-z0-9._~+/-]{20,}' | cut -c1-120)
|
|
85
|
+
[ -z "$names$hits" ] && { echo "secrets none"; return 0; }
|
|
86
|
+
echo "secrets FOUND — STOP and tell the user; suggest gitleaks or git-secrets as a pre-commit guard:"
|
|
87
|
+
[ -n "$names" ] && printf '%s\n' "$names" | sed 's/^/ file /'
|
|
88
|
+
[ -n "$hits" ] && printf '%s\n' "$hits" | sed 's/^/ line /'
|
|
89
|
+
return 1
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
CHECKS_FAILED='[.statusCheckRollup[]? | select(((.conclusion // .state) // "") | test("FAILURE|ERROR|CANCELLED|TIMED_OUT|ACTION_REQUIRED")) | (.name // .context)] | join(",")'
|
|
93
|
+
CHECKS_RUNNING='[.statusCheckRollup[]? | select((.status // .state // "") | test("IN_PROGRESS|QUEUED|PENDING|WAITING")) | (.name // .context)] | join(",")'
|
|
94
|
+
PR_JQ="[.number, .isDraft, .mergeable, (.reviewDecision // \"\"), ($CHECKS_FAILED), ($CHECKS_RUNNING), .baseRefName, .headRefName] | map(tostring) | join(\"|\")"
|
|
95
|
+
|
|
96
|
+
merge_check() { # merge_check [pr]; sets PR_BASE and PR_HEAD
|
|
97
|
+
local line n draft mergeable review failing pending why=""
|
|
98
|
+
line=$(gh pr view ${1:+"$1"} --json number,isDraft,mergeable,reviewDecision,statusCheckRollup,baseRefName,headRefName -q "$PR_JQ") || stop "no open PR found."
|
|
99
|
+
IFS='|' read -r n draft mergeable review failing pending PR_BASE PR_HEAD <<< "$line"
|
|
100
|
+
[ "$draft" = true ] && why="$why draft;"
|
|
101
|
+
[ "$mergeable" = CONFLICTING ] && why="$why conflicts with the base;"
|
|
102
|
+
[ -n "$failing" ] && why="$why CI failing: $failing;"
|
|
103
|
+
case "$review" in REVIEW_REQUIRED|CHANGES_REQUESTED) why="$why review: $review;" ;; esac
|
|
104
|
+
[ -n "$pending" ] && why="$why CI still running: $pending;"
|
|
105
|
+
[ -z "$why" ] && { echo "merge ok #$n"; return 0; }
|
|
106
|
+
echo "merge STOP #$n:$why"; return 1
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
land() { # land <pr> [squash|merge]
|
|
110
|
+
local worktree=""
|
|
111
|
+
[ -n "${1:-}" ] || stop "usage: ship-prep.sh land <pr> [squash|merge]"
|
|
112
|
+
merge_check "$1" >/dev/null || { merge_check "$1"; exit 1; }
|
|
113
|
+
[ "$(git rev-parse --git-dir)" != "$(git rev-parse --git-common-dir)" ] && worktree=1
|
|
114
|
+
if [ "${2:-squash}" = merge ]; then gh pr merge "$1" --merge || stop "gh pr merge failed."
|
|
115
|
+
elif [ -n "$worktree" ]; then
|
|
116
|
+
# gh --delete-branch switches the local checkout, which a worktree cannot; delete the branch on origin instead.
|
|
117
|
+
gh pr merge "$1" --squash || stop "gh pr merge failed."
|
|
118
|
+
git push -q origin --delete "$PR_HEAD" 2>/dev/null
|
|
119
|
+
else gh pr merge "$1" --squash --delete-branch || stop "gh pr merge failed."; fi
|
|
120
|
+
if [ -n "$worktree" ]; then
|
|
121
|
+
git fetch -q origin 2>/dev/null
|
|
122
|
+
echo "landed #$1; this is a worktree, so it stays on its branch — close the session to remove it"
|
|
123
|
+
else
|
|
124
|
+
git checkout -q "$PR_BASE" && git pull -q --ff-only origin "$PR_BASE" 2>/dev/null
|
|
125
|
+
echo "landed #$1; now on $(git branch --show-current) at $(git rev-parse --short HEAD)"
|
|
126
|
+
fi
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
release() {
|
|
130
|
+
local tag v major minor patch commits breaking feat next
|
|
131
|
+
git fetch -q --tags origin 2>/dev/null
|
|
132
|
+
tag=$(git tag --sort=-v:refname | head -1)
|
|
133
|
+
[ -n "$tag" ] || { echo "tag none; suggest v0.1.0 (confirm with the user)"; return 0; }
|
|
134
|
+
commits=$(git log "$tag"..HEAD --format='%s')
|
|
135
|
+
[ -n "$commits" ] || stop "no commits since $tag — nothing to release."
|
|
136
|
+
v=${tag##*[!0-9.]}; IFS=. read -r major minor patch <<< "$v"
|
|
137
|
+
breaking=$(( $(printf '%s\n' "$commits" | grep -cE '^[a-z]+(\([^)]*\))?!:') + $(git log "$tag"..HEAD --format=%B | grep -c 'BREAKING CHANGE') ))
|
|
138
|
+
feat=$(printf '%s\n' "$commits" | grep -cE '^feat(\([^)]*\))?!?:')
|
|
139
|
+
if [ "$major" -ge 1 ]; then
|
|
140
|
+
if [ "$breaking" -gt 0 ]; then next="$((major+1)).0.0"; elif [ "$feat" -gt 0 ]; then next="$major.$((minor+1)).0"; else next="$major.$minor.$((patch+1))"; fi
|
|
141
|
+
elif [ "$breaking" -gt 0 ]; then next="0.$((minor+1)).0"
|
|
142
|
+
else next="0.$minor.$((patch+1))"; fi
|
|
143
|
+
echo "tag $tag"
|
|
144
|
+
echo "suggest ${tag%"$v"}$next (confirm with the user)"
|
|
145
|
+
printf '%s\n' "$commits" | sed 's/^/ /'
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
case "${1:-}" in
|
|
149
|
+
start) start ;;
|
|
150
|
+
scope) scope ;;
|
|
151
|
+
secrets) secrets ;;
|
|
152
|
+
merge) merge_check "${2:-}" ;;
|
|
153
|
+
land) land "${2:-}" "${3:-}" ;;
|
|
154
|
+
release) release ;;
|
|
155
|
+
*) echo "usage: ship-prep.sh start | scope | secrets | merge [pr] | land <pr> [squash|merge] | release" >&2; exit 2 ;;
|
|
156
|
+
esac
|
|
@@ -20,8 +20,13 @@ 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
|
|
|
29
|
+
case "$LOG" in skill-flags.jsonl) STEP=audit ;; *) STEP=review ;; esac
|
|
30
|
+
SHIP_LOG_ROOT="${CWD:-$PROJECT_DIR}" node "$SCRIPT_DIR/ship-log.mjs" step "$STEP" end >/dev/null 2>&1
|
|
31
|
+
|
|
27
32
|
exit 0
|
package/hooks/skill-gate.sh
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
INPUT=$(cat)
|
|
5
5
|
|
|
6
6
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
7
|
+
. "$SCRIPT_DIR/gate-config.sh"
|
|
7
8
|
PROJECT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
|
|
8
9
|
|
|
9
10
|
if ! find "$PROJECT_DIR" -path '*/.claude/skills/*/SKILL.md' 2>/dev/null | grep -q .; then
|
|
@@ -130,25 +131,14 @@ if [ -n "$TARGETS" ]; then
|
|
|
130
131
|
#
|
|
131
132
|
# The value must be quoted in YAML either way: a scalar opening with * is
|
|
132
133
|
# an alias indicator.
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
SNAME=$(sed -n 's/^name:[[:space:]]*//p' "$SKILL_FILE" | head -1 | sed "$UNQUOTE")
|
|
134
|
+
SPATHS=$(skill_field "$SKILL_FILE" '[[:space:]]*gate-paths')
|
|
135
|
+
STRACKS=$(skill_field "$SKILL_FILE" tracks)
|
|
136
|
+
SNAME=$(skill_field "$SKILL_FILE" name)
|
|
137
137
|
[ -z "$SPATHS" ] && continue
|
|
138
138
|
[ -z "$SNAME" ] && continue
|
|
139
139
|
case "$INSTALLED" in *"\"$SNAME\""*) ;; *) continue ;; esac # automark marks only installed and custom skills
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
for G in $(printf '%s' "$SPATHS" | tr ',' ' '); do
|
|
143
|
-
# Claude Code writes these gitignore-style. A case glob has no **, but *
|
|
144
|
-
# already crosses / here, so dropping the **/ prefix is equivalent.
|
|
145
|
-
G=$(printf '%s' "$G" | sed 's!\*\*/!!g')
|
|
146
|
-
case "$G" in \**|/*) ;; *) G="*$G" ;; esac
|
|
147
|
-
for T in $TARGETS; do
|
|
148
|
-
case "$T" in $G) HIT=1 ;; esac
|
|
149
|
-
done
|
|
150
|
-
done
|
|
151
|
-
[ -z "$HIT" ] && continue
|
|
140
|
+
# shellcheck disable=SC2086
|
|
141
|
+
[ -n "$(skill_hits "$SPATHS" $TARGETS)" ] || continue
|
|
152
142
|
|
|
153
143
|
# A .tsx file is not proof of React — it could be Astro, Solid or Preact.
|
|
154
144
|
# The packages the skill tracks are the proof, so the manifests decide.
|
package/lib/setup-hook.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { homedir } from "node:os";
|
|
|
22
22
|
// ship-gate-projects.sh sourced by ship-gate.sh — project detection, reuse
|
|
23
23
|
// ship-gate-hook.sh PreToolUse on Bash — no commit without a receipt
|
|
24
24
|
// ship-mark.sh written by /ship — no PR on a branch /ship never ran on
|
|
25
|
+
// ship-prep.sh run by /ship — prechecks, scope and skills in one call
|
|
25
26
|
// version-check.sh SessionStart — a line when a newer catalog exists
|
|
26
27
|
// skill-flags-record.sh SubagentStop — logs each skills-audit verdict
|
|
27
28
|
// skill-report.mjs read by /skill-report — tallies that log
|
|
@@ -55,6 +56,7 @@ const SHIP_LOG_FILENAME = "ship-log.mjs";
|
|
|
55
56
|
const SHIP_LOG_PARSE_FILENAME = "ship-log-parse.mjs";
|
|
56
57
|
const SHIP_LOG_REPORT_FILENAME = "ship-log-report.mjs";
|
|
57
58
|
const SHIP_MARK_FILENAME = "ship-mark.sh";
|
|
59
|
+
const SHIP_PREP_FILENAME = "ship-prep.sh";
|
|
58
60
|
const LEGACY_EVAL_FILENAME = "skill-forced-eval-hook.sh";
|
|
59
61
|
const LEGACY_AUDIT_RUNNER_FILENAME = "skill-audit-runner.sh";
|
|
60
62
|
|
|
@@ -163,6 +165,7 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
163
165
|
SHIP_LOG_PARSE_FILENAME,
|
|
164
166
|
SHIP_LOG_REPORT_FILENAME,
|
|
165
167
|
SHIP_MARK_FILENAME,
|
|
168
|
+
SHIP_PREP_FILENAME,
|
|
166
169
|
]) {
|
|
167
170
|
const dest = join(hooksDir, name);
|
|
168
171
|
await writeFile(dest, await readHookSource(name), { mode: 0o755 });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spardutti/claude-skills",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.57.0",
|
|
4
4
|
"description": "Guardrails for Claude Code — best-practice skills, planning commands, and delivery gates.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"claude-skills": "bin/cli.mjs"
|
|
12
12
|
},
|
|
13
13
|
"scripts": {
|
|
14
|
-
"prepack": "cp ../README.md README.md && mkdir -p hooks && cp ../scripts/skill-gate.sh ../scripts/skill-gate-automark.sh ../scripts/skill-application-gate.sh ../scripts/gauntlet.sh ../scripts/ship-gate.sh ../scripts/gate-config.sh ../scripts/ship-gate-projects.sh ../scripts/ship-gate-structure.sh ../scripts/ship-gate-files.sh ../scripts/ship-gate-hook.sh ../scripts/version-check.sh ../scripts/skill-flags-record.sh ../scripts/skill-report.mjs ../scripts/edit-check.sh ../scripts/line-limit.sh ../scripts/lint-ratchet.mjs ../scripts/text-rules.mjs ../scripts/ratchet-baseline.mjs ../scripts/diary-check.mjs ../scripts/diary-lib.mjs ../scripts/ship-log.mjs ../scripts/ship-log-parse.mjs ../scripts/ship-log-report.mjs ../scripts/ship-mark.sh hooks/",
|
|
14
|
+
"prepack": "cp ../README.md README.md && mkdir -p hooks && cp ../scripts/skill-gate.sh ../scripts/skill-gate-automark.sh ../scripts/skill-application-gate.sh ../scripts/gauntlet.sh ../scripts/ship-gate.sh ../scripts/gate-config.sh ../scripts/ship-gate-projects.sh ../scripts/ship-gate-structure.sh ../scripts/ship-gate-files.sh ../scripts/ship-gate-hook.sh ../scripts/version-check.sh ../scripts/skill-flags-record.sh ../scripts/skill-report.mjs ../scripts/edit-check.sh ../scripts/line-limit.sh ../scripts/lint-ratchet.mjs ../scripts/text-rules.mjs ../scripts/ratchet-baseline.mjs ../scripts/diary-check.mjs ../scripts/diary-lib.mjs ../scripts/ship-log.mjs ../scripts/ship-log-parse.mjs ../scripts/ship-log-report.mjs ../scripts/ship-mark.sh ../scripts/ship-prep.sh hooks/",
|
|
15
15
|
"postpack": "git checkout -- README.md && rm -rf hooks"
|
|
16
16
|
},
|
|
17
17
|
"engines": {
|