@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 CHANGED
@@ -36,7 +36,7 @@ npx @spardutti/claude-skills
36
36
  ```
37
37
 
38
38
  ```text
39
- Claude Skills Installer v2.56.2
39
+ Claude Skills Installer v2.57.0
40
40
 
41
41
  ── Frontend ──────────────────────────────
42
42
  ◉ react ◯ tanstack-query
@@ -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() {
@@ -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=$(printf '%s' "$INPUT" | grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"' \
24
- | head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
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
- *"git push"*)
36
- BRANCH=$(git branch --show-current 2>/dev/null)
37
- case "$BRANCH" in
38
- main|master|develop|development|dev) ;;
39
- *) exit 0 ;;
40
- esac
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"
@@ -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=$(git diff -U0 "$BASE" -- "$f" 2>/dev/null \
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
@@ -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
- // A gate run by hand or on a release is not a ship; only /ship's start opens one.
117
- if (mode === "gate" && !existsSync(path)) return 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
+ }
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
- BRANCH=$(git -C "$PROJECT_DIR" branch --show-current 2>/dev/null | json_string)
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
@@ -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
- UNQUOTE='s/^["'"'"']//; s/["'"'"']$//'
134
- SPATHS=$(sed -n 's/^[[:space:]]*gate-paths:[[:space:]]*//p' "$SKILL_FILE" | head -1 | sed "$UNQUOTE")
135
- STRACKS=$(sed -n 's/^tracks:[[:space:]]*//p' "$SKILL_FILE" | head -1 | sed "$UNQUOTE")
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
- HIT=""
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.
@@ -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.56.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": {