@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 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.56.3
40
40
 
41
41
  ── Frontend ──────────────────────────────
42
42
  ◉ react ◯ tanstack-query
@@ -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() {
@@ -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
 
@@ -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
- 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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spardutti/claude-skills",
3
- "version": "2.56.2",
3
+ "version": "2.56.3",
4
4
  "description": "Guardrails for Claude Code — best-practice skills, planning commands, and delivery gates.",
5
5
  "type": "module",
6
6
  "repository": {