@spardutti/claude-skills 2.62.1 → 2.64.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/hooks/registry-lib.mjs +11 -0
- package/hooks/registry.mjs +26 -26
- package/hooks/ship-log-parse.mjs +15 -0
- package/hooks/ship-log.mjs +18 -18
- package/hooks/ship-prep.sh +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -135,7 +135,7 @@ Portable slash commands installed to `.claude/commands/`. Some orchestrate paral
|
|
|
135
135
|
| `/ship` | Unified delivery pipeline — commit → gate → PR → merge → release. The gate is the one enforcement moment: it checks the diff's file lengths and that new files sit in their kind folder, audits it against the skills installed in the project, mutation-tests the changed lines to prove the tests would catch a break, and runs one `/code-review` round for logic bugs — **fixing what it finds** rather than handing you a list. It runs before the **PR**, not before every commit: its scope is the whole branch, so gating each commit re-mutated every file the branch had ever touched. `--force` skips it. No argument steps through interactively; `/ship pr` runs through PR creation; `/ship release` runs the full pipeline |
|
|
136
136
|
| `/discover` | Find the right problem before deciding what to build — diverges first: generates competing framings through blind subagents under forced constraints, stress-tests the winner with a blind critic, and refuses to converge until every open question is answered or deferred, then drafts scope, non-goals, edge cases and success criteria for you to correct. Run before `/plan-feature` |
|
|
137
137
|
| `/plan-feature` | Integration-first feature planning, then building — one agent scans the repo for reusable code, patterns and touch points, grounded clarifying questions follow, the plan is written as type-checked stubs at the real paths, and on your go it fills them in. It reads every `*.registry.yaml` first and must mark each one reuse or skip |
|
|
138
|
-
| `/registry` | Record the project's reusable systems — aiming methods, a state machine, an effect pipeline — as a small `<name>.registry.yaml` next to their code, so `/plan-feature` extends them instead of rebuilding them. You name the system, it drafts the entry for your yes. `/registry eval` lists existing code that does an entry's job outside its folder |
|
|
138
|
+
| `/registry` | Record the project's reusable systems — aiming methods, a state machine, an effect pipeline — as a small `<name>.registry.yaml` next to their code, so `/plan-feature` extends them instead of rebuilding them. You name the system, it drafts the entry for your yes. `/registry eval` lists existing code that does an entry's job outside its folder, and `/registry update` redrafts every entry against today's code |
|
|
139
139
|
| `/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 |
|
|
140
140
|
| `/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 |
|
|
141
141
|
| `/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 |
|
package/hooks/registry-lib.mjs
CHANGED
|
@@ -62,6 +62,15 @@ export function symbolsOf(extendBy = "") {
|
|
|
62
62
|
return [...new Set([...ticked, ...shaped])];
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
export function planMarks(planPath) {
|
|
66
|
+
const text = existsSync(planPath) ? readFileSync(planPath, "utf8") : null;
|
|
67
|
+
if (text === null) return null;
|
|
68
|
+
const section = text.split(/^## /m).find((s) => /^Registry\s*$/m.test(s.split("\n")[0])) ?? "";
|
|
69
|
+
const marks = new Map();
|
|
70
|
+
for (const m of section.matchAll(/^[-*]\s*`?([\w.-]+)`?\s*:\s*(reuse|skip)\b/gim)) marks.set(m[1], m[2].toLowerCase());
|
|
71
|
+
return marks;
|
|
72
|
+
}
|
|
73
|
+
|
|
65
74
|
export const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
66
75
|
|
|
67
76
|
export function triggerHits(entry, text) {
|
|
@@ -74,6 +83,8 @@ export function insideLocation(entry, file) {
|
|
|
74
83
|
return loc !== "" && (file === loc || file.startsWith(loc.replace(/\/?$/, "/")));
|
|
75
84
|
}
|
|
76
85
|
|
|
86
|
+
export const isAllowed = (entry, file) => entry.allowed.some((a) => (a.endsWith("/") ? file.startsWith(a) : file === a));
|
|
87
|
+
|
|
77
88
|
// Only what the stub added: an extended file's old code would trip triggers it never wrote.
|
|
78
89
|
export function addedText(root, file) {
|
|
79
90
|
const tracked = spawnSync("git", ["-C", root, "ls-files", "--error-unmatch", file], { encoding: "utf8" });
|
package/hooks/registry.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
3
|
import { dirname, join, relative, resolve } from "node:path";
|
|
4
4
|
import {
|
|
5
|
-
REGISTRY_SUFFIX, addedText, escape, insideLocation, loadRegistry, projectFiles, projectRoot, symbolsOf, triggerHits,
|
|
5
|
+
REGISTRY_SUFFIX, addedText, escape, insideLocation, isAllowed, loadRegistry, planMarks, projectFiles, projectRoot, symbolsOf, triggerHits,
|
|
6
6
|
} from "./registry-lib.mjs";
|
|
7
7
|
import { loadEditRules } from "./text-rules.mjs";
|
|
8
8
|
|
|
@@ -26,15 +26,6 @@ function list() {
|
|
|
26
26
|
stale();
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
function planMarks(planPath) {
|
|
30
|
-
const text = existsSync(planPath) ? readFileSync(planPath, "utf8") : null;
|
|
31
|
-
if (text === null) return null;
|
|
32
|
-
const section = text.split(/^## /m).find((s) => /^Registry\s*$/m.test(s.split("\n")[0])) ?? "";
|
|
33
|
-
const marks = new Map();
|
|
34
|
-
for (const m of section.matchAll(/^[-*]\s*`?([\w.-]+)`?\s*:\s*(reuse|skip)\b/gim)) marks.set(m[1], m[2].toLowerCase());
|
|
35
|
-
return marks;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
29
|
function checkPlan(planPath, stubs) {
|
|
39
30
|
if (!entries.length) return console.log("registry ok — no entries");
|
|
40
31
|
const marks = planMarks(planPath);
|
|
@@ -59,11 +50,15 @@ function checkPlan(planPath, stubs) {
|
|
|
59
50
|
if (!asks.length) console.log("registry ok");
|
|
60
51
|
}
|
|
61
52
|
|
|
53
|
+
function planMissing() {
|
|
54
|
+
if (entries.length && !planMarks(join(root, "PLAN.md"))?.size) console.log(`registry unplanned — ${entries.length} entries, no marks in PLAN.md`);
|
|
55
|
+
}
|
|
56
|
+
|
|
62
57
|
function evaluate() {
|
|
63
58
|
if (!entries.length) return console.log(`registry none — no *${REGISTRY_SUFFIX} in this project`);
|
|
64
59
|
for (const e of entries) {
|
|
65
60
|
if (!e.does.length) { console.log(`registry eval ${e.name}: skipped, no does`); continue; }
|
|
66
|
-
const outside = files.filter((f) => !insideLocation(e, f) && !e
|
|
61
|
+
const outside = files.filter((f) => !insideLocation(e, f) && !isAllowed(e, f) && !f.endsWith(REGISTRY_SUFFIX));
|
|
67
62
|
const hits = outside.flatMap((f) => readFileSync(join(root, f), "utf8").split("\n").flatMap((line, i) =>
|
|
68
63
|
e.does.filter((code) => calls(line, code)).map((code) => `registry SUSPECT ${e.name} ${f}:${i + 1} ${code}`)));
|
|
69
64
|
console.log(hits.length ? hits.join("\n") : `registry eval ${e.name}: nothing outside ${e.location} does its job`);
|
|
@@ -87,24 +82,28 @@ function stale() {
|
|
|
87
82
|
if (/^\s+-\s/m.test(e.text)) say("write lists on one line, like triggers: [aim, target]");
|
|
88
83
|
if (seen.has(e.name)) say(`name ${e.name} is used twice`);
|
|
89
84
|
seen.add(e.name);
|
|
90
|
-
|
|
91
|
-
if (!e.location || !existsSync(loc)) { say(`location ${e.location} not found`); continue; }
|
|
92
|
-
const near = statSync(loc).isDirectory() ? loc : dirname(loc);
|
|
93
|
-
if (e.example && !existsSync(join(near, e.example)) && !existsSync(join(root, e.example))) say(`example ${e.example} not found`);
|
|
94
|
-
const syms = symbolsOf(e.extend_by);
|
|
95
|
-
if (!syms.length) say("extend_by names no symbol — write names like `AimStrategy`");
|
|
96
|
-
const code = files.filter((f) => insideLocation(e, f) && !f.endsWith(REGISTRY_SUFFIX))
|
|
97
|
-
.map((f) => readFileSync(join(root, f), "utf8")).join("\n");
|
|
98
|
-
for (const s of syms) {
|
|
99
|
-
// A plain word like `first` matches `var first = enemies[0]`; `Aim.first` only matches real use.
|
|
100
|
-
if (/^[a-z][a-z0-9]*$/.test(s)) say(`\`${s}\` is too common a word to prove use — name its owner, like \`Owner.${s}\``);
|
|
101
|
-
else if (!s.split(".").every((part) => uses(code, part))) say(`${s} from extend_by is not found in ${e.location}`);
|
|
102
|
-
}
|
|
103
|
-
for (const c of e.does) if (!calls(code, c)) say(`does ${c} is not found in ${e.location}`);
|
|
85
|
+
staleCode(e, say);
|
|
104
86
|
}
|
|
105
87
|
if (problems.length) fail(problems);
|
|
106
88
|
}
|
|
107
89
|
|
|
90
|
+
function staleCode(e, say) {
|
|
91
|
+
const loc = join(root, e.location ?? "");
|
|
92
|
+
if (!e.location || !existsSync(loc)) return say(`location ${e.location} not found`);
|
|
93
|
+
const near = statSync(loc).isDirectory() ? loc : dirname(loc);
|
|
94
|
+
if (e.example && !existsSync(join(near, e.example)) && !existsSync(join(root, e.example))) say(`example ${e.example} not found`);
|
|
95
|
+
const syms = symbolsOf(e.extend_by);
|
|
96
|
+
if (!syms.length) say("extend_by names no symbol — write names like `AimStrategy`");
|
|
97
|
+
const code = files.filter((f) => insideLocation(e, f) && !f.endsWith(REGISTRY_SUFFIX))
|
|
98
|
+
.map((f) => readFileSync(join(root, f), "utf8")).join("\n");
|
|
99
|
+
for (const s of syms) {
|
|
100
|
+
// A plain word like `first` matches `var first = enemies[0]`; `Aim.first` only matches real use.
|
|
101
|
+
if (/^[a-z][a-z0-9]*$/.test(s)) say(`\`${s}\` is too common a word to prove use — name its owner, like \`Owner.${s}\``);
|
|
102
|
+
else if (!s.split(".").every((part) => uses(code, part))) say(`${s} from extend_by is not found in ${e.location}`);
|
|
103
|
+
}
|
|
104
|
+
for (const c of e.does) if (!calls(code, c)) say(`does ${c} is not found in ${e.location}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
108
107
|
function fail(lines) {
|
|
109
108
|
for (const line of lines) console.log(line);
|
|
110
109
|
process.exitCode = 1;
|
|
@@ -113,9 +112,10 @@ function fail(lines) {
|
|
|
113
112
|
const [mode, ...rest] = process.argv.slice(2);
|
|
114
113
|
if (mode === undefined) list();
|
|
115
114
|
else if (mode === "--plan" && rest[0]) checkPlan(resolve(rest[0]), rest.slice(1));
|
|
115
|
+
else if (mode === "--plan-missing") planMissing();
|
|
116
116
|
else if (mode === "--stale") stale();
|
|
117
117
|
else if (mode === "--eval") evaluate();
|
|
118
118
|
else {
|
|
119
|
-
console.error("usage: registry.mjs [--plan PLAN.md <stub files…> | --stale | --eval]");
|
|
119
|
+
console.error("usage: registry.mjs [--plan PLAN.md <stub files…> | --plan-missing | --stale | --eval]");
|
|
120
120
|
process.exit(2);
|
|
121
121
|
}
|
package/hooks/ship-log-parse.mjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { parseLine, readLines } from "./diary-lib.mjs";
|
|
5
|
+
import { planMarks } from "./registry-lib.mjs";
|
|
3
6
|
|
|
4
7
|
const PROJECT = /^ {2}(\S.*?) (stryker|mutmut|config)(?: \((\d+)s\))? — (.*)$/;
|
|
5
8
|
const SURVIVOR = /^ {6}\[(Survived|NoCoverage)\] (\S+)\s+(\S+)$/;
|
|
@@ -100,3 +103,15 @@ export function prSize(base, testRe, sourceExt) {
|
|
|
100
103
|
}
|
|
101
104
|
return size;
|
|
102
105
|
}
|
|
106
|
+
|
|
107
|
+
export function planRegistry(root) {
|
|
108
|
+
const marks = planMarks(join(root, "PLAN.md"));
|
|
109
|
+
return marks && Object.fromEntries(marks);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Worktrees share one diary, so only this branch's blocks during the ship count.
|
|
113
|
+
export function ruleBlocks(diary, branch, since, until) {
|
|
114
|
+
return readLines(join(diary, "edit-blocks.jsonl")).map(parseLine)
|
|
115
|
+
.filter((r) => r?.branch === branch && r.ts >= since && r.ts <= until)
|
|
116
|
+
.map(({ ts, file, rules }) => ({ ts, file, rules: [].concat(rules) }));
|
|
117
|
+
}
|
package/hooks/ship-log.mjs
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import { parseLine, readLines } from "./diary-lib.mjs";
|
|
6
|
+
import { git, parseReport, planRegistry, prSize, ruleBlocks } from "./ship-log-parse.mjs";
|
|
6
7
|
import { printReport, tally } from "./ship-log-report.mjs";
|
|
7
8
|
|
|
8
9
|
const HERE = dirname(resolve(process.argv[1]));
|
|
@@ -58,13 +59,14 @@ function settleSurvivors(record) {
|
|
|
58
59
|
}
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
function finalize(
|
|
62
|
+
function finalize(c, path, record, tag, status, reason, at) {
|
|
62
63
|
settleSurvivors(record);
|
|
63
64
|
Object.assign(record, { status, reason: reason ?? null, finished: at ?? now() });
|
|
65
|
+
record.rules = ruleBlocks(c.diary, record.branch, record.started, record.finished);
|
|
64
66
|
record.summary = summarize(record);
|
|
65
67
|
const stem = `${record.app}_${record.finished.slice(0, 10)}_${tag}`;
|
|
66
|
-
let out = join(dir, `${stem}.json`);
|
|
67
|
-
for (let i = 2; existsSync(out); i++) out = join(dir, `${stem}-${i}.json`);
|
|
68
|
+
let out = join(c.dir, `${stem}.json`);
|
|
69
|
+
for (let i = 2; existsSync(out); i++) out = join(c.dir, `${stem}-${i}.json`);
|
|
68
70
|
save(path, record);
|
|
69
71
|
renameSync(path, out);
|
|
70
72
|
console.log(`ship-log: saved ${out}`);
|
|
@@ -78,8 +80,8 @@ function prOf(branch) {
|
|
|
78
80
|
// Agents skip the `pr` call, so a log whose branch has a PR by now is closed at that PR.
|
|
79
81
|
// A PR older than the log was only updated by it, so that log ends at its own last event.
|
|
80
82
|
// A long-lived branch like dev keeps its merged PRs, so an old one that is not open is not this log's.
|
|
81
|
-
function closeOpened(
|
|
82
|
-
const open = join(dir, "open");
|
|
83
|
+
function closeOpened(c, app) {
|
|
84
|
+
const open = join(c.dir, "open");
|
|
83
85
|
for (const f of existsSync(open) ? readdirSync(open).filter((n) => n.startsWith(`${app}__`)) : []) {
|
|
84
86
|
const path = join(open, f), record = JSON.parse(readFileSync(path, "utf8"));
|
|
85
87
|
const pr = prOf(record.branch), empty = !record.gate.length && !record.findings.length;
|
|
@@ -88,16 +90,16 @@ function closeOpened(dir, app) {
|
|
|
88
90
|
if (empty) continue;
|
|
89
91
|
const opened = pr && pr.createdAt >= record.started;
|
|
90
92
|
if (!pr || (!opened && pr.state !== "OPEN")) continue;
|
|
91
|
-
finalize(
|
|
93
|
+
finalize(c, path, Object.assign(record, { pr: pr.number }), `pr${pr.number}`,
|
|
92
94
|
opened ? "pr-opened" : "pr-updated", null, opened ? pr.createdAt : lastEvent(record));
|
|
93
95
|
}
|
|
94
96
|
}
|
|
95
97
|
|
|
96
98
|
// A ship that never reached pr or end was abandoned; keep it, but never fold it into the next one.
|
|
97
|
-
function restart(
|
|
98
|
-
closeOpened(
|
|
99
|
-
if (existsSync(path)) finalize(
|
|
100
|
-
save(path, load(path, id));
|
|
99
|
+
function restart(c, path, record, id) {
|
|
100
|
+
closeOpened(c, id.app);
|
|
101
|
+
if (existsSync(path)) finalize(c, path, record, id.branch.replace(/[^\w.-]+/g, "-"), "abandoned");
|
|
102
|
+
save(path, { ...load(path, id), registry: planRegistry(c.root) });
|
|
101
103
|
}
|
|
102
104
|
|
|
103
105
|
function addGateRun(record, [report, exit, seconds, base]) {
|
|
@@ -129,9 +131,7 @@ function addStep(record, [name, edge]) {
|
|
|
129
131
|
|
|
130
132
|
// Worktrees share one diary, so only this branch's latest review since the ship started counts.
|
|
131
133
|
function lastReview(diary, branch, since) {
|
|
132
|
-
const
|
|
133
|
-
const rows = existsSync(file) ? readFileSync(file, "utf8").split("\n").filter(Boolean) : [];
|
|
134
|
-
const last = rows.map((l) => { try { return JSON.parse(l); } catch { return null; } })
|
|
134
|
+
const last = readLines(join(diary, "review-findings.jsonl")).map(parseLine)
|
|
135
135
|
.filter((r) => r?.branch === branch && r.ts >= since && typeof r.reply === "string").at(-1);
|
|
136
136
|
const json = last?.reply.match(/```json\s*([\s\S]*?)```/)?.[1];
|
|
137
137
|
try {
|
|
@@ -161,11 +161,11 @@ function run(mode, args) {
|
|
|
161
161
|
if (!c.on) return 0;
|
|
162
162
|
const id = identity(c.root), path = openPath(c.dir, id), record = load(path, id);
|
|
163
163
|
if (mode === "start" && RELEASE.test(id.branch)) {
|
|
164
|
-
closeOpened(c
|
|
164
|
+
closeOpened(c, id.app);
|
|
165
165
|
if (existsSync(path)) rmSync(path);
|
|
166
166
|
return 0;
|
|
167
167
|
}
|
|
168
|
-
if (mode === "start") return restart(c
|
|
168
|
+
if (mode === "start") return restart(c, path, record, id), 0;
|
|
169
169
|
if (mode === "ready") {
|
|
170
170
|
const todo = existsSync(path) ? missing(record, c) : [];
|
|
171
171
|
if (todo.length) console.log(todo.map((t) => ` - ${t}`).join("\n"));
|
|
@@ -178,9 +178,9 @@ function run(mode, args) {
|
|
|
178
178
|
else if (mode === "step") addStep(record, args);
|
|
179
179
|
else if (mode === "pr" && /^\d+$/.test(args[0] ?? "")) {
|
|
180
180
|
record.pr = Number(args[0]);
|
|
181
|
-
return finalize(c
|
|
181
|
+
return finalize(c, path, record, `pr${args[0]}`, "pr-opened"), 0;
|
|
182
182
|
}
|
|
183
|
-
else if (mode === "end") return finalize(c
|
|
183
|
+
else if (mode === "end") return finalize(c, path, record, id.branch.replace(/[^\w.-]+/g, "-"), "stopped", args.join(" ") || null), 0;
|
|
184
184
|
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]");
|
|
185
185
|
save(path, record);
|
|
186
186
|
return 0;
|
package/hooks/ship-prep.sh
CHANGED
|
@@ -59,6 +59,9 @@ scope() {
|
|
|
59
59
|
base=$(printf '%s\n' "$scope" | sed -n '1s/^base //p')
|
|
60
60
|
while IFS=$'\t' read -r f _; do [ -n "$f" ] && files+=("$f"); done < <(printf '%s\n' "$scope" | sed '1d')
|
|
61
61
|
skills_report "${files[@]}"
|
|
62
|
+
if printf '%s\n' "$scope" | grep -q '(new)$' && [ -n "$(node "$HERE/registry.mjs" --plan-missing 2>/dev/null)" ]; then
|
|
63
|
+
echo "plan none: this branch adds files and no /plan-feature marked the registry"
|
|
64
|
+
fi
|
|
62
65
|
diff="/tmp/claude-ship-$(printf '%s %s' "$PWD" "$branch" | cksum | cut -d' ' -f1).diff"
|
|
63
66
|
: > "$diff"
|
|
64
67
|
if [ ${#files[@]} -gt 0 ]; then
|