@spardutti/claude-skills 2.52.0 → 2.53.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.52.0
39
+ Claude Skills Installer v2.53.0
40
40
 
41
41
  ── Frontend ──────────────────────────────
42
42
  ◉ react ◯ tanstack-query
@@ -139,7 +139,7 @@ Portable slash commands installed to `.claude/commands/`. Some orchestrate paral
139
139
  | `/handoff` | Write a handoff doc so a fresh session picks the work up — captures what auto-compaction loses: decisions made and rejected, dead ends already ruled out, why the tree is dirty, and the one next step |
140
140
  | `/lockdown` | Per-repo supply-chain hardening — detects the package managers, Dockerfiles, and CI in use, then guides you through and applies install-time, deploy, and pipeline hardening for npm/pnpm, pip/uv, Docker, and GitHub Actions |
141
141
  | `/add-rule` | Turn a mistake Claude keeps making into a check on every edit — a text rule with a bad and a good example from real code when a pattern can catch it, or a lesson shown only on the files it names when it needs judgment. Old hits go into a baseline and come back as "fix it if you can" notes; nothing is added to CLAUDE.md |
142
- | `/skill-report` | Which installed skills earn their place in this repo — every `/ship` skills audit is logged by a hook, and this tallies it: how often each skill applied, how often it caught a violation, and the rule it catches most. Names rules that break often enough to become a script check, and skills that never flag and may be dead weight |
142
+ | `/skill-report` | Which installed skills earn their place in this repo — every `/ship` skills audit is logged by a hook, and this tallies it: how often each skill applied, how often it caught a violation, and the rule it catches most. Names rules that break often enough to become a script check, and skills that never flag and may be dead weight. Also ranks the rules the edit hook blocks most, by how many branches they hit — a rule Claude keeps writing wrong first is one its skill should state earlier |
143
143
 
144
144
  ## How It Works
145
145
 
@@ -198,6 +198,7 @@ scripts/ validate-skills.mjs — checks skill length caps and reference in
198
198
  version-check.sh — SessionStart nudge when a newer catalog is published
199
199
  skill-flags-record.sh — logs each /ship skills audit and /code-review; skill-report.mjs tallies them
200
200
  edit-check.sh — PostToolUse check on each edited file: line-limit.sh, lint-ratchet.mjs, .edit-rules.json
201
+ diary-check.mjs — run at the end of /ship; says when the diaries hold enough for /add-rule
201
202
  line-limit.sh — the 200-line ratchet, shared by the edit check, the ship gate and preflight
202
203
  gauntlet-selftest.sh — behavioural tests for the hooks (runs on pre-push)
