@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 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 |
@@ -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" });
@@ -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.allowed.includes(f) && !f.endsWith(REGISTRY_SUFFIX));
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
- const loc = join(root, e.location ?? "");
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
  }
@@ -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
+ }
@@ -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 { git, parseReport, prSize } from "./ship-log-parse.mjs";
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(dir, path, record, tag, status, reason, at) {
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(dir, app) {
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(dir, path, Object.assign(record, { pr: pr.number }), `pr${pr.number}`,
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(dir, path, record, id) {
98
- closeOpened(dir, id.app);
99
- if (existsSync(path)) finalize(dir, path, record, id.branch.replace(/[^\w.-]+/g, "-"), "abandoned");
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 file = join(diary, "review-findings.jsonl");
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.dir, id.app);
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.dir, path, record, id), 0;
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.dir, path, record, `pr${args[0]}`, "pr-opened"), 0;
181
+ return finalize(c, path, record, `pr${args[0]}`, "pr-opened"), 0;
182
182
  }
183
- else if (mode === "end") return finalize(c.dir, path, record, id.branch.replace(/[^\w.-]+/g, "-"), "stopped", args.join(" ") || null), 0;
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;
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spardutti/claude-skills",
3
- "version": "2.62.1",
3
+ "version": "2.64.0",
4
4
  "description": "Guardrails for Claude Code — best-practice skills, planning commands, and delivery gates.",
5
5
  "type": "module",
6
6
  "repository": {