@spardutti/claude-skills 2.51.3 → 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 +7 -3
- package/hooks/diary-check.mjs +37 -0
- package/hooks/diary-lib.mjs +41 -0
- package/hooks/edit-check.sh +60 -0
- package/hooks/gate-config.sh +43 -0
- package/hooks/line-limit.sh +125 -0
- package/hooks/lint-ratchet.mjs +159 -0
- package/hooks/ratchet-baseline.mjs +39 -0
- package/hooks/ship-gate-files.sh +1 -1
- package/hooks/ship-gate-projects.sh +1 -1
- package/hooks/ship-gate-structure.sh +17 -2
- package/hooks/ship-gate.sh +3 -27
- package/hooks/skill-flags-record.sh +6 -4
- package/hooks/skill-report.mjs +29 -19
- package/hooks/text-rules.mjs +47 -0
- package/lib/hook-settings.mjs +7 -0
- package/lib/setup-hook.mjs +38 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ npx @spardutti/claude-skills
|
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
```text
|
|
39
|
-
Claude Skills Installer v2.
|
|
39
|
+
Claude Skills Installer v2.53.0
|
|
40
40
|
|
|
41
41
|
── Frontend ──────────────────────────────
|
|
42
42
|
◉ react ◯ tanstack-query
|
|
@@ -138,7 +138,8 @@ Portable slash commands installed to `.claude/commands/`. Some orchestrate paral
|
|
|
138
138
|
| `/optimize` | Make one measured job faster without changing what it proves — fixes a benchmark and a result that must not move, sends 4 read-only subagents to find suspects, then changes one thing per round with a prediction written first, keeping only what beats the noise. Profiles after every wrong prediction, reruns before judging a result that flipped, and ends by writing the cause as a skill rule for you |
|
|
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. 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 |
|
|
142
143
|
|
|
143
144
|
## How It Works
|
|
144
145
|
|
|
@@ -195,7 +196,10 @@ scripts/ validate-skills.mjs — checks skill length caps and reference in
|
|
|
195
196
|
ship-gate.sh — /ship's file-length, folder-structure and mutation checks, behind an exit code
|
|
196
197
|
ship-gate-hook.sh — refuses gh pr create/merge without a ship-gate receipt
|
|
197
198
|
version-check.sh — SessionStart nudge when a newer catalog is published
|
|
198
|
-
skill-flags-record.sh — logs each /ship skills
|
|
199
|
+
skill-flags-record.sh — logs each /ship skills audit and /code-review; skill-report.mjs tallies them
|
|
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
|
|
202
|
+
line-limit.sh — the 200-line ratchet, shared by the edit check, the ship gate and preflight
|
|
199
203
|
gauntlet-selftest.sh — behavioural tests for the hooks (runs on pre-push)
|
|
200
204
|
gauntlet-survey.sh — what the hook would do in every repo under a directory
|
|
201
205
|
preflight.sh — everything that must pass before a release
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
INPUT=$(cat)
|
|
4
|
+
|
|
5
|
+
FILE=$(printf '%s' "$INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' \
|
|
6
|
+
| head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
|
|
7
|
+
[ -z "$FILE" ] && exit 0
|
|
8
|
+
|
|
9
|
+
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
10
|
+
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
11
|
+
if [ -f "$HERE/gate-config.sh" ]; then
|
|
12
|
+
. "$HERE/gate-config.sh"
|
|
13
|
+
load_gauntlet_conf "$PROJECT_DIR/.claude/gauntlet.conf" "$HERE/../gauntlet.conf"
|
|
14
|
+
is_ignored "${FILE#"$PROJECT_DIR"/}" && exit 0
|
|
15
|
+
fi
|
|
16
|
+
FAILS=""
|
|
17
|
+
NOTES=""
|
|
18
|
+
BLOCKED="" # one rule id per line
|
|
19
|
+
|
|
20
|
+
run() { # run <rule id when it fails, or empty> <command...>
|
|
21
|
+
local id="$1" out rc
|
|
22
|
+
shift
|
|
23
|
+
out=$("$@" 2>&1); rc=$?
|
|
24
|
+
[ -z "$out" ] && return
|
|
25
|
+
if [ "$rc" = 0 ]; then NOTES="$NOTES$out"$'\n'; else FAILS="$FAILS$out"$'\n'; [ -n "$id" ] && BLOCKED="$BLOCKED$id"$'\n'; fi
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# The gate measures length from the git root, so a monorepo subfolder shares its baseline.
|
|
29
|
+
GIT_ROOT=$(git -C "$PROJECT_DIR" rev-parse --show-toplevel 2>/dev/null || printf '%s' "$PROJECT_DIR")
|
|
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
|
+
}
|
|
51
|
+
|
|
52
|
+
if [ -n "$FAILS" ]; then
|
|
53
|
+
declare -F json_string >/dev/null && declare -F diary_dir >/dev/null && log_block
|
|
54
|
+
printf '%s%s' "$FAILS" "$NOTES" >&2
|
|
55
|
+
exit 2
|
|
56
|
+
fi
|
|
57
|
+
if [ -n "$NOTES" ] && declare -F json_string >/dev/null; then
|
|
58
|
+
printf '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"%s"}}\n' "$(printf '%s' "$NOTES" | json_string)"
|
|
59
|
+
fi
|
|
60
|
+
exit 0
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
GAUNTLET_MAX_LINES=200
|
|
4
|
+
GAUNTLET_MUTATE=""
|
|
5
|
+
GAUNTLET_SOURCE_EXT="ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cpp|hpp|cs|swift|gd"
|
|
6
|
+
GAUNTLET_IGNORE_EXT="md|mdx|txt|rst|adoc|jsonc?|ya?ml|toml|lock|cfg|ini|env|csv|tsv|sql|html|css|scss|svg|png|jpg|jpeg|gif|webp|ico|pdf|woff2?|ttf|otf|mp3|mp4|wav|zip|gz|tres|tscn|import|godot"
|
|
7
|
+
GAUNTLET_IGNORE_FILES="*.gen.ts *.gen.tsx *.generated.* */migrations/*.py */alembic/versions/*.py */components/ui/*.tsx */hooks/use-mobile.ts */*.config.*"
|
|
8
|
+
# Components are length-checked but not mutated: their mutants are class names and JSX, not
|
|
9
|
+
# behaviour. Override in .claude/gauntlet.conf to mutate them too.
|
|
10
|
+
GAUNTLET_NO_MUTATE='\.(tsx|jsx)$'
|
|
11
|
+
|
|
12
|
+
load_gauntlet_conf() { # load_gauntlet_conf <project conf> <fallback conf>
|
|
13
|
+
[ -n "${HOME:-}" ] && [ -f "$HOME/.claude/gauntlet.conf" ] && . "$HOME/.claude/gauntlet.conf"
|
|
14
|
+
local conf="$1"; [ -f "$conf" ] || conf="$2"
|
|
15
|
+
if [ -f "$conf" ]; then . "$conf"; fi
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
# set -f: unquoted, the list is glob-expanded before it is split, and */components/ui/*.tsx
|
|
19
|
+
# expanded into the very path it should match.
|
|
20
|
+
is_ignored() {
|
|
21
|
+
set -f
|
|
22
|
+
for pat in $GAUNTLET_IGNORE_FILES; do
|
|
23
|
+
case "/$1" in $pat) set +f; return 0 ;; esac
|
|
24
|
+
done
|
|
25
|
+
set +f
|
|
26
|
+
return 1
|
|
27
|
+
}
|
|
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
|
+
|
|
35
|
+
json_string() {
|
|
36
|
+
awk 'BEGIN { ORS = "" } {
|
|
37
|
+
gsub(/\033\[[0-9;?]*[ -\/]*[@-~]/, "")
|
|
38
|
+
gsub(/[\001-\010\013-\037\177]/, "") # JSON forbids raw control characters
|
|
39
|
+
gsub(/\\/, "\\\\"); gsub(/"/, "\\\""); gsub(/\t/, "\\t")
|
|
40
|
+
if (NR > 1) print "\\n"
|
|
41
|
+
print
|
|
42
|
+
}'
|
|
43
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
set -uo pipefail
|
|
4
|
+
|
|
5
|
+
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
6
|
+
. "$HERE/gate-config.sh" || exit 1
|
|
7
|
+
ROOT="$(cd "${LINE_LIMIT_ROOT:-$(git -C "$HERE" rev-parse --show-toplevel 2>/dev/null || echo "$HERE/..")}" && pwd -P)"
|
|
8
|
+
load_gauntlet_conf "$ROOT/.claude/gauntlet.conf" "$HERE/../gauntlet.conf"
|
|
9
|
+
LIMIT=$GAUNTLET_MAX_LINES
|
|
10
|
+
BASELINE="$ROOT/.line-limit-baseline"
|
|
11
|
+
SELF="${HERE#"$ROOT"/}/line-limit.sh"
|
|
12
|
+
if ! [ "$LIMIT" -gt 0 ] 2>/dev/null; then
|
|
13
|
+
[ "${1:-}" = "--file" ] || echo "LINE LIMIT — off (GAUNTLET_MAX_LINES=$LIMIT)"
|
|
14
|
+
exit 0
|
|
15
|
+
fi
|
|
16
|
+
|
|
17
|
+
is_code() {
|
|
18
|
+
local ext="\\.($GAUNTLET_SOURCE_EXT|sh)$" skip="(^|/)node_modules/|^\\.claude/"
|
|
19
|
+
[[ $1 =~ $ext ]] && ! [[ $1 =~ $skip ]] && ! is_ignored "$1"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
# Paths are kept relative to ROOT so the baseline is portable between clones.
|
|
23
|
+
files() {
|
|
24
|
+
{ git -C "$ROOT" ls-files --cached --others --exclude-standard 2>/dev/null \
|
|
25
|
+
|| (cd "$ROOT" && find . -type f -not -path './.git/*' | sed 's|^\./||'); } \
|
|
26
|
+
| while IFS= read -r f; do is_code "$f" && [ -f "$ROOT/$f" ] && printf '%s/%s\n' "$ROOT" "$f"; done | sort
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
counts() {
|
|
30
|
+
while IFS= read -r f; do
|
|
31
|
+
printf '%s %s\n' "$(wc -l < "$f" | tr -d ' ')" "${f#"$ROOT"/}"
|
|
32
|
+
done
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
ONE=""
|
|
36
|
+
if [ "${1:-}" = "--file" ]; then
|
|
37
|
+
ONE="$(cd "$(dirname -- "${2:-.}")" 2>/dev/null && pwd -P)/$(basename -- "${2:-.}")"
|
|
38
|
+
REL="${ONE#"$ROOT"/}"
|
|
39
|
+
[ "$REL" != "$ONE" ] && [ -f "$ONE" ] && is_code "$REL" || exit 0
|
|
40
|
+
git -C "$ROOT" check-ignore -q -- "$REL" 2>/dev/null && exit 0
|
|
41
|
+
NOW=$(printf '%s\n' "$ONE" | counts)
|
|
42
|
+
else
|
|
43
|
+
NOW=$(files | counts)
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
# On an existing baseline this only banks: an entry may drop or go, never appear or grow.
|
|
47
|
+
if [ "${1:-}" = "--baseline" ]; then
|
|
48
|
+
if [ -f "$BASELINE" ]; then
|
|
49
|
+
printf '%s\n' "$NOW" | awk -v lim="$LIMIT" '{ n = $1; p = $0; sub(/^[^ ]+ /, "", p) }
|
|
50
|
+
NR == FNR { was[p] = n; next } (p in was) && n > lim { print (n < was[p] ? n : was[p]), p }' "$BASELINE" - > "$BASELINE.tmp" \
|
|
51
|
+
&& mv "$BASELINE.tmp" "$BASELINE"
|
|
52
|
+
else
|
|
53
|
+
printf '%s\n' "$NOW" | awk -v lim="$LIMIT" '$1 > lim' > "$BASELINE"
|
|
54
|
+
fi
|
|
55
|
+
n=$(wc -l < "$BASELINE" | tr -d ' ')
|
|
56
|
+
echo "line-limit: baseline written — $n file(s) over $LIMIT accepted as debt."
|
|
57
|
+
echo "Commit $BASELINE. From here they may shrink but never grow."
|
|
58
|
+
exit 0
|
|
59
|
+
fi
|
|
60
|
+
|
|
61
|
+
FAILED=0
|
|
62
|
+
NEW_OVER=""
|
|
63
|
+
GREW=""
|
|
64
|
+
SHRANK=""
|
|
65
|
+
|
|
66
|
+
while read -r n f; do
|
|
67
|
+
[ -z "$f" ] && continue
|
|
68
|
+
was=$(awk -v p="$f" '{ n = $1; sub(/^[^ ]+ /, "") } $0 == p { print n }' "$BASELINE" 2>/dev/null)
|
|
69
|
+
if [ -z "$was" ]; then
|
|
70
|
+
# Not accepted debt, so the real limit applies.
|
|
71
|
+
if [ "$n" -gt "$LIMIT" ]; then
|
|
72
|
+
NEW_OVER="$NEW_OVER $f — $n lines (limit $LIMIT)
|
|
73
|
+
"
|
|
74
|
+
FAILED=1
|
|
75
|
+
fi
|
|
76
|
+
elif [ "$n" -gt "$was" ]; then
|
|
77
|
+
GREW="$GREW $f — $n lines, was $was
|
|
78
|
+
"
|
|
79
|
+
FAILED=1
|
|
80
|
+
elif [ "$n" -lt "$was" ]; then
|
|
81
|
+
SHRANK="$SHRANK $f — $n lines, was $was
|
|
82
|
+
"
|
|
83
|
+
elif [ -n "$ONE" ]; then
|
|
84
|
+
echo "LINE LIMIT — $f is $n lines, over $LIMIT (old debt). Split it if this change allows."
|
|
85
|
+
fi
|
|
86
|
+
done <<EOF
|
|
87
|
+
$NOW
|
|
88
|
+
EOF
|
|
89
|
+
|
|
90
|
+
# A file in the baseline that no longer exists is debt that was paid by deleting
|
|
91
|
+
# or renaming it. Report it so the baseline can be trimmed, never fail on it.
|
|
92
|
+
GONE=""
|
|
93
|
+
if [ -z "$ONE" ] && [ -f "$BASELINE" ]; then
|
|
94
|
+
while read -r was f; do
|
|
95
|
+
[ -z "$f" ] && continue
|
|
96
|
+
[ -f "$ROOT/$f" ] || GONE="$GONE $f — gone (was $was lines)
|
|
97
|
+
"
|
|
98
|
+
done < "$BASELINE"
|
|
99
|
+
fi
|
|
100
|
+
|
|
101
|
+
if [ -n "$NEW_OVER" ]; then
|
|
102
|
+
echo "LINE LIMIT — over $LIMIT and not accepted debt:"
|
|
103
|
+
printf '%s' "$NEW_OVER"
|
|
104
|
+
echo " Split them."
|
|
105
|
+
fi
|
|
106
|
+
|
|
107
|
+
if [ -n "$GREW" ]; then
|
|
108
|
+
echo "LINE LIMIT — accepted debt that grew:"
|
|
109
|
+
printf '%s' "$GREW"
|
|
110
|
+
echo " The baseline is a ceiling, not a licence. Take the addition elsewhere."
|
|
111
|
+
fi
|
|
112
|
+
|
|
113
|
+
if [ -n "$SHRANK" ] || [ -n "$GONE" ]; then
|
|
114
|
+
echo "LINE LIMIT — debt paid down (not a failure):"
|
|
115
|
+
[ -n "$SHRANK" ] && printf '%s' "$SHRANK"
|
|
116
|
+
[ -n "$GONE" ] && printf '%s' "$GONE"
|
|
117
|
+
echo " Run: bash $SELF --baseline to bank it."
|
|
118
|
+
fi
|
|
119
|
+
|
|
120
|
+
if [ "$FAILED" = 0 ] && [ -z "$SHRANK" ] && [ -z "$GONE" ] && [ -z "$ONE" ]; then
|
|
121
|
+
over=$(wc -l < "$BASELINE" 2>/dev/null | tr -d ' ')
|
|
122
|
+
echo "LINE LIMIT — ok. ${over:-0} file(s) of accepted debt, none grew."
|
|
123
|
+
fi
|
|
124
|
+
|
|
125
|
+
exit $FAILED
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { appendFileSync, existsSync, realpathSync } from "node:fs";
|
|
4
|
+
import { relative, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { brokenRules, loadEditRules, projectFiles, textFindings } from "./text-rules.mjs";
|
|
7
|
+
import { mergeBaseline, readBaseline, writeBaseline } from "./ratchet-baseline.mjs";
|
|
8
|
+
|
|
9
|
+
const ROOT = realpathSync(process.cwd());
|
|
10
|
+
const BASELINE = resolve(ROOT, ".lint-baseline");
|
|
11
|
+
const ESLINT = resolve(ROOT, "node_modules/.bin/eslint");
|
|
12
|
+
const SELF = relative(ROOT, fileURLToPath(import.meta.url));
|
|
13
|
+
let TEXT = [];
|
|
14
|
+
let LESSONS = [];
|
|
15
|
+
|
|
16
|
+
const isTest = (f) => /\.(test|spec)\.|(^|\/)(test|tests|__tests__)\//.test(f);
|
|
17
|
+
|
|
18
|
+
// A test's describe() callback is one long function by design, and React
|
|
19
|
+
// components are left unannotated by convention, so each rule names its files.
|
|
20
|
+
const RULES = [
|
|
21
|
+
{
|
|
22
|
+
id: "max-lines-per-function",
|
|
23
|
+
skill: "code-structure",
|
|
24
|
+
config: ["error", { max: 30, skipBlankLines: true, skipComments: true }],
|
|
25
|
+
applies: (f) => /\.(m?js|jsx|ts|tsx)$/.test(f) && !isTest(f),
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: "@typescript-eslint/explicit-module-boundary-types",
|
|
29
|
+
skill: "typescript-best-practices",
|
|
30
|
+
config: "error",
|
|
31
|
+
applies: (f) => /\.ts$/.test(f) && !isTest(f),
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
// skills/ is where this repo keeps the skills it ships; a consumer has them under .claude/skills/.
|
|
36
|
+
const hasSkill = (name) =>
|
|
37
|
+
existsSync(resolve(ROOT, ".claude/skills", name, "SKILL.md")) || existsSync(resolve(ROOT, "skills", name, "SKILL.md"));
|
|
38
|
+
const LINT = RULES.filter((r) => hasSkill(r.skill));
|
|
39
|
+
|
|
40
|
+
// A plugin rule the project's config cannot load makes ESLint crash, so retry without it.
|
|
41
|
+
function eslint(rules, target) {
|
|
42
|
+
const results = eslintOnce(rules, target);
|
|
43
|
+
const core = rules.filter((r) => !r.id.includes("/"));
|
|
44
|
+
if (results || core.length === rules.length) return { results, ran: rules };
|
|
45
|
+
return { results: eslintOnce(core, target), ran: core };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function eslintOnce(rules, target) {
|
|
49
|
+
const args = ["-f", "json"];
|
|
50
|
+
for (const r of rules) args.push("--rule", JSON.stringify({ [r.id]: r.config }));
|
|
51
|
+
args.push(target);
|
|
52
|
+
const run = spawnSync(ESLINT, args, { cwd: ROOT, encoding: "utf8", maxBuffer: 1 << 28 });
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(run.stdout);
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function eslintFindings(results) {
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const file of results) {
|
|
63
|
+
const rel = relative(ROOT, file.filePath);
|
|
64
|
+
for (const m of file.messages) {
|
|
65
|
+
const rule = RULES.find((r) => r.id === m.ruleId);
|
|
66
|
+
if (rule && rule.applies(rel)) out.push({ rel, rule: m.ruleId, line: m.line, message: m.message });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function countBy(list) {
|
|
73
|
+
const counts = new Map();
|
|
74
|
+
for (const f of list) {
|
|
75
|
+
const key = `${f.rule} ${f.rel}`;
|
|
76
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
77
|
+
}
|
|
78
|
+
return counts;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function baseline(only, newOnly) {
|
|
82
|
+
const was = readBaseline(BASELINE);
|
|
83
|
+
const pick = (r) => (only ? r.id === only : !newOnly || !was.rules.has(r.id));
|
|
84
|
+
const lint = existsSync(ESLINT) ? LINT.filter(pick) : [];
|
|
85
|
+
const text = TEXT.filter(pick);
|
|
86
|
+
if (newOnly && was.exists && lint.length + text.length === 0) return 0;
|
|
87
|
+
const { results, ran } = lint.length ? eslint(lint, ".") : { results: [], ran: [] };
|
|
88
|
+
if (!results) {
|
|
89
|
+
console.error("lint-ratchet: eslint gave no JSON; baseline not written.");
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
92
|
+
const found = [...eslintFindings(results), ...projectFiles(ROOT).flatMap((rel) => textFindings(text, ROOT, rel))];
|
|
93
|
+
const judged = new Set([...ran, ...text].map((r) => r.id));
|
|
94
|
+
const n = writeBaseline(BASELINE, mergeBaseline(was, countBy(found), judged), new Set([...was.rules, ...judged]));
|
|
95
|
+
console.log(`lint-ratchet: baseline written — ${n} rule/file pair(s) accepted as debt.`);
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function scan(rel) {
|
|
100
|
+
const lint = existsSync(ESLINT) ? LINT.filter((r) => r.applies(rel)) : [];
|
|
101
|
+
const { results, ran } = lint.length ? eslint(lint, rel) : { results: null, ran: [] };
|
|
102
|
+
const text = TEXT.filter((r) => r.applies(rel));
|
|
103
|
+
return {
|
|
104
|
+
rules: [...(results ? ran : []), ...text],
|
|
105
|
+
found: [...(results ? eslintFindings(results) : []), ...textFindings(text, ROOT, rel)],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function checkFile(path) {
|
|
110
|
+
const abs = resolve(ROOT, path);
|
|
111
|
+
const rel = relative(ROOT, existsSync(abs) ? realpathSync(abs) : abs);
|
|
112
|
+
if (rel.startsWith("..") || spawnSync("git", ["check-ignore", "-q", "--", rel], { cwd: ROOT }).status === 0) return 0;
|
|
113
|
+
const { rules, found } = scan(rel);
|
|
114
|
+
const was = readBaseline(BASELINE);
|
|
115
|
+
let failed = 0;
|
|
116
|
+
for (const rule of rules) {
|
|
117
|
+
const hits = found.filter((f) => f.rule === rule.id);
|
|
118
|
+
const before = was.counts.get(`${rule.id} ${rel}`) ?? 0;
|
|
119
|
+
if (hits.length > before && !was.rules.has(rule.id)) {
|
|
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
|
+
} else if (hits.length > before) {
|
|
122
|
+
failed = 1;
|
|
123
|
+
if (process.env.LINT_RATCHET_BLOCKED) appendFileSync(process.env.LINT_RATCHET_BLOCKED, `${rule.id}\n`);
|
|
124
|
+
console.log(`LINT — ${rel}: ${hits.length} × ${rule.id}, was ${before}. Fix the new one(s):`);
|
|
125
|
+
for (const h of hits) console.log(` ${rel}:${h.line} ${h.message}`);
|
|
126
|
+
} else if (hits.length > 0) {
|
|
127
|
+
console.log(`NOTE — ${rel} has ${hits.length} old ${rule.id} finding(s). Fix them if this change allows.`);
|
|
128
|
+
} else if (before > 0) {
|
|
129
|
+
console.log(`NOTE — ${rel}: ${rule.id} debt paid down. Run node ${SELF} --baseline ${rule.id} to bank it.`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
for (const lesson of LESSONS.filter((l) => l.applies(rel))) console.log(`LESSON — ${lesson.message}`);
|
|
133
|
+
return failed;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function selftest() {
|
|
137
|
+
const broken = brokenRules(TEXT);
|
|
138
|
+
for (const b of broken) console.log(`RULE — ${b}`);
|
|
139
|
+
if (broken.length === 0) console.log(`lint-ratchet: ${TEXT.length} text rule(s) catch their bad example and pass their good one.`);
|
|
140
|
+
return broken.length ? 1 : 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function main(mode, arg) {
|
|
144
|
+
({ text: TEXT, lessons: LESSONS } = loadEditRules(ROOT));
|
|
145
|
+
if (mode === "--baseline") return baseline(arg, false);
|
|
146
|
+
if (mode === "--baseline-new") return baseline(undefined, true);
|
|
147
|
+
if (mode === "--file") return checkFile(arg ?? "");
|
|
148
|
+
if (mode === "--selftest") return selftest();
|
|
149
|
+
console.error("usage: lint-ratchet.mjs --baseline [rule-id] | --baseline-new | --file <path> | --selftest");
|
|
150
|
+
return 1;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const [mode, arg] = process.argv.slice(2);
|
|
154
|
+
try {
|
|
155
|
+
process.exit(main(mode, arg));
|
|
156
|
+
} catch (err) {
|
|
157
|
+
console.log(`NOTE — .edit-rules.json could not be used, so its rules did not run: ${err.message}`);
|
|
158
|
+
process.exit(mode === "--file" ? 0 : 1);
|
|
159
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
const ruleOf = (key) => key.split(" ")[0];
|
|
4
|
+
|
|
5
|
+
// "# rules:" names every rule baselined so far, since a rule with no hits leaves no other line.
|
|
6
|
+
export function readBaseline(path) {
|
|
7
|
+
const counts = new Map();
|
|
8
|
+
const rules = new Set();
|
|
9
|
+
if (!existsSync(path)) return { counts, rules, exists: false };
|
|
10
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
11
|
+
if (line.startsWith("# rules:")) {
|
|
12
|
+
for (const id of line.slice(8).trim().split(/\s+/).filter(Boolean)) rules.add(id);
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
const m = line.match(/^(\d+) (\S+) (.+)$/);
|
|
16
|
+
if (!m) continue;
|
|
17
|
+
counts.set(`${m[2]} ${m[3]}`, Number(m[1]));
|
|
18
|
+
rules.add(m[2]);
|
|
19
|
+
}
|
|
20
|
+
return { counts, rules, exists: true };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// A rule baselined before only banks: its debt may drop or go, never appear or grow.
|
|
24
|
+
export function mergeBaseline(was, now, judged) {
|
|
25
|
+
const merged = new Map([...was.counts].filter(([key]) => !judged.has(ruleOf(key))));
|
|
26
|
+
for (const [key, n] of was.counts) {
|
|
27
|
+
if (judged.has(ruleOf(key)) && now.has(key)) merged.set(key, Math.min(n, now.get(key)));
|
|
28
|
+
}
|
|
29
|
+
for (const [key, n] of now) {
|
|
30
|
+
if (judged.has(ruleOf(key)) && !was.rules.has(ruleOf(key))) merged.set(key, n);
|
|
31
|
+
}
|
|
32
|
+
return merged;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function writeBaseline(path, merged, rules) {
|
|
36
|
+
const lines = [...merged].map(([key, n]) => `${n} ${key}`).sort();
|
|
37
|
+
writeFileSync(path, [`# rules: ${[...rules].sort().join(" ")}`, ...lines, ""].join("\n"));
|
|
38
|
+
return lines.length;
|
|
39
|
+
}
|
package/hooks/ship-gate-files.sh
CHANGED
|
@@ -20,7 +20,7 @@ changed_tests() { # changed_tests <family>
|
|
|
20
20
|
files_context() { # files_context <owner> <owned files>
|
|
21
21
|
fam=$(family_of "$1/.")
|
|
22
22
|
{ printf '%s\n' "$PROJECT_DIR" "$1" "$(git rev-parse "$BASE" 2>/dev/null)"
|
|
23
|
-
cat "$HERE/ship-gate.sh" "$HERE/ship-gate-projects.sh" "$HERE/ship-gate-files.sh"
|
|
23
|
+
cat "$HERE/ship-gate.sh" "$HERE/ship-gate-projects.sh" "$HERE/ship-gate-files.sh" "$HERE/gate-config.sh" "$HERE/line-limit.sh"
|
|
24
24
|
printf '%s\n' "$CHANGED" | grep -vE '\.(md|mdx|txt|rst|adoc)$' | grep -vE "$TEST_RE" \
|
|
25
25
|
| grep -vxF -f <(printf '%s\n' "$2") | while IFS= read -r f; do
|
|
26
26
|
[ -n "$f" ] || continue
|
|
@@ -139,7 +139,7 @@ project_key() { # project_key <owner> <tool>
|
|
|
139
139
|
fam=$(family_of "$1/.")
|
|
140
140
|
{ printf '%s\n' "$PROJECT_DIR" "$1" "$2" "$(git rev-parse "$BASE" 2>/dev/null)" \
|
|
141
141
|
"$GAUNTLET_NO_MUTATE" "$GAUNTLET_IGNORE_FILES"
|
|
142
|
-
cat "$HERE/ship-gate.sh" "$HERE/ship-gate-projects.sh" "$HERE/ship-gate-files.sh"
|
|
142
|
+
cat "$HERE/ship-gate.sh" "$HERE/ship-gate-projects.sh" "$HERE/ship-gate-files.sh" "$HERE/gate-config.sh" "$HERE/line-limit.sh"
|
|
143
143
|
printf '%s\n' "$CHANGED" | grep -vE '\.(md|mdx|txt|rst|adoc)$' | while IFS= read -r f; do
|
|
144
144
|
[ -n "$f" ] || continue
|
|
145
145
|
case "$(family_of "$f")" in "$fam"|any) printf '%s\n' "$f"; [ -f "$f" ] && cat "$f" ;; esac
|
|
@@ -107,6 +107,21 @@ structure_fail() {
|
|
|
107
107
|
# An agent that pipes the gate through `tail` cuts the findings off; the last line still says where they are.
|
|
108
108
|
gate_report() {
|
|
109
109
|
REPORT="/tmp/claude-shipgate-$1.log"
|
|
110
|
-
exec > >(tee "$REPORT") 2>&1
|
|
111
|
-
|
|
110
|
+
exec 3>&1 4>&2 > >(tee "$REPORT") 2>&1
|
|
111
|
+
TEE_PID=${!:-} # bash before 4.4 does not set $! for process substitution
|
|
112
|
+
trap 'gate_exit $?' EXIT
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
# A FAIL goes to the diary /add-rule reads, so a test gap the gate keeps finding can become a rule.
|
|
116
|
+
gate_exit() {
|
|
117
|
+
echo "ship-gate: full output in $REPORT"
|
|
118
|
+
[ "$1" = 1 ] || return 0
|
|
119
|
+
exec 1>&3 2>&4
|
|
120
|
+
# A child left running (a dev server) can hold tee open forever; give it 2s, not a hang.
|
|
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
|
+
local diary; diary=$(diary_dir "$HERE")
|
|
123
|
+
mkdir -p "$diary"
|
|
124
|
+
printf '{"ts":"%s","branch":"%s","status":"FAIL","report":"%s"}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
125
|
+
"$(git branch --show-current 2>/dev/null | json_string)" "$(json_string < "$REPORT")" \
|
|
126
|
+
>> "$diary/gate-findings.jsonl"
|
|
112
127
|
}
|
package/hooks/ship-gate.sh
CHANGED
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
set -uo pipefail
|
|
48
48
|
|
|
49
49
|
HERE=$(cd "$(dirname "$0")" && pwd)
|
|
50
|
-
. "$HERE/ship-gate-projects.sh" && . "$HERE/ship-gate-structure.sh" && . "$HERE/ship-gate-files.sh" || { echo "ship-gate: cannot read its helpers in $HERE"; exit 1; }
|
|
50
|
+
. "$HERE/gate-config.sh" && . "$HERE/ship-gate-projects.sh" && . "$HERE/ship-gate-structure.sh" && . "$HERE/ship-gate-files.sh" || { echo "ship-gate: cannot read its helpers in $HERE"; exit 1; }
|
|
51
51
|
|
|
52
52
|
MODE=""
|
|
53
53
|
case "${1:-}" in
|
|
@@ -73,20 +73,7 @@ if [ -n "$ROOT" ]; then
|
|
|
73
73
|
PROJECT_DIR="$ROOT"
|
|
74
74
|
fi
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
GAUNTLET_MUTATE=""
|
|
78
|
-
GAUNTLET_SOURCE_EXT="ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cpp|hpp|cs|swift|gd"
|
|
79
|
-
GAUNTLET_IGNORE_EXT="md|mdx|txt|rst|adoc|jsonc?|ya?ml|toml|lock|cfg|ini|env|csv|tsv|sql|html|css|scss|svg|png|jpg|jpeg|gif|webp|ico|pdf|woff2?|ttf|otf|mp3|mp4|wav|zip|gz|tres|tscn|import|godot"
|
|
80
|
-
GAUNTLET_IGNORE_FILES="*.gen.ts *.gen.tsx *.generated.* */migrations/*.py */alembic/versions/*.py */components/ui/*.tsx */hooks/use-mobile.ts */*.config.*"
|
|
81
|
-
# Length-checked and skill-audited like everything else, but not mutated.
|
|
82
|
-
# Mutation pays on logic and burns time on presentation: a component's mutants
|
|
83
|
-
# are class names, copy and JSX shape, none of which is behaviour, and one
|
|
84
|
-
# session spent an evening killing them. Logic that lives inside a component is
|
|
85
|
-
# logic that wants extracting — which the 200-line rule already pushes toward.
|
|
86
|
-
# Override in .claude/gauntlet.conf to mutate components too.
|
|
87
|
-
GAUNTLET_NO_MUTATE='\.(tsx|jsx)$'
|
|
88
|
-
[ -n "${HOME:-}" ] && [ -f "$HOME/.claude/gauntlet.conf" ] && . "$HOME/.claude/gauntlet.conf"
|
|
89
|
-
CONF=.claude/gauntlet.conf; [ -f "$CONF" ] || CONF="$HERE/../gauntlet.conf"; [ -f "$CONF" ] && . "$CONF"
|
|
76
|
+
load_gauntlet_conf .claude/gauntlet.conf "$HERE/../gauntlet.conf"
|
|
90
77
|
|
|
91
78
|
# ------------------------------------------------------------------- the scope
|
|
92
79
|
BASE="${1:-}"
|
|
@@ -135,17 +122,6 @@ SOURCED=$(printf '%s\n' "$CHANGED" | grep -E "\.($GAUNTLET_SOURCE_EXT)$" \
|
|
|
135
122
|
# uncovered line started failing the gate: vitest.config.ts has no tests, will
|
|
136
123
|
# never have tests, and is not a thing anyone writes a test for. Left in, every
|
|
137
124
|
# diff that touches a config reports findings nobody can close.
|
|
138
|
-
# set -f matters: an unquoted list is glob-expanded before it is split, so
|
|
139
|
-
# */components/ui/*.tsx turned itself into the very path it was meant to match
|
|
140
|
-
# and then matched nothing.
|
|
141
|
-
is_ignored() {
|
|
142
|
-
set -f
|
|
143
|
-
for pat in $GAUNTLET_IGNORE_FILES; do
|
|
144
|
-
case "/$1" in $pat) set +f; return 0 ;; esac
|
|
145
|
-
done
|
|
146
|
-
set +f
|
|
147
|
-
return 1
|
|
148
|
-
}
|
|
149
125
|
FILES=$(printf '%s\n' "$SOURCED" | while IFS= read -r f; do
|
|
150
126
|
[ -n "$f" ] && ! is_ignored "$f" && printf '%s\n' "$f"
|
|
151
127
|
done)
|
|
@@ -190,7 +166,7 @@ if [ "$GAUNTLET_MAX_LINES" -gt 0 ]; then
|
|
|
190
166
|
while IFS= read -r f; do
|
|
191
167
|
n=$(wc -l < "$f")
|
|
192
168
|
[ "$n" -gt "$LARGEST" ] && LARGEST=$n
|
|
193
|
-
|
|
169
|
+
out=$(LINE_LIMIT_ROOT="$PWD" bash "$HERE/line-limit.sh" --file "$f") || OVER="$OVER$(printf '%s\n' "$out" | grep -E '^ .* — [0-9]+ lines')
|
|
194
170
|
"
|
|
195
171
|
done <<< "$FILES"
|
|
196
172
|
fi
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
# SubagentStop: appends each gauntlet-skills verdict to .claude/skill-flags.jsonl.
|
|
3
2
|
|
|
4
3
|
INPUT=$(cat | tr -d '\n')
|
|
5
4
|
|
|
6
5
|
case "$INPUT" in
|
|
7
|
-
*'"agent_type":"gauntlet-skills"'*) ;;
|
|
6
|
+
*'"agent_type":"gauntlet-skills"'*) LOG=skill-flags.jsonl ;;
|
|
7
|
+
*'\"failure_scenario\"'*) LOG=review-findings.jsonl ;;
|
|
8
8
|
*) exit 0 ;;
|
|
9
9
|
esac
|
|
10
10
|
|
|
@@ -19,7 +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
|
-
|
|
23
|
-
|
|
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)
|
|
24
|
+
printf '{"ts":"%s","branch":"%s","model":"%s","reply":"%s"}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$BRANCH" "$MODEL" "$REPLY" \
|
|
25
|
+
>> "$(diary_dir "$SCRIPT_DIR")/$LOG"
|
|
24
26
|
|
|
25
27
|
exit 0
|
package/hooks/skill-report.mjs
CHANGED
|
@@ -1,18 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
69
|
+
|
|
70
|
+
const audits = tally(readLines(resolve(DIR, "skill-flags.jsonl")));
|
|
71
|
+
console.log(render(audits) + renderBlocks(tallyBlocks(readLines(resolve(DIR, "edit-blocks.jsonl")))));
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
export function loadEditRules(root) {
|
|
6
|
+
const file = resolve(root, ".edit-rules.json");
|
|
7
|
+
const { text = [], lessons = [] } = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
|
|
8
|
+
const withFiles = (r) => ({ ...r, applies: (f) => new RegExp(r.files).test(f) });
|
|
9
|
+
return { text: text.map(withFiles), lessons: lessons.map(withFiles) };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function matchLines(rule, content) {
|
|
13
|
+
const re = new RegExp(rule.pattern, `g${(rule.flags ?? "").replace("g", "")}`);
|
|
14
|
+
return [...content.matchAll(re)].map((m) => content.slice(0, m.index).split("\n").length);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function textFindings(rules, root, rel) {
|
|
18
|
+
const own = rules.filter((r) => r.applies(rel));
|
|
19
|
+
const path = resolve(root, rel);
|
|
20
|
+
if (own.length === 0 || !existsSync(path)) return [];
|
|
21
|
+
const content = readFileSync(path, "utf8");
|
|
22
|
+
return own.flatMap((r) => matchLines(r, content).map((line) => ({ rel, rule: r.id, line, message: r.message })));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function projectFiles(root) {
|
|
26
|
+
const args = ["ls-files", "--cached", "--others", "--exclude-standard"];
|
|
27
|
+
const run = spawnSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: 1 << 28 });
|
|
28
|
+
return run.status === 0 ? run.stdout.split("\n").filter(Boolean) : walk(root, "");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function walk(root, dir) {
|
|
32
|
+
return readdirSync(resolve(root, dir), { withFileTypes: true }).flatMap((e) => {
|
|
33
|
+
if (e.name === "node_modules" || e.name === ".git") return [];
|
|
34
|
+
const rel = dir ? `${dir}/${e.name}` : e.name;
|
|
35
|
+
return e.isDirectory() ? walk(root, rel) : [rel];
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function brokenRules(rules) {
|
|
40
|
+
const broken = [];
|
|
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`);
|
|
43
|
+
if (matchLines(r, r.bad ?? "").length === 0) broken.push(`${r.id}: its bad example is not caught`);
|
|
44
|
+
if (matchLines(r, r.good ?? "").length > 0) broken.push(`${r.id}: its good example is flagged`);
|
|
45
|
+
}
|
|
46
|
+
return broken;
|
|
47
|
+
}
|
package/lib/hook-settings.mjs
CHANGED
|
@@ -12,6 +12,7 @@ const SHIP_GATE_FILENAME = "ship-gate.sh";
|
|
|
12
12
|
const SHIP_GATE_HOOK_FILENAME = "ship-gate-hook.sh";
|
|
13
13
|
const VERSION_CHECK_FILENAME = "version-check.sh";
|
|
14
14
|
const SKILL_FLAGS_FILENAME = "skill-flags-record.sh";
|
|
15
|
+
const EDIT_CHECK_FILENAME = "edit-check.sh";
|
|
15
16
|
const LEGACY_EVAL_FILENAME = "skill-forced-eval-hook.sh";
|
|
16
17
|
const LEGACY_AUDIT_RUNNER_FILENAME = "skill-audit-runner.sh";
|
|
17
18
|
|
|
@@ -96,6 +97,12 @@ export async function registerHooks(settingsPath) {
|
|
|
96
97
|
settings.hooks.PostToolUse = [autoMarkEntry];
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
const editCheckEntry = {
|
|
101
|
+
matcher: "Write|Edit|MultiEdit",
|
|
102
|
+
hooks: [{ type: "command", command: `$CLAUDE_PROJECT_DIR/.claude/hooks/${EDIT_CHECK_FILENAME}` }],
|
|
103
|
+
};
|
|
104
|
+
upsert(settings.hooks.PostToolUse, EDIT_CHECK_FILENAME, editCheckEntry);
|
|
105
|
+
|
|
99
106
|
// Register SessionStart version check. No matcher: startup and resume both
|
|
100
107
|
// want it, and the script's own daily stamp keeps a burst of resumes quiet.
|
|
101
108
|
const versionCheckCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${VERSION_CHECK_FILENAME}`;
|
package/lib/setup-hook.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { registerHooks } from "./hook-settings.mjs";
|
|
2
2
|
import { mkdir, writeFile, readFile, chmod, unlink } from "node:fs/promises";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
3
4
|
import { join, resolve, dirname } from "node:path";
|
|
4
|
-
import { spawn } from "node:child_process";
|
|
5
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
6
|
import { homedir } from "node:os";
|
|
6
7
|
|
|
7
8
|
// Installs the hook scripts and registers them in .claude/settings.json.
|
|
@@ -17,17 +18,22 @@ import { homedir } from "node:os";
|
|
|
17
18
|
// explicitly applied before the first edit
|
|
18
19
|
// gauntlet.sh Stop — fast typecheck and tests on the diff
|
|
19
20
|
// ship-gate.sh file length and mutation, behind an exit code
|
|
21
|
+
// gate-config.sh sourced by ship-gate.sh — shared helpers
|
|
20
22
|
// ship-gate-projects.sh sourced by ship-gate.sh — project detection, reuse
|
|
21
23
|
// ship-gate-hook.sh PreToolUse on Bash — no commit without a receipt
|
|
22
24
|
// version-check.sh SessionStart — a line when a newer catalog exists
|
|
23
25
|
// skill-flags-record.sh SubagentStop — logs each skills-audit verdict
|
|
24
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
|
|
28
|
+
// edit-check.sh PostToolUse — line-limit.sh, lint-ratchet.mjs and
|
|
29
|
+
// text-rules.mjs on each edited file
|
|
25
30
|
|
|
26
31
|
const GATE_FILENAME = "skill-gate.sh";
|
|
27
32
|
const AUTO_MARK_FILENAME = "skill-gate-automark.sh";
|
|
28
33
|
const APPLICATION_GATE_FILENAME = "skill-application-gate.sh";
|
|
29
34
|
const GAUNTLET_FILENAME = "gauntlet.sh";
|
|
30
35
|
const SHIP_GATE_FILENAME = "ship-gate.sh";
|
|
36
|
+
const GATE_CONFIG_FILENAME = "gate-config.sh";
|
|
31
37
|
const SHIP_GATE_PROJECTS_FILENAME = "ship-gate-projects.sh";
|
|
32
38
|
const SHIP_GATE_STRUCTURE_FILENAME = "ship-gate-structure.sh";
|
|
33
39
|
const SHIP_GATE_FILES_FILENAME = "ship-gate-files.sh";
|
|
@@ -35,6 +41,13 @@ const SHIP_GATE_HOOK_FILENAME = "ship-gate-hook.sh";
|
|
|
35
41
|
const VERSION_CHECK_FILENAME = "version-check.sh";
|
|
36
42
|
const SKILL_FLAGS_FILENAME = "skill-flags-record.sh";
|
|
37
43
|
const SKILL_REPORT_FILENAME = "skill-report.mjs";
|
|
44
|
+
const EDIT_CHECK_FILENAME = "edit-check.sh";
|
|
45
|
+
const LINE_LIMIT_FILENAME = "line-limit.sh";
|
|
46
|
+
const LINT_RATCHET_FILENAME = "lint-ratchet.mjs";
|
|
47
|
+
const TEXT_RULES_FILENAME = "text-rules.mjs";
|
|
48
|
+
const RATCHET_BASELINE_FILENAME = "ratchet-baseline.mjs";
|
|
49
|
+
const DIARY_CHECK_FILENAME = "diary-check.mjs";
|
|
50
|
+
const DIARY_LIB_FILENAME = "diary-lib.mjs";
|
|
38
51
|
const LEGACY_EVAL_FILENAME = "skill-forced-eval-hook.sh";
|
|
39
52
|
const LEGACY_AUDIT_RUNNER_FILENAME = "skill-audit-runner.sh";
|
|
40
53
|
|
|
@@ -99,6 +112,20 @@ export async function writeGauntletConf(targetDir, testCommand) {
|
|
|
99
112
|
return confPath;
|
|
100
113
|
}
|
|
101
114
|
|
|
115
|
+
// Before the edit hook is registered, so code that predates it never blocks an edit.
|
|
116
|
+
// Only rules never baselined are recorded: re-recording a known one would bank growth.
|
|
117
|
+
function writeBaselines(dir, hooksDir) {
|
|
118
|
+
const gitRoot = spawnSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], { encoding: "utf8" }).stdout?.trim();
|
|
119
|
+
const root = gitRoot || dir;
|
|
120
|
+
if (!existsSync(join(root, ".line-limit-baseline"))) {
|
|
121
|
+
spawnSync("bash", [join(hooksDir, LINE_LIMIT_FILENAME), "--baseline"],
|
|
122
|
+
{ cwd: root, env: { ...process.env, LINE_LIMIT_ROOT: root }, stdio: "ignore" });
|
|
123
|
+
}
|
|
124
|
+
console.log(" Recording old code as baseline debt (runs ESLint once per new rule)...");
|
|
125
|
+
spawnSync(process.execPath, [join(hooksDir, LINT_RATCHET_FILENAME), "--baseline-new"],
|
|
126
|
+
{ cwd: dir, stdio: "ignore", timeout: 600_000 });
|
|
127
|
+
}
|
|
128
|
+
|
|
102
129
|
export async function setupHook(targetDir = process.cwd()) {
|
|
103
130
|
const resolved = resolve(targetDir);
|
|
104
131
|
const hooksDir = join(resolved, ".claude", "hooks");
|
|
@@ -111,6 +138,7 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
111
138
|
APPLICATION_GATE_FILENAME,
|
|
112
139
|
GAUNTLET_FILENAME,
|
|
113
140
|
SHIP_GATE_FILENAME,
|
|
141
|
+
GATE_CONFIG_FILENAME,
|
|
114
142
|
SHIP_GATE_PROJECTS_FILENAME,
|
|
115
143
|
SHIP_GATE_STRUCTURE_FILENAME,
|
|
116
144
|
SHIP_GATE_FILES_FILENAME,
|
|
@@ -118,6 +146,13 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
118
146
|
VERSION_CHECK_FILENAME,
|
|
119
147
|
SKILL_FLAGS_FILENAME,
|
|
120
148
|
SKILL_REPORT_FILENAME,
|
|
149
|
+
EDIT_CHECK_FILENAME,
|
|
150
|
+
LINE_LIMIT_FILENAME,
|
|
151
|
+
LINT_RATCHET_FILENAME,
|
|
152
|
+
TEXT_RULES_FILENAME,
|
|
153
|
+
RATCHET_BASELINE_FILENAME,
|
|
154
|
+
DIARY_CHECK_FILENAME,
|
|
155
|
+
DIARY_LIB_FILENAME,
|
|
121
156
|
]) {
|
|
122
157
|
const dest = join(hooksDir, name);
|
|
123
158
|
await writeFile(dest, await readHookSource(name), { mode: 0o755 });
|
|
@@ -131,6 +166,7 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
131
166
|
// not present — fine
|
|
132
167
|
}
|
|
133
168
|
|
|
169
|
+
writeBaselines(resolved, hooksDir);
|
|
134
170
|
await registerHooks(settingsPath);
|
|
135
171
|
|
|
136
172
|
console.log(` Hook installed: .claude/hooks/${GATE_FILENAME}`);
|
|
@@ -140,5 +176,6 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
140
176
|
console.log(` Installed: .claude/hooks/${SHIP_GATE_FILENAME} (used by /ship)`);
|
|
141
177
|
console.log(` Hook installed: .claude/hooks/${SHIP_GATE_HOOK_FILENAME}`);
|
|
142
178
|
console.log(` Hook installed: .claude/hooks/${SKILL_FLAGS_FILENAME}`);
|
|
179
|
+
console.log(" Hook installed: .claude/hooks/edit-check.sh (baselines: .line-limit-baseline, .lint-baseline)");
|
|
143
180
|
console.log(` Settings updated: .claude/settings.json`);
|
|
144
181
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spardutti/claude-skills",
|
|
3
|
-
"version": "2.
|
|
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/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 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": {
|