203
204
  gauntlet-survey.sh — what the hook would do in every repo under a directory
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { claudeDir, parseLine, readLines, verdictOf } from "./diary-lib.mjs";
5
+
6
+ const DIR = claudeDir();
7
+ const STAMP = resolve(DIR, "add-rule-checked");
8
+ const TRUNKS = new Set(["main", "master", "dev", "develop", "development"]);
9
+ const lines = (name) => readLines(resolve(DIR, name));
10
+
11
+ function entries() {
12
+ const parsed = (name) => lines(name).map(parseLine).filter(Boolean);
13
+ const audits = parsed("skill-flags.jsonl").filter((e) => verdictOf(e.reply ?? "")?.violations?.length > 0);
14
+ const all = [...parsed("review-findings.jsonl"), ...parsed("gate-findings.jsonl"), ...audits];
15
+ return all.filter((e) => e.branch && !TRUNKS.has(e.branch));
16
+ }
17
+
18
+ function check() {
19
+ const since = existsSync(STAMP) ? readFileSync(STAMP, "utf8").trim() : "";
20
+ const fresh = entries().filter((e) => (e.ts ?? "") > since);
21
+ const branches = new Set(fresh.map((e) => e.branch));
22
+ if (branches.size < 2) return;
23
+ console.log(`${fresh.length} diary entries from ${branches.size} branches since /add-rule last looked (${since || "never"}). Offer only repeats with an entry after that.`);
24
+ }
25
+
26
+ const mode = process.argv[2];
27
+ if (mode === "--mark") {
28
+ mkdirSync(DIR, { recursive: true });
29
+ writeFileSync(STAMP, `${new Date().toISOString().replace(/\.\d+Z$/, "Z")}\n`);
30
+ } else if (mode === "--entries") {
31
+ for (const e of entries()) console.log(JSON.stringify(e));
32
+ } else if (mode === undefined) {
33
+ check();
34
+ } else {
35
+ console.error("usage: diary-check.mjs [--mark | --entries]");
36
+ process.exit(1);
37
+ }
@@ -0,0 +1,41 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+
5
+ // Asks gate-config.sh's diary_dir, so the hooks that write the diaries and the scripts that
6
+ // read them share one rule. argv[1] keeps a symlinked path the way bash sees it.
7
+ export function claudeDir() {
8
+ const here = dirname(resolve(process.argv[1]));
9
+ const run = spawnSync("bash", ["-c", '. "$1/gate-config.sh" && diary_dir "$1"', "_", here], { encoding: "utf8" });
10
+ return run.stdout?.trim() || resolve(process.env.CLAUDE_PROJECT_DIR || process.cwd(), ".claude");
11
+ }
12
+
13
+ export function readLines(path) {
14
+ try {
15
+ return readFileSync(path, "utf-8").split("\n").filter(Boolean);
16
+ } catch {
17
+ return [];
18
+ }
19
+ }
20
+
21
+ export function parseLine(line) {
22
+ try {
23
+ return JSON.parse(line);
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ export function verdictOf(reply) {
30
+ try {
31
+ return JSON.parse(reply.slice(reply.indexOf("{"), reply.lastIndexOf("}") + 1));
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ export function parseVerdict(line) {
38
+ const entry = parseLine(line);
39
+ const verdict = entry && verdictOf(entry.reply ?? "");
40
+ return verdict ? { model: entry.model, verdict } : null;
41
+ }
@@ -15,21 +15,42 @@ if [ -f "$HERE/gate-config.sh" ]; then
15
15
  fi
16
16
  FAILS=""
17
17
  NOTES=""
18
+ BLOCKED="" # one rule id per line
18
19
 
19
- run() {
20
- local out rc
20
+ run() { # run <rule id when it fails, or empty> <command...>
21
+ local id="$1" out rc
22
+ shift
21
23
  out=$("$@" 2>&1); rc=$?
22
24
  [ -z "$out" ] && return
23
- if [ "$rc" = 0 ]; then NOTES="$NOTES$out"$'\n'; else FAILS="$FAILS$out"$'\n'; fi
25
+ if [ "$rc" = 0 ]; then NOTES="$NOTES$out"$'\n'; else FAILS="$FAILS$out"$'\n'; [ -n "$id" ] && BLOCKED="$BLOCKED$id"$'\n'; fi
24
26
  }
25
27
 
26
28
  # The gate measures length from the git root, so a monorepo subfolder shares its baseline.
27
29
  GIT_ROOT=$(git -C "$PROJECT_DIR" rev-parse --show-toplevel 2>/dev/null || printf '%s' "$PROJECT_DIR")
28
- [ -f "$HERE/line-limit.sh" ] && run env LINE_LIMIT_ROOT="$GIT_ROOT" bash "$HERE/line-limit.sh" --file "$FILE"
29
- lint() { cd "$PROJECT_DIR" && node "$HERE/lint-ratchet.mjs" --file "$FILE"; }
30
- [ -f "$HERE/lint-ratchet.mjs" ] && command -v node >/dev/null && run lint
30
+ [ -f "$HERE/line-limit.sh" ] && run line-limit env LINE_LIMIT_ROOT="$GIT_ROOT" bash "$HERE/line-limit.sh" --file "$FILE"
31
+ if [ -f "$HERE/lint-ratchet.mjs" ] && command -v node >/dev/null; then
32
+ IDS=$(mktemp); trap 'rm -f "$IDS"' EXIT TERM
33
+ lint() { cd "$PROJECT_DIR" && LINT_RATCHET_BLOCKED="$IDS" node "$HERE/lint-ratchet.mjs" --file "$FILE"; }
34
+ run "" lint
35
+ BLOCKED="$BLOCKED$(cat "$IDS")"$'\n'
36
+ fi
37
+
38
+ # A rule that blocks on branch after branch is one its skill should teach up front.
39
+ log_block() {
40
+ local rules diary
41
+ rules=$(printf '%s' "$BLOCKED" | sort -u | while IFS= read -r id; do
42
+ [ -n "$id" ] && printf '"%s",' "$(printf '%s' "$id" | json_string)"
43
+ done)
44
+ [ -n "$rules" ] || return 0
45
+ diary=$(diary_dir "$HERE")
46
+ mkdir -p "$diary"
47
+ printf '{"ts":"%s","branch":"%s","file":"%s","rules":[%s]}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
48
+ "$(git -C "$(dirname "$FILE")" branch --show-current 2>/dev/null | json_string)" \
49
+ "$(printf '%s' "${FILE#"$PROJECT_DIR"/}" | json_string)" "${rules%,}" >> "$diary/edit-blocks.jsonl"
50
+ }
31
51
 
32
52
  if [ -n "$FAILS" ]; then
53
+ declare -F json_string >/dev/null && declare -F diary_dir >/dev/null && log_block
33
54
  printf '%s%s' "$FAILS" "$NOTES" >&2
34
55
  exit 2
35
56
  fi
@@ -26,6 +26,12 @@ is_ignored() {
26
26
  return 1
27
27
  }
28
28
 
29
+ # Every diary sits in the .claude that holds the hooks writing it, so a worktree or a
30
+ # subfolder session still shares one set. Outside .claude/hooks (this repo's scripts/), the project.
31
+ diary_dir() { # diary_dir <script dir>
32
+ case "$1" in */.claude/hooks) dirname "$1" ;; *) printf '%s/.claude' "${CLAUDE_PROJECT_DIR:-$PWD}" ;; esac
33
+ }
34
+
29
35
  json_string() {
30
36
  awk 'BEGIN { ORS = "" } {
31
37
  gsub(/\033\[[0-9;?]*[ -\/]*[@-~]/, "")
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawnSync } from "node:child_process";
3
- import { existsSync, realpathSync } from "node:fs";
3
+ import { appendFileSync, existsSync, realpathSync } from "node:fs";
4
4
  import { relative, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { brokenRules, loadEditRules, projectFiles, textFindings } from "./text-rules.mjs";
@@ -120,6 +120,7 @@ function checkFile(path) {
120
120
  console.log(`NOTE — ${rule.id} has no baseline yet, so its ${hits.length} finding(s) in ${rel} do not block. Run node ${SELF} --baseline-new to record old code.`);
121
121
  } else if (hits.length > before) {
122
122
  failed = 1;
123
+ if (process.env.LINT_RATCHET_BLOCKED) appendFileSync(process.env.LINT_RATCHET_BLOCKED, `${rule.id}\n`);
123
124
  console.log(`LINT — ${rel}: ${hits.length} × ${rule.id}, was ${before}. Fix the new one(s):`);
124
125
  for (const h of hits) console.log(` ${rel}:${h.line} ${h.message}`);
125
126
  } else if (hits.length > 0) {
@@ -119,8 +119,9 @@ gate_exit() {
119
119
  exec 1>&3 2>&4
120
120
  # A child left running (a dev server) can hold tee open forever; give it 2s, not a hang.
121
121
  for _ in 1 2 3 4 5 6 7 8 9 10; do [ -n "$TEE_PID" ] && kill -0 "$TEE_PID" 2>/dev/null || break; sleep 0.2; done
122
- mkdir -p "$PROJECT_DIR/.claude"
122
+ local diary; diary=$(diary_dir "$HERE")
123
+ mkdir -p "$diary"
123
124
  printf '{"ts":"%s","branch":"%s","status":"FAIL","report":"%s"}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
124
125
  "$(git branch --show-current 2>/dev/null | json_string)" "$(json_string < "$REPORT")" \
125
- >> "$PROJECT_DIR/.claude/gate-findings.jsonl"
126
+ >> "$diary/gate-findings.jsonl"
126
127
  }
@@ -19,9 +19,9 @@ TRANSCRIPT=$(printf '%s' "$INPUT" | grep -o '"agent_transcript_path":"[^"]*"' |
19
19
  MODEL=""
20
20
  [ -f "$TRANSCRIPT" ] && MODEL=$(grep -o '"model":"[^"]*"' "$TRANSCRIPT" | tail -1 | sed 's/.*:"//; s/"$//')
21
21
 
22
- . "$SCRIPT_DIR/gate-config.sh" 2>/dev/null || json_string() { tr -cd '[:alnum:]/._-'; }
22
+ . "$SCRIPT_DIR/gate-config.sh" 2>/dev/null || exit 0
23
23
  BRANCH=$(git -C "$PROJECT_DIR" branch --show-current 2>/dev/null | json_string)
24
24
  printf '{"ts":"%s","branch":"%s","model":"%s","reply":"%s"}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$BRANCH" "$MODEL" "$REPLY" \
25
- >> "$PROJECT_DIR/.claude/$LOG"
25
+ >> "$(diary_dir "$SCRIPT_DIR")/$LOG"
26
26
 
27
27
  exit 0
@@ -1,18 +1,10 @@
1
1
  #!/usr/bin/env node
2
- // Prints, per skill, how often the /ship skills audit applied it and flagged a violation.
3
- import { readFileSync } from "node:fs";
4
- import { fileURLToPath } from "node:url";
2
+ import { statSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { claudeDir, parseLine, parseVerdict, readLines } from "./diary-lib.mjs";
5
5
 
6
- const DEFAULT_LOG = fileURLToPath(new URL("../skill-flags.jsonl", import.meta.url));
7
-
8
- function parseVerdict(line) {
9
- try {
10
- const { model, reply } = JSON.parse(line);
11
- return { model, verdict: JSON.parse(reply.slice(reply.indexOf("{"), reply.lastIndexOf("}") + 1)) };
12
- } catch {
13
- return null;
14
- }
15
- }
6
+ const given = process.argv[2] && resolve(process.argv[2]);
7
+ const DIR = !given ? claudeDir() : statSync(given, { throwIfNoEntry: false })?.isFile() ? dirname(given) : given;
16
8
 
17
9
  function tally(lines) {
18
10
  const skills = new Map();
@@ -52,10 +44,28 @@ function render({ skills, models, unreadable }) {
52
44
  return out.join("\n");
53
45
  }
54
46
 
55
- let text = "";
56
- try {
57
- text = readFileSync(process.argv[2] ?? DEFAULT_LOG, "utf-8");
58
- } catch {
59
- // no log yet
47
+ function tallyBlocks(lines) {
48
+ const rules = new Map();
49
+ for (const line of lines) {
50
+ const entry = parseLine(line);
51
+ const ids = Array.isArray(entry?.rules) ? entry.rules : (entry?.rules ?? "").split(" ").filter(Boolean);
52
+ for (const id of ids) {
53
+ const r = rules.get(id) ?? { count: 0, branches: new Set() };
54
+ r.count += 1;
55
+ if (entry.branch) r.branches.add(entry.branch);
56
+ rules.set(id, r);
57
+ }
58
+ }
59
+ return rules;
60
+ }
61
+
62
+ function renderBlocks(rules) {
63
+ if (rules.size === 0) return "";
64
+ const rows = [...rules].sort((a, b) => b[1].branches.size - a[1].branches.size || b[1].count - a[1].count);
65
+ const width = Math.max(22, ...rows.map(([id]) => id.length));
66
+ const line = ([id, r]) => `${id.padEnd(width)} ${String(r.count).padStart(5)} ${String(r.branches.size).padStart(8)}`;
67
+ return ["", `${"edit-hook blocks".padEnd(width)} count branches`, ...rows.map(line)].join("\n");
60
68
  }
61
- console.log(render(tally(text.split("\n").filter(Boolean))));
69
+
70
+ const audits = tally(readLines(resolve(DIR, "skill-flags.jsonl")));
71
+ console.log(render(audits) + renderBlocks(tallyBlocks(readLines(resolve(DIR, "edit-blocks.jsonl")))));
@@ -39,6 +39,7 @@ function walk(root, dir) {
39
39
  export function brokenRules(rules) {
40
40
  const broken = [];
41
41
  for (const r of rules) {
42
+ if (/\s/.test(r.id ?? "")) broken.push(`${r.id}: its id has whitespace, which .lint-baseline cannot store`);
42
43
  if (matchLines(r, r.bad ?? "").length === 0) broken.push(`${r.id}: its bad example is not caught`);
43
44
  if (matchLines(r, r.good ?? "").length > 0) broken.push(`${r.id}: its good example is flagged`);
44
45
  }
@@ -24,6 +24,7 @@ import { homedir } from "node:os";
24
24
  // version-check.sh SessionStart — a line when a newer catalog exists
25
25
  // skill-flags-record.sh SubagentStop — logs each skills-audit verdict
26
26
  // skill-report.mjs read by /skill-report — tallies that log
27
+ // diary-check.mjs run by /ship — says when /add-rule has repeats to look at
27
28
  // edit-check.sh PostToolUse — line-limit.sh, lint-ratchet.mjs and
28
29
  // text-rules.mjs on each edited file
29
30
 
@@ -45,6 +46,8 @@ const LINE_LIMIT_FILENAME = "line-limit.sh";
45
46
  const LINT_RATCHET_FILENAME = "lint-ratchet.mjs";
46
47
  const TEXT_RULES_FILENAME = "text-rules.mjs";
47
48
  const RATCHET_BASELINE_FILENAME = "ratchet-baseline.mjs";
49
+ const DIARY_CHECK_FILENAME = "diary-check.mjs";
50
+ const DIARY_LIB_FILENAME = "diary-lib.mjs";
48
51
  const LEGACY_EVAL_FILENAME = "skill-forced-eval-hook.sh";
49
52
  const LEGACY_AUDIT_RUNNER_FILENAME = "skill-audit-runner.sh";
50
53
 
@@ -148,6 +151,8 @@ export async function setupHook(targetDir = process.cwd()) {
148
151
  LINT_RATCHET_FILENAME,
149
152
  TEXT_RULES_FILENAME,
150
153
  RATCHET_BASELINE_FILENAME,
154
+ DIARY_CHECK_FILENAME,
155
+ DIARY_LIB_FILENAME,
151
156
  ]) {
152
157
  const dest = join(hooksDir, name);
153
158
  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.52.0",
3
+ "version": "2.53.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 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 hooks/",
15
15
  "postpack": "git checkout -- README.md && rm -rf hooks"
16
16
  },
17
17
  "engines": {