skilldrop-cli 0.15.1 → 0.16.1

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.
Files changed (34) hide show
  1. package/bin/skilldrop.js +158 -28
  2. package/contracts/skill.schema.json +15 -0
  3. package/package.json +1 -1
  4. package/packs/ai-engineering/skills/ai-usage-report/manifest.json +1 -0
  5. package/packs/converters/skills/file-to-markdown/manifest.json +2 -1
  6. package/packs/converters/skills/file-to-markdown/scripts/to_markdown.py +33 -2
  7. package/packs/converters/skills/md-to-docx/manifest.json +2 -1
  8. package/packs/converters/skills/md-to-docx/scripts/md_to_docx.py +3 -1
  9. package/packs/converters/skills/md-to-html/manifest.json +1 -0
  10. package/packs/converters/skills/md-to-xlsx/SKILL.md +2 -1
  11. package/packs/converters/skills/md-to-xlsx/manifest.json +2 -1
  12. package/packs/converters/skills/md-to-xlsx/scripts/md_to_xlsx.py +18 -4
  13. package/packs/converters/skills/mermaid-render/manifest.json +1 -0
  14. package/packs/core/skills/output-hygiene/manifest.json +1 -0
  15. package/packs/data-analytics/skills/sql-review/manifest.json +1 -0
  16. package/packs/design/skills/brand-kit/manifest.json +1 -0
  17. package/packs/design/skills/deck-builder/manifest.json +1 -0
  18. package/packs/design/skills/marketing-flyer/manifest.json +1 -0
  19. package/packs/dev-team/skills/contribution-wizard/SKILL.md +2 -0
  20. package/packs/dev-team/skills/contribution-wizard/manifest.json +1 -1
  21. package/packs/dev-team/skills/pre-merge-review/manifest.json +2 -1
  22. package/packs/dev-team/skills/pre-merge-review/scripts/gate.py +4 -1
  23. package/packs/experience-design/skills/content-design/manifest.json +1 -0
  24. package/packs/grc/skills/risk-register/manifest.json +1 -0
  25. package/packs/infra-as-code/skills/terraform-plan-review/manifest.json +1 -0
  26. package/packs/research/skills/hypothesis-comparison/manifest.json +1 -0
  27. package/packs/research/skills/source-synthesis/manifest.json +1 -0
  28. package/packs/skill-engineering/skills/skill-review/manifest.json +1 -0
  29. package/packs/solution-architect/skills/figma-diagrams/manifest.json +1 -0
  30. package/packs/sre-oncall/skills/cloud-cost-review/manifest.json +1 -0
  31. package/packs/sre-oncall/skills/delivery-metrics-report/manifest.json +1 -0
  32. package/packs/trackers/skills/backlog-triage/manifest.json +1 -0
  33. package/packs/trackers/skills/team-status-report/manifest.json +1 -0
  34. package/packs/trackers/skills/tracker-brief-sync/manifest.json +1 -0
package/bin/skilldrop.js CHANGED
@@ -51,9 +51,11 @@ Usage:
51
51
  (--no-skills for the loop alone) (RFC-0028)
52
52
  skilldrop install --panel review install the review panel — the 3 reviewer subagents +
53
53
  the pre-merge-review orchestrator that fires them (RFC-0020)
54
- skilldrop update [--force] re-copy installed skills whose version changed; files
54
+ skilldrop update [--force] [--changed] re-copy installed skills whose version changed; files
55
55
  you edited are kept and the new copy lands beside them
56
- as <file>.upstream (--force overwrites) (RFC-0032)
56
+ as <file>.upstream (--force overwrites) (RFC-0032).
57
+ Files changed upstream under the same version are named,
58
+ not taken, unless you add --changed (RFC-0039)
57
59
  skilldrop outdated show installed vs current versions, change nothing
58
60
  skilldrop diff <skill> [--stat] what differs between your installed copy and the catalog's —
59
61
  your edits, upstream changes, or both
@@ -81,7 +83,8 @@ Catalogs:
81
83
  (default) the catalog bundled with this package
82
84
  --from <dir|url> a skilldrop catalog (packs/<pack>/skills/<name>/ or flat skills/<name>/) OR
83
85
  an agentbundle one (packs/<pack>/.apm/skills/<name>/SKILL.md); a git URL
84
- works for either — append #<branch-or-tag> to pin
86
+ works for either. Append #<commit-sha> to pin exactly (a branch or tag can
87
+ move after you review it); installs record the commit they got
85
88
 
86
89
  Install/update/uninstall targets (pick one):
87
90
  (default) ~/.claude/skills Claude Code, user scope
@@ -150,29 +153,68 @@ function parseArgs(argv) {
150
153
  manifestOf; never touch cat.skillsDir directly (it exists only on the flat skilldrop shape). */
151
154
 
152
155
  const catalogCache = {};
156
+ /* Commit pinning (RFC-0039, OWASP AST02/AST07). A tag or branch can be moved to point at
157
+ different code after you reviewed it; a commit SHA cannot. `--from <url>#<sha>` fetches
158
+ exactly that commit, and every install records the commit it actually got, so the ledger
159
+ says what is on disk and an unpinned install can tell you how to pin it. */
160
+ const SHA_RE = /^[0-9a-f]{7,40}$/i;
161
+ function gitHead(dir) {
162
+ try {
163
+ return execFileSync("git", ["rev-parse", "HEAD"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
164
+ } catch (e) { return null; }
165
+ }
166
+ function splitSource(source) {
167
+ const i = source.lastIndexOf("#");
168
+ return i > 0 ? [source.slice(0, i), source.slice(i + 1)] : [source, ""];
169
+ }
170
+ function isPinned(source) { return !!source && SHA_RE.test(splitSource(source)[1]); }
171
+
172
+ function fetchCatalog(source) {
173
+ const [url, ref] = splitSource(source);
174
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skilldrop-cat-"));
175
+ const git = (args, cwd) => execFileSync("git", args, { cwd, stdio: ["ignore", "ignore", "pipe"] });
176
+ try {
177
+ if (SHA_RE.test(ref)) {
178
+ // `git clone --branch` takes branches and tags, not commits: fetch the commit itself.
179
+ git(["init", "-q"], dir);
180
+ git(["remote", "add", "origin", url], dir);
181
+ let direct = ref.length === 40;
182
+ if (direct) {
183
+ try { git(["fetch", "-q", "--depth", "1", "origin", ref], dir); }
184
+ catch (e) { direct = false; } // a host that won't serve a commit by itself
185
+ }
186
+ if (!direct) git(["fetch", "-q", "origin", "+refs/heads/*:refs/remotes/origin/*", "+refs/tags/*:refs/tags/*"], dir);
187
+ try { git(["checkout", "-q", direct ? "FETCH_HEAD" : ref], dir); }
188
+ catch (e) { die(`catalog '${source}': commit ${ref} isn't in that repository (check the SHA, or that it was pushed)`); }
189
+ } else {
190
+ git(["clone", "-q", "--depth", "1", ...(ref ? ["--branch", ref] : []), url, dir]);
191
+ }
192
+ } catch (e) {
193
+ die(`could not fetch catalog '${source}' — not a local path, and git ${SHA_RE.test(ref) ? "could not fetch that commit" : "clone failed"}`);
194
+ }
195
+ const head = gitHead(dir);
196
+ if (SHA_RE.test(ref) && (!head || !head.startsWith(ref.toLowerCase())))
197
+ die(`catalog '${source}' resolved to ${head || "nothing"}, not the pinned commit ${ref}`);
198
+ return dir;
199
+ }
200
+
153
201
  function resolveCatalog(source) {
154
202
  const key = source || BUNDLED;
155
203
  if (catalogCache[key]) return catalogCache[key];
156
204
  let dir;
157
205
  if (!source) dir = ROOT;
158
206
  else if (fs.existsSync(source)) dir = path.resolve(source);
159
- else {
160
- const [url, ref] = source.split("#");
161
- dir = fs.mkdtempSync(path.join(os.tmpdir(), "skilldrop-cat-"));
162
- try {
163
- execFileSync("git", ["clone", "--depth", "1", ...(ref ? ["--branch", ref] : []), url, dir], { stdio: ["ignore", "ignore", "pipe"] });
164
- } catch (e) {
165
- die(`could not fetch catalog '${source}' — not a local path, and git clone failed`);
166
- }
167
- }
207
+ else dir = fetchCatalog(source);
168
208
  const src = source || BUNDLED;
209
+ // The bundled catalog is versioned by the npm package; anything else by its git commit.
210
+ const meta = { commit: source ? gitHead(dir) : null, pinned: isPinned(source) };
169
211
  const packsDir = path.join(dir, "packs");
170
212
  if (fs.existsSync(packsDir) && fs.readdirSync(packsDir).some((p) => fs.existsSync(path.join(packsDir, p, "pack.json"))))
171
- return (catalogCache[key] = readPacksCatalog(dir, src));
213
+ return (catalogCache[key] = Object.assign(readPacksCatalog(dir, src), meta));
172
214
  if (fs.existsSync(path.join(dir, "skills")))
173
- return (catalogCache[key] = { dir, source: src, shape: "skilldrop", skillsDir: path.join(dir, "skills") });
215
+ return (catalogCache[key] = Object.assign({ dir, source: src, shape: "skilldrop", skillsDir: path.join(dir, "skills") }, meta));
174
216
  if (fs.existsSync(path.join(dir, "packs")))
175
- return (catalogCache[key] = readApmCatalog(dir, src));
217
+ return (catalogCache[key] = Object.assign(readApmCatalog(dir, src), meta));
176
218
  die(`'${src}' is not a catalog — expected packs/<pack>/pack.json, a skills/ directory, or packs/<pack>/pack.toml (agentbundle)`);
177
219
  }
178
220
 
@@ -389,7 +431,7 @@ const SCRIPT_RULES = [
389
431
  { id: "shell-exec", sev: "🟧", owasp: ["AST03", "LLM06"], why: "executes shell commands",
390
432
  re: /\b(os\.system|subprocess\.(run|call|Popen|check_output)|child_process|execSync|spawnSync|shell_exec|`[^`\n]*\$\()/ },
391
433
  { id: "network", sev: "🟧", owasp: ["AST03", "LLM02"], why: "makes outbound network calls",
392
- re: /\b(requests\.(get|post|put)|urllib\.request|httpx\.|axios\.|node-fetch|\bfetch\s*\(\s*["'`]https?:|curl\s+https?:|wget\s+https?:)/i },
434
+ re: /\b(requests\.(get|post|put|patch|delete|head|request|Session)\b|urllib\.request|urlopen\s*\(|http\.client|httpx\.|aiohttp\.|axios\.|node-fetch|\bfetch\s*\(\s*["'`]https?:|curl\s+https?:|wget\s+https?:)/i },
393
435
  { id: "credentials", sev: "🟧", owasp: ["AST01", "LLM02"], why: "reads credentials or secret material",
394
436
  re: /\b(os\.environ|process\.env)\b[^\n]{0,40}(TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL)|~\/\.(aws|ssh|npmrc|netrc)|\.env\b|id_rsa/i },
395
437
  { id: "broad-fs", sev: "🟨", owasp: ["AST03", "LLM06"], why: "writes outside the skill's own directory",
@@ -441,10 +483,47 @@ function scanSkill(cat, s) {
441
483
  }
442
484
  });
443
485
  }
486
+ findings.push(...permissionGaps(cat, s, findings));
444
487
  const order = { "🟥": 0, "🟧": 1, "🟨": 2, "⚪": 3 };
445
488
  return findings.sort((a, b) => order[a.sev] - order[b.sev]);
446
489
  }
447
490
 
491
+ /* RFC-0039: compare what a skill's scripts do (the scan's script findings) with what its
492
+ manifest says they do. A capability the code shows and the manifest doesn't declare is the
493
+ finding that matters most: it is either an honest omission or something hiding. */
494
+ function permissionsOf(cat, s) {
495
+ if (cat.shape === "apm") return null;
496
+ try { return manifestOf(cat, s).permissions || null; } catch (e) { return null; }
497
+ }
498
+ function permissionGaps(cat, s, findings) {
499
+ const dir = skillDir(cat, s);
500
+ if (!dir || !fs.existsSync(path.join(dir, "scripts"))) return [];
501
+ const perm = permissionsOf(cat, s);
502
+ if (!perm)
503
+ return [{ sev: "🟨", id: "no-permissions", owasp: ["AST03", "AST10"], file: "manifest.json", line: 0,
504
+ why: "ships scripts but declares no permissions (network, commands, files) — nothing to check them against",
505
+ text: "add a `permissions` block to manifest.json (RFC-0039)" }];
506
+ const code = findings.filter((f) => SCRIPT_EXT.has(path.extname(f.file).toLowerCase()));
507
+ const gaps = [];
508
+ const gap = (id, f, why) => gaps.push({ sev: "🟥", id, owasp: ["AST03", "AST01"], why, file: f.file, line: f.line, text: f.text });
509
+ for (const f of code) {
510
+ if ((f.id === "network" || f.id === "exec-remote") && !(perm.network || []).length)
511
+ gap("undeclared-network", f, "makes network calls, but the manifest declares no network hosts");
512
+ if ((f.id === "shell-exec" || f.id === "exec-remote") && !(perm.commands || []).length)
513
+ gap("undeclared-commands", f, "runs commands, but the manifest declares none");
514
+ if (f.id === "broad-fs" && perm.files !== "anywhere")
515
+ gap("undeclared-files", f, `writes outside its own paths, but the manifest declares files: "${perm.files}"`);
516
+ }
517
+ return gaps;
518
+ }
519
+ function permissionSummary(perm) {
520
+ if (!perm) return "no permissions declared";
521
+ const net = (perm.network || []).length ? `contacts ${perm.network.join(", ")}` : "no network";
522
+ const cmds = (perm.commands || []).length ? `runs ${perm.commands.map((c) => c === "*" ? "commands you configure" : c).join(", ")}` : "runs no commands";
523
+ const files = { none: "writes nothing", "named-paths": "writes only paths you name", project: "writes inside the project", anywhere: "writes anywhere" }[perm.files] || `files: ${perm.files}`;
524
+ return `${net} · ${cmds} · ${files}`;
525
+ }
526
+
448
527
  function printScan(cat, names, { compact = false } = {}) {
449
528
  let total = 0, worst = null;
450
529
  for (const s of names) {
@@ -916,6 +995,7 @@ function copyOne(cat, s, dest, ide, l, notes) {
916
995
  const note = writeWiring(ide, dest, s, m.description);
917
996
  if (note && notes) notes.push(note);
918
997
  l.data[s] = { version: m.version, source: cat.source, files: fileHashes(skillDir(cat, s)) };
998
+ if (cat.commit) l.data[s].commit = cat.commit;
919
999
  return m;
920
1000
  }
921
1001
 
@@ -974,6 +1054,7 @@ function mergeOne(cat, s, dest, ide, l, notes, dry = false) {
974
1054
  const note = writeWiring(ide, dest, s, m.description);
975
1055
  if (note && notes) notes.push(note);
976
1056
  l.data[s] = { version: m.version, source: cat.source, files: next };
1057
+ if (cat.commit) l.data[s].commit = cat.commit;
977
1058
  return { m, kept, orphaned };
978
1059
  }
979
1060
 
@@ -1110,10 +1191,20 @@ function install(args) {
1110
1191
  console.log(`\n${withHooks.join(", ")} declare hooks — re-run with --with-hooks to wire them (git pre-commit reminders / session-start context).`);
1111
1192
  }
1112
1193
 
1194
+ if (cat.source !== BUNDLED && cat.commit) {
1195
+ if (cat.pinned) console.log(`\nPinned: these skills came from commit ${cat.commit.slice(0, 12)}, as requested.`);
1196
+ else console.log(`\nNot pinned: '${cat.source}' can change after you review it. These skills came from commit ${cat.commit.slice(0, 12)};` +
1197
+ `\n to get exactly these files again, install with --from ${splitSource(cat.source)[0]}#${cat.commit}`);
1198
+ }
1113
1199
  if (cat.source !== BUNDLED) {
1114
1200
  console.log(`\nWARNING: third-party catalog '${cat.source}'. Skills are instructions your AI agent will follow — review each installed SKILL.md under ${dest} before first use. Install copied files only; nothing was executed.`);
1115
1201
  // Supply-chain scan (RFC-0022): surface risky patterns at the moment of trust. Reports only —
1116
1202
  // the files are already copied, and a match is a prompt to read, not a verdict.
1203
+ const scripted = names.filter((s) => fs.existsSync(path.join(skillDir(cat, s), "scripts")));
1204
+ if (scripted.length) {
1205
+ console.log(`\nWhat the scripts declare they do (RFC-0039):`);
1206
+ for (const s of scripted) console.log(` ${s}: ${permissionSummary(permissionsOf(cat, s))}`);
1207
+ }
1117
1208
  const { total } = printScan(cat, names, { compact: true });
1118
1209
  console.log(total
1119
1210
  ? `\n${total} pattern(s) worth reading before you trust these skills. Full detail: skilldrop scan --from ${cat.source}`
@@ -1159,12 +1250,22 @@ function installedRows(flags) {
1159
1250
  const l = ledger(dest);
1160
1251
  const rows = Object.keys(l.data).sort().map((s) => {
1161
1252
  const src = lsrc(l.data[s]);
1162
- let current = null, cat = null;
1253
+ let current = null, cat = null, changed = [];
1254
+ const entry = l.data[s];
1163
1255
  try {
1164
1256
  cat = resolveCatalog(src === BUNDLED ? undefined : src);
1165
- if (skillExists(cat, s)) current = manifestOf(cat, s).version;
1257
+ if (skillExists(cat, s)) {
1258
+ current = manifestOf(cat, s).version;
1259
+ // Same version, different files: the catalog changed the skill without bumping it.
1260
+ // The version check alone would never see that (OWASP AST07, update drift).
1261
+ if (current === lver(entry) && typeof entry === "object" && entry.files) {
1262
+ const now = fileHashes(skillDir(cat, s));
1263
+ changed = [...new Set([...Object.keys(now), ...Object.keys(entry.files)])]
1264
+ .filter((f) => now[f] !== entry.files[f]).sort();
1265
+ }
1266
+ }
1166
1267
  } catch (e) { /* unreachable source: current stays null */ }
1167
- return { s, src, cat, installed: lver(l.data[s]), current };
1268
+ return { s, src, cat, installed: lver(entry), current, changed, pinned: isPinned(src) };
1168
1269
  });
1169
1270
  return { dest, ide, l, rows };
1170
1271
  }
@@ -1173,7 +1274,10 @@ function update(args) {
1173
1274
  const { dest, ide, l, rows } = installedRows(args.flags);
1174
1275
  if (!rows.length) return console.log(`nothing installed at ${dest}`);
1175
1276
  const dry = !!args.flags["dry-run"];
1176
- const due = rows.filter((r) => r.current && r.current !== r.installed);
1277
+ // A row is due when its version moved, or (with --changed) when its files moved under the
1278
+ // same version. Same-version changes are never taken silently: they are named and skipped.
1279
+ const isDue = (r) => r.current && (r.current !== r.installed || (args.flags.changed && r.changed.length));
1280
+ const due = rows.filter(isDue);
1177
1281
  if (args.flags.force && !dry) {
1178
1282
  const lost = due.flatMap((r) => editedFiles(dest, r.s, l.data[r.s]).map((f) => `${r.s}/${f}`));
1179
1283
  if (lost.length) {
@@ -1183,19 +1287,20 @@ function update(args) {
1183
1287
  }
1184
1288
  if (dry) return planUpdate(dest, ide, l, rows, args.flags);
1185
1289
  let n = 0, conflicts = 0;
1290
+ const silent = rows.filter((r) => r.current === r.installed && r.changed.length && !args.flags.changed);
1186
1291
  for (const r of rows) {
1187
1292
  if (!r.current) { console.log(`skip ${r.s}: source '${r.src}' unreachable or skill gone from it`); continue; }
1188
- if (r.current === r.installed) continue;
1293
+ if (!isDue(r)) continue;
1189
1294
  const problems = checkSkill(r.cat, r.s);
1190
1295
  if (problems.length) { console.log(`skip ${r.s}: fails structural check in '${r.src}' (${problems[0]})`); continue; }
1191
1296
  // No baseline (installed before RFC-0032) or --force: overwrite as before, and record one.
1192
1297
  const entry = l.data[r.s];
1193
1298
  if (args.flags.force || typeof entry !== "object" || !entry.files) {
1194
1299
  copyOne(r.cat, r.s, dest, ide, l);
1195
- console.log(`updated ${r.s} ${r.installed} -> ${r.current} (${r.src})`);
1300
+ console.log(`updated ${r.s} ${r.installed === r.current ? `${r.current} (same version, changed upstream)` : `${r.installed} -> ${r.current}`} (${r.src})`);
1196
1301
  } else {
1197
1302
  const { kept, orphaned } = mergeOne(r.cat, r.s, dest, ide, l);
1198
- console.log(`updated ${r.s} ${r.installed} -> ${r.current} (${r.src})`);
1303
+ console.log(`updated ${r.s} ${r.installed === r.current ? `${r.current} (same version, changed upstream)` : `${r.installed} -> ${r.current}`} (${r.src})`);
1199
1304
  for (const f of kept) console.log(` kept your edits in ${f} — new version at ${f}.upstream`);
1200
1305
  for (const f of orphaned) console.log(` kept ${f} — dropped upstream, but you edited it`);
1201
1306
  conflicts += kept.length;
@@ -1203,10 +1308,17 @@ function update(args) {
1203
1308
  n++;
1204
1309
  }
1205
1310
  saveLedger(l);
1206
- console.log(n ? `\n${n} skill(s) updated.` : "everything up to date.");
1311
+ console.log(n ? `\n${n} skill(s) updated.` : (silent.length ? "no version changes." : "everything up to date."));
1312
+ if (silent.length)
1313
+ console.log(`\nNot taken: ${silent.map((r) => r.s).join(", ")} changed upstream without a version bump.` +
1314
+ `\n Read the change (skilldrop diff <skill>), then take it with: skilldrop update --changed`);
1315
+ const pinned = rows.filter((r) => r.pinned);
1316
+ if (pinned.length)
1317
+ console.log(`\n${pinned.length} skill(s) come from a catalog pinned to a commit, so they stay as they are. To move them,` +
1318
+ `\n reinstall with the new commit: skilldrop install <skill> --from <url>#<new-commit>`);
1207
1319
  // An update is a fresh act of trust in a third-party catalog (OWASP AST07): scan what changed.
1208
1320
  const thirdParty = {};
1209
- for (const r of rows) if (r.current && r.current !== r.installed && r.src !== BUNDLED) (thirdParty[r.src] = thirdParty[r.src] || { cat: r.cat, names: [] }).names.push(r.s);
1321
+ for (const r of rows) if (isDue(r) && r.src !== BUNDLED) (thirdParty[r.src] = thirdParty[r.src] || { cat: r.cat, names: [] }).names.push(r.s);
1210
1322
  for (const { cat, names } of Object.values(thirdParty)) {
1211
1323
  const { total } = printScan(cat, names, { compact: true });
1212
1324
  console.log(total ? `\n${total} pattern(s) in the updated skills from '${cat.source}' worth reading. Full detail: skilldrop scan --from ${cat.source}`
@@ -1222,7 +1334,11 @@ function planUpdate(dest, ide, l, rows, flags) {
1222
1334
  let n = 0;
1223
1335
  for (const r of rows) {
1224
1336
  if (!r.current) { console.log(`would skip ${r.s}: source '${r.src}' unreachable or skill gone from it`); continue; }
1225
- if (r.current === r.installed) continue;
1337
+ if (r.current === r.installed && !r.changed.length) continue;
1338
+ if (r.current === r.installed && !flags.changed) {
1339
+ console.log(`would not take ${r.s}: ${r.changed.length} file(s) changed upstream without a version bump (add --changed to take them)`);
1340
+ continue;
1341
+ }
1226
1342
  const problems = checkSkill(r.cat, r.s);
1227
1343
  if (problems.length) { console.log(`would skip ${r.s}: fails structural check (${problems[0]})`); continue; }
1228
1344
  n++;
@@ -1237,25 +1353,34 @@ function planUpdate(dest, ide, l, rows, flags) {
1237
1353
  for (const f of kept) console.log(` would keep your edits in ${f} and write the new version to ${f}.upstream`);
1238
1354
  for (const f of orphaned) console.log(` would keep ${f} — dropped upstream, but you edited it`);
1239
1355
  }
1240
- console.log(n ? `\n${n} skill(s) would update. Rerun without --dry-run to apply.` : "everything up to date.");
1356
+ const held = rows.filter((r) => r.current && r.current === r.installed && r.changed.length && !flags.changed).length;
1357
+ console.log(n ? `\n${n} skill(s) would update. Rerun without --dry-run to apply.` : (held ? "no version changes." : "everything up to date."));
1241
1358
  }
1242
1359
 
1243
1360
  function outdated(args) {
1244
1361
  const { dest, rows } = installedRows(args.flags);
1245
1362
  const stale = rows.filter((r) => r.current && r.current !== r.installed);
1363
+ const drift = rows.filter((r) => r.changed && r.changed.length);
1246
1364
  if (args.flags.json)
1247
1365
  return emitJSON({
1248
1366
  dest,
1249
1367
  count: rows.length,
1250
1368
  outdatedCount: stale.length,
1369
+ changedWithoutVersionCount: drift.length,
1251
1370
  skills: rows.map((r) => ({
1252
1371
  name: r.s, installed: r.installed, current: r.current,
1253
1372
  source: r.src, outdated: !!(r.current && r.current !== r.installed),
1373
+ changedWithoutVersion: r.changed || [], pinned: r.pinned,
1254
1374
  })),
1255
1375
  });
1256
1376
  if (!rows.length) return console.log(`nothing installed at ${dest}`);
1257
1377
  for (const r of stale) console.log(`${r.s}: installed ${r.installed}, current ${r.current} (${r.src})`);
1258
- console.log(stale.length ? `\n${stale.length} outdated — run: skilldrop update` : "everything up to date.");
1378
+ for (const r of drift)
1379
+ console.log(`${r.s}: ${r.changed.length} file(s) changed in '${r.src}' without a version bump (${r.changed.slice(0, 3).join(", ")}${r.changed.length > 3 ? ", …" : ""})`);
1380
+ if (drift.length)
1381
+ console.log(`\n${drift.length} skill(s) changed upstream with the same version. Read the change first (skilldrop diff <skill>),` +
1382
+ `\nthen take it with: skilldrop update --changed`);
1383
+ console.log(stale.length ? `\n${stale.length} outdated — run: skilldrop update` : (drift.length ? "" : "everything up to date."));
1259
1384
  }
1260
1385
 
1261
1386
  function uninstall(args) {
@@ -1890,6 +2015,7 @@ function info(args) {
1890
2015
  deps: { pip: (m.deps || {}).pip || [], npm: (m.deps || {}).npm || [], requirementsTxt: needsPip },
1891
2016
  env: { required: (m.env || {}).required || [], optional: (m.env || {}).optional || [] },
1892
2017
  hooks: m.hooks || [],
2018
+ permissions: m.permissions || null,
1893
2019
  });
1894
2020
  console.log(`${m.name}@${m.version} (tier: ${(m.model && m.model.tier) || "n/a"})\n\n${m.description}\n`);
1895
2021
  console.log(`catalog: ${cat.source}`);
@@ -1898,6 +2024,8 @@ function info(args) {
1898
2024
  if (((m.deps || {}).pip || []).length || fs.existsSync(path.join(skillDir(cat, s), "requirements.txt")))
1899
2025
  console.log("deps: python (requirements.txt)");
1900
2026
  if (((m.env || {}).required || []).length) console.log(`env: ${m.env.required.join(", ")} (required)`);
2027
+ if (fs.existsSync(path.join(skillDir(cat, s), "scripts")))
2028
+ console.log(`scripts: ${permissionSummary(m.permissions)}${m.permissions && m.permissions.notes ? `\n ${m.permissions.notes}` : ""}`);
1901
2029
  }
1902
2030
 
1903
2031
  function listPacks(args) {
@@ -1924,6 +2052,8 @@ function validateCmd(args) {
1924
2052
  let bad = 0;
1925
2053
  for (const s of names) {
1926
2054
  const problems = checkSkill(cat, s);
2055
+ for (const f of scanSkill(cat, s).filter((x) => x.id.startsWith("undeclared-")))
2056
+ problems.push(`${f.file}:${f.line} ${f.why} (RFC-0039)`);
1927
2057
  for (const p of problems) console.log(`FAIL ${s}: ${p}`);
1928
2058
  if (problems.length) bad++;
1929
2059
  }
@@ -38,6 +38,21 @@
38
38
  "description": "Vars that change behaviour but do not block." }
39
39
  }
40
40
  },
41
+ "permissions": {
42
+ "type": "object",
43
+ "additionalProperties": false,
44
+ "required": ["network", "commands", "files"],
45
+ "description": "RFC-0039 (OWASP AST03, AST10). What the skill's scripts do, declared so a reader and `skilldrop scan` can compare it with what the code does. Required for every skill that ships scripts/.",
46
+ "properties": {
47
+ "network": { "type": "array", "items": { "type": "string" },
48
+ "description": "Hosts the scripts contact, e.g. api.figma.com. Empty means none." },
49
+ "commands": { "type": "array", "items": { "type": "string" },
50
+ "description": "External programs the scripts run, e.g. pdftotext. \"*\" means commands the user configures. Empty means none." },
51
+ "files": { "type": "string", "enum": ["none", "named-paths", "project", "anywhere"],
52
+ "description": "What the scripts write: nothing; only paths the user names; inside the current project; or anywhere." },
53
+ "notes": { "type": "string", "description": "Anything a reviewer should know that the fields can't say." }
54
+ }
55
+ },
41
56
  "related": {
42
57
  "type": "array",
43
58
  "items": { "type": "string" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skilldrop-cli",
3
- "version": "0.15.1",
3
+ "version": "0.16.1",
4
4
  "description": "Portable AI-agent skills for the deliverables knowledge workers ship \u2014 diagrams, ADRs, PRDs, runbooks, decks, reviews \u2014 installable into Claude Code, Cursor, Kiro, and any IDE that takes custom instructions. One command per skill or per role-based pack.",
5
5
  "author": {
6
6
  "name": "Sanjay Ananthanarayan",
@@ -7,5 +7,6 @@
7
7
  "env": { "required": [], "optional": [] },
8
8
  "related": [],
9
9
  "tags": ["ai-usage", "reporting", "telemetry", "adoption", "observability", "manager-tools"],
10
+ "permissions": {"network": [], "commands": [], "files": "named-paths"},
10
11
  "model": { "tier": "standard", "rationale": "Tabulates telemetry; the 'AI theater' effectiveness call needs some judgment." }
11
12
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "file-to-markdown",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Convert a Word document, PowerPoint deck, Excel workbook, web page, CSV, JSON, text file or PDF into clean Markdown that keeps headings, lists, tables, links, bold and italic, slide titles and speaker notes, and reports everything it dropped (images, charts, comments, formulas, tracked changes), so the content can be read, reviewed or fed to another skill. Use when the user hands over a .docx, .pptx, .xlsx, .html, .csv, .json or .pdf and says \"read this\", \"turn this into markdown\", \"extract the text\", or wants it critiqued or briefed.",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {
@@ -41,6 +41,7 @@
41
41
  "pdf",
42
42
  "extract"
43
43
  ],
44
+ "permissions": {"network": [], "commands": ["pdftotext"], "files": "named-paths", "notes": "pdftotext (Poppler) is used only for .pdf input, and only if it is installed."},
44
45
  "model": {
45
46
  "tier": "light",
46
47
  "rationale": "A deterministic stdlib script does the extraction; the model runs it, relays the dropped-content report and hands off. No generation beyond a short summary of losses."
@@ -56,6 +56,7 @@ class Report:
56
56
  def md_escape(text):
57
57
  text = text.replace("\\", "\\\\")
58
58
  text = re.sub(r"([*_`\[\]])", r"\\\1", text)
59
+ text = re.sub(r"<(?=[A-Za-z/!])", r"\\<", text) # literal <tag> text must not read as raw HTML
59
60
  return text
60
61
 
61
62
 
@@ -330,7 +331,7 @@ def docx_to_md(path, rep):
330
331
  if tcpr is not None and tcpr.find(W + "gridSpan") is not None:
331
332
  row.extend([""] * (int(tcpr.find(W + "gridSpan").get(W + "val", "1")) - 1))
332
333
  rows.append(row)
333
- if any(tbl.iter(W + "tbl")) and len(list(tbl.iter(W + "tbl"))) > 1:
334
+ if len(list(tbl.iter(W + "tbl"))) > 1:
334
335
  rep.notes.append("a nested table was flattened into its parent cell")
335
336
  rep.keep("tables")
336
337
  return md_table(rows)
@@ -339,6 +340,19 @@ def docx_to_md(path, rep):
339
340
  return "".join((n.text or "") if n.tag == W + "t" else "\t" for n in p.iter()
340
341
  if n.tag in (W + "t", W + "tab"))
341
342
 
343
+ def style_name(p):
344
+ ppr = p.find(W + "pPr")
345
+ ps = ppr.find(W + "pStyle") if ppr is not None else None
346
+ if ps is None:
347
+ return ""
348
+ sid = ps.get(W + "val", "")
349
+ return styles.get(sid, (sid.lower(), None))[0]
350
+
351
+ def code_text(p):
352
+ """Text of a code paragraph: line breaks and tabs kept, no inline formatting or escaping."""
353
+ return "".join((n.text or "") if n.tag == W + "t" else "\t" if n.tag == W + "tab" else "\n"
354
+ for n in p.iter() if n.tag in (W + "t", W + "tab", W + "br", W + "cr"))
355
+
342
356
  def run_size(p):
343
357
  """(largest font size in half-points, every text run bold?) for unstyled documents."""
344
358
  sizes, bold = [0], True
@@ -402,7 +416,19 @@ def docx_to_md(path, rep):
402
416
  if not text:
403
417
  prev_list = False if not li else prev_list
404
418
  continue
405
- if lvl:
419
+ sname = "" if lvl or li else style_name(el)
420
+ if re.match(r"(source ?code|code|html preformatted|preformatted text|macro text)$", sname):
421
+ # one code block may be one paragraph with line breaks or a run of one-line paragraphs
422
+ if blocks and blocks[-1][0] == "code":
423
+ blocks[-1] = ("code", blocks[-1][1] + "\n" + code_text(el))
424
+ else:
425
+ blocks.append(("code", code_text(el)))
426
+ rep.keep("code blocks")
427
+ prev_list = False
428
+ elif "quote" in sname or sname == "block text":
429
+ blocks.append(("block", "> " + text.replace("\n", "\n> ")))
430
+ prev_list = False
431
+ elif lvl:
406
432
  rep.keep("headings")
407
433
  blocks.append(("block", "#" * lvl + " " + text.replace(" \n", " ")))
408
434
  prev_list = False
@@ -439,6 +465,11 @@ def docx_to_md(path, rep):
439
465
  for k, (kind, text) in enumerate(blocks):
440
466
  if k and not (kind == "list" and blocks[k - 1][0] == "list"):
441
467
  out.append("")
468
+ if kind == "code":
469
+ fence = "```"
470
+ while fence in text:
471
+ fence += "`"
472
+ text = fence + "\n" + text + "\n" + fence
442
473
  out.append(text)
443
474
  return "\n".join(out) + "\n"
444
475
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "md-to-docx",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Turn a Markdown draft into a Word document (.docx) that someone outside engineering can open, edit and track changes in, with real Heading styles so the navigation pane and table of contents work, live hyperlinks, numbered and bulleted lists, tables, code blocks, embedded images and optional brand fonts and colours, and a warning for everything that could not be carried over. Use when the user wants a Markdown file as a Word doc, says \"send this as a .docx\", \"convert to Word\", \"legal needs it in Word\", or \"make this editable for the client\".",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {
@@ -44,6 +44,7 @@
44
44
  "document",
45
45
  "export"
46
46
  ],
47
+ "permissions": {"network": [], "commands": [], "files": "named-paths"},
47
48
  "model": {
48
49
  "tier": "standard",
49
50
  "rationale": "A stdlib script does the conversion; the model restructures the Markdown so it converts cleanly (heading levels, footnotes, HTML), reads each warning and explains the losses. Bounded editing, no open-ended generation."
@@ -34,6 +34,8 @@ WARNINGS = []
34
34
 
35
35
  def warn(line, msg):
36
36
  where = "line %d: " % line if line else ""
37
+ if where + msg in WARNINGS: # a big table repeats one warning per cell; say it once
38
+ return
37
39
  WARNINGS.append(where + msg)
38
40
  print("WARN %s%s" % (where, msg), file=sys.stderr)
39
41
 
@@ -942,7 +944,7 @@ class Doc(object):
942
944
 
943
945
 
944
946
  def static_parts(title):
945
- now = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
947
+ now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
946
948
  ct = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
947
949
  '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
948
950
  '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
@@ -35,6 +35,7 @@
35
35
  "export",
36
36
  "brand"
37
37
  ],
38
+ "permissions": {"network": [], "commands": [], "files": "named-paths", "notes": "The script makes no network calls. With --mermaid cdn, the HTML it writes loads Mermaid from cdn.jsdelivr.net when someone opens it."},
38
39
  "model": {
39
40
  "tier": "light",
40
41
  "rationale": "A deterministic stdlib script does the conversion; the model picks options, fixes a few unsupported Markdown constructs and relays the script's warnings. Little judgment."
@@ -78,7 +78,8 @@ nothing. Formulas stay off unless the request says otherwise, noted as an `[assu
78
78
  | Anything else, including `n/a`, `1.234,50`, `30/09/2026` | Text |
79
79
 
80
80
  In Markdown cells, `**bold**`, `` `code` ``, links and images are reduced to their text, and
81
- `<br>` becomes a line break inside a wrapped cell. JSON numbers and booleans keep their type.
81
+ `<br>` becomes a line break inside a wrapped cell. JSON numbers and booleans keep their type,
82
+ except integers over 15 digits, which become text.
82
83
  JSON strings stay text except ISO dates, because whoever produced the JSON chose a string.
83
84
 
84
85
  ## Useful references in this skill
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "md-to-xlsx",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Turn the tables in a Markdown file, a CSV or a JSON array into an Excel workbook (.xlsx) that finance, ops or a client can sort, filter and sum, with one sheet per table named from its heading, real numbers, percentages, currency and dates instead of text, a bold frozen header row with filters, sensible column widths, and formulas left inert unless asked for. Use when the user wants a spreadsheet from Markdown tables or data, says \"put this in Excel\", \"convert the CSV to xlsx\", \"give finance a spreadsheet\", or \"turn this JSON into a sheet\".",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {
@@ -25,6 +25,7 @@
25
25
  "markdown",
26
26
  "convert"
27
27
  ],
28
+ "permissions": {"network": [], "commands": [], "files": "named-paths"},
28
29
  "model": {
29
30
  "tier": "standard",
30
31
  "rationale": "A stdlib script types and writes the cells; the model shapes tables from the source (headings, units, one header row), decides whether formulas are safe to enable, and checks the per-sheet type counts against the data. Bounded, mostly mechanical judgment."
@@ -170,8 +170,11 @@ def type_text(s, formulas, stats, tz_flag, infer_numbers=True):
170
170
  text = "0"
171
171
  return Cell("n", text, fmt, width)
172
172
  if RE_SCI.match(s):
173
- stats["numbers"] += 1
174
- return Cell("n", repr(float(s)), None, width)
173
+ val = float(s)
174
+ # 1e999 overflows to inf, which is not a number Excel can read: keep it as text
175
+ if val == val and val not in (float("inf"), float("-inf")):
176
+ stats["numbers"] += 1
177
+ return Cell("n", num_text(val), None, width)
175
178
  if len(raw) > MAX_CELL:
176
179
  stats["truncated"] += 1
177
180
  s = s[:MAX_CELL]
@@ -189,6 +192,10 @@ def type_json(v, formulas, stats, tz_flag):
189
192
  if v != v or v in (float("inf"), float("-inf")):
190
193
  stats["text"] += 1
191
194
  return Cell("s", str(v), None, len(str(v)))
195
+ if isinstance(v, int) and len(str(abs(v))) > 15:
196
+ # Excel keeps 15 significant digits; a longer integer is an ID, and rounding it is data loss
197
+ stats["text"] += 1
198
+ return Cell("s", str(v), None, len(str(v)))
192
199
  stats["numbers"] += 1
193
200
  return Cell("n", num_text(v), None, len(num_text(v)))
194
201
  if isinstance(v, (dict, list)):
@@ -230,6 +237,9 @@ def split_row(line):
230
237
  return cells
231
238
 
232
239
 
240
+ HTML_TAGS = [0] # tags removed by strip_md, reported once per file
241
+
242
+
233
243
  def strip_md(s):
234
244
  """Plain text from a Markdown table cell."""
235
245
  s = re.sub(r"<br\s*/?>", "\n", s, flags=re.I)
@@ -240,7 +250,8 @@ def strip_md(s):
240
250
  s = re.sub(r"(?<![\w*])\*(?!\s)(.+?)(?<!\s)\*(?![\w*])", r"\1", s)
241
251
  s = re.sub(r"(?<!\w)_(?!\s)(.+?)(?<!\s)_(?!\w)", r"\1", s)
242
252
  s = re.sub(r"~~(.+?)~~", r"\1", s)
243
- s = re.sub(r"</?[A-Za-z][^>]*>", "", s)
253
+ s, n = re.subn(r"</?[A-Za-z][^>]*>", "", s)
254
+ HTML_TAGS[0] += n
244
255
  s = re.sub(r"\\([\\`*_{}\[\]()#+\-.!|~])", r"\1", s)
245
256
  return s
246
257
 
@@ -280,6 +291,9 @@ def read_markdown(text):
280
291
  tables.append((heading, header, rows, start))
281
292
  continue
282
293
  i += 1
294
+ if HTML_TAGS[0]:
295
+ warn("%d HTML tag(s) removed from headings and cells (text between them kept); write a literal "
296
+ "<...> as &lt;...&gt; or in backticks to keep it" % HTML_TAGS[0])
283
297
  return tables
284
298
 
285
299
 
@@ -533,7 +547,7 @@ def write_xlsx(out, sheets, title, formulas):
533
547
  '<bookViews><workbookView activeTab="0"/></bookViews><sheets>%s</sheets>'
534
548
  '<definedNames>%s</definedNames>%s</workbook>') % (
535
549
  "".join(sheet_tags), "".join(names), '<calcPr fullCalcOnLoad="1"/>' if formulas else "")
536
- now = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
550
+ now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
537
551
  core = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
538
552
  '<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" '
539
553
  'xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" '
@@ -32,6 +32,7 @@
32
32
  "png",
33
33
  "render"
34
34
  ],
35
+ "permissions": {"network": [], "commands": ["mmdc"], "files": "named-paths", "notes": "mmdc (mermaid-cli) renders SVG, PNG and PDF if it is installed. Without it the script writes an HTML page that loads Mermaid from cdn.jsdelivr.net when opened; the script itself makes no network calls."},
35
36
  "model": {
36
37
  "tier": "standard",
37
38
  "rationale": "The script lints and renders deterministically, but fixing a broken diagram means reading the author's intent and rewriting syntax without changing nodes, edges or labels, and knowing when to ask instead."
@@ -26,6 +26,7 @@
26
26
  "ai-detection",
27
27
  "pre-ship"
28
28
  ],
29
+ "permissions": {"network": [], "commands": [], "files": "named-paths", "notes": "Writes only with --fix, which rewrites the files it was given, in place."},
29
30
  "model": {
30
31
  "tier": "standard",
31
32
  "rationale": "The mechanical pass is a deterministic script; the model does bounded rewriting of flagged prose against a fixed tell list, not adversarial review."
@@ -34,6 +34,7 @@
34
34
  "bigquery",
35
35
  "snowflake"
36
36
  ],
37
+ "permissions": {"network": [], "commands": [], "files": "named-paths"},
37
38
  "model": {
38
39
  "tier": "heavy",
39
40
  "rationale": "Adversarial correctness review: finding join fan-out, NULL and timezone bugs means reasoning about data the query never shows, and a missed finding ships a wrong number. Never downgrade."
@@ -37,6 +37,7 @@
37
37
  "design",
38
38
  "style-guide"
39
39
  ],
40
+ "permissions": {"network": [], "commands": [], "files": "none", "notes": "The checker reads brand.json and the files it names, and writes nothing."},
40
41
  "model": {
41
42
  "tier": "standard",
42
43
  "rationale": "Extracts and normalises brand facts from files and pages, then runs a checker. Bounded synthesis."
@@ -13,5 +13,6 @@
13
13
  "pip": "python3 -m pip install -r requirements.txt"
14
14
  },
15
15
  "tags": ["powerpoint", "pptx", "deck", "presentation", "template", "charts", "stakeholder-communication"],
16
+ "permissions": {"network": [], "commands": [], "files": "named-paths", "notes": "Needs the python-pptx package (declared in deps)."},
16
17
  "model": { "tier": "standard", "rationale": "Shapes content, maps a brand template's layouts, and runs a script. Judgment, not hard reasoning." }
17
18
  }
@@ -32,6 +32,7 @@
32
32
  "brand",
33
33
  "design"
34
34
  ],
35
+ "permissions": {"network": [], "commands": ["google-chrome", "chromium", "chrome"], "files": "named-paths", "notes": "A local Chrome or Chromium is run headless only for --pdf and --png; the HTML needs nothing."},
35
36
  "model": {
36
37
  "tier": "standard",
37
38
  "rationale": "Writes short copy to fit and fills a spec for a deterministic builder. Bounded generation."
@@ -46,6 +46,8 @@ After intake, produce the following in a single response, each in its own fenced
46
46
  }
47
47
  ```
48
48
 
49
+ If the skill ships `scripts/`, add a `permissions` block before `model`: the hosts the scripts contact, the external programs they run, and where they write (`"none"`, `"named-paths"`, `"project"` or `"anywhere"`). For example: `"permissions": { "network": [], "commands": ["pdftotext"], "files": "named-paths" }`. Validation fails a script skill without it, and fails one whose code does something it doesn't declare.
50
+
49
51
  Tier guidance:
50
52
  - **light**: pattern matching, formatting, extraction with no judgment calls
51
53
  - **standard**: synthesis, judgment calls, structured output requiring reasoning (most skills)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contribution-wizard",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "Guides an author through creating a new skilldrop skill from scratch — generates the manifest, SKILL.md, eval cases, and skill-catalogue entry from a plain-language description of the skill's purpose. Use when authoring a new skill for this repo or a private fork, ensuring a new skill meets the schema and validation requirements before submission.",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": { "npm": [], "pip": [] },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pre-merge-review",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Gate an existing change for merge — run one skill that fires the whole production-readiness pass. First a deterministic mechanical gate (lint + typecheck + tests, via a script whose exit code decides), then the three-reviewer panel dispatched in parallel — devils-advocate (correctness), security-reviewer (exploitability), code-quality (craft) — then a single READY / NOT READY verdict. Use before opening a PR or merging, when you want the full review fleet run on a diff you already have. Do NOT use to implement a feature from a spec (that's feature-implement-loop) or to review a document (that's doc-critique).",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": { "npm": [], "pip": [] },
@@ -10,5 +10,6 @@
10
10
  { "to": "council-review", "when": "the blocker is a design disagreement, not a defect", "purpose": "Adjudicate the disagreement outside the diff.", "fallback": "If council-review is absent, state each position and its strongest counter inline, and name the crux the decision turns on." }
11
11
  ],
12
12
  "tags": ["dev-workflow", "code-review", "pre-merge", "quality-gate", "subagents", "production-readiness"],
13
+ "permissions": {"network": [], "commands": ["git", "ruff", "mypy", "pytest", "*"], "files": "project", "notes": "Runs the lint, typecheck and test commands configured for the project, through a shell, by design. --install-hook writes a git pre-commit hook in the repository."},
13
14
  "model": { "tier": "standard", "rationale": "Coordination: runs a deterministic gate script, dispatches the (heavy) reviewer subagents, and renders a rule-based go/no-go. The adversarial reasoning is delegated to the panel, so the skill itself is orchestration, not deep judgment." }
14
15
  }
@@ -112,7 +112,10 @@ def install_hook(root, cmd, config):
112
112
  body += "\n" + _hook_block(cmd, config)
113
113
  with open(path, "w", encoding="utf-8") as fh:
114
114
  fh.write(body)
115
- os.chmod(path, 0o755)
115
+ # chmod +x, not a fixed 0o755: add execute only where the umask already granted read, so a
116
+ # restrictive umask (077) keeps the hook private instead of being widened to world-readable.
117
+ mode = os.stat(path).st_mode
118
+ os.chmod(path, mode | ((mode & 0o444) >> 2))
116
119
  print(f"gate: installed a blocking pre-commit hook -> {path}")
117
120
  print(" a RED gate now blocks `git commit` (any tool). Bypass once with --no-verify; "
118
121
  "wire CI required-checks for the un-bypassable backstop.")
@@ -33,6 +33,7 @@
33
33
  "ux",
34
34
  "user-needs"
35
35
  ],
36
+ "permissions": {"network": [], "commands": [], "files": "none", "notes": "Prints readability figures; writes nothing."},
36
37
  "model": {
37
38
  "tier": "standard",
38
39
  "rationale": "Rewrites source text from user needs under fixed plain-language rules, with a script for the readability numbers. Prioritising questions needs judgment, but facts come from the source."
@@ -35,6 +35,7 @@
35
35
  "governance",
36
36
  "compliance"
37
37
  ],
38
+ "permissions": {"network": [], "commands": [], "files": "named-paths"},
38
39
  "model": {
39
40
  "tier": "standard",
40
41
  "rationale": "Structured writing against a fixed format and scale, with a script doing the checks and counts. Scoring needs some judgment but is bounded by the written scale."
@@ -40,6 +40,7 @@
40
40
  "iam",
41
41
  "drift"
42
42
  ],
43
+ "permissions": {"network": [], "commands": [], "files": "named-paths", "notes": "Reads a plan JSON, which can hold secrets in clear text; the script never prints attribute values."},
43
44
  "model": {
44
45
  "tier": "heavy",
45
46
  "rationale": "Adversarial review of a destructive change: judging which replacements lose data, which access changes widen exposure, and what is out of scope needs careful reasoning; a missed destroy costs far more than the model."
@@ -35,6 +35,7 @@
35
35
  "analysis",
36
36
  "evidence"
37
37
  ],
38
+ "permissions": {"network": [], "commands": [], "files": "named-paths"},
38
39
  "model": {
39
40
  "tier": "heavy",
40
41
  "rationale": "Judgment-heavy: generating real rivals, rating every evidence-hypothesis cell honestly and resisting the favourite theory is where a fast pass fails; the script only does the arithmetic."
@@ -34,6 +34,7 @@
34
34
  "citations",
35
35
  "evidence"
36
36
  ],
37
+ "permissions": {"network": [], "commands": [], "files": "named-paths"},
37
38
  "model": {
38
39
  "tier": "standard",
39
40
  "rationale": "Bounded synthesis over supplied text with a fixed citation format and strength criteria; the citation checker catches the mechanical failures, so mid-tier judgment is enough."
@@ -42,6 +42,7 @@
42
42
  "security",
43
43
  "owasp"
44
44
  ],
45
+ "permissions": {"network": [], "commands": [], "files": "named-paths"},
45
46
  "model": {
46
47
  "tier": "heavy",
47
48
  "rationale": "Adversarial audit with a verdict: routing collisions and paraphrased safety issues are what a fast pass misses, and the lint script covers only the mechanical half."
@@ -28,6 +28,7 @@
28
28
  "design",
29
29
  "api"
30
30
  ],
31
+ "permissions": {"network": ["api.figma.com"], "commands": [], "files": "named-paths", "notes": "Reads FIGMA_TOKEN from the environment to call the Figma REST API."},
31
32
  "model": {
32
33
  "tier": "standard",
33
34
  "rationale": "API calls + spec generation. Bounded transformation."
@@ -41,6 +41,7 @@
41
41
  "savings",
42
42
  "sre"
43
43
  ],
44
+ "permissions": {"network": [], "commands": [], "files": "named-paths"},
44
45
  "model": {
45
46
  "tier": "standard",
46
47
  "rationale": "A stdlib script does the aggregation; the model maps columns, checks evidence and computes savings with shown arithmetic against a fixed category checklist, which is bounded judgment."
@@ -40,6 +40,7 @@
40
40
  "engineering-metrics",
41
41
  "reporting"
42
42
  ],
43
+ "permissions": {"network": [], "commands": [], "files": "named-paths"},
43
44
  "model": {
44
45
  "tier": "standard",
45
46
  "rationale": "A stdlib script computes every number; the model maps columns, checks data quality and interprets weekly trends against fixed DORA definitions, which is bounded judgment."
@@ -34,6 +34,7 @@
34
34
  "refinement",
35
35
  "duplicates"
36
36
  ],
37
+ "permissions": {"network": [], "commands": [], "files": "named-paths"},
37
38
  "model": {
38
39
  "tier": "standard",
39
40
  "rationale": "A script does the counting; the model confirms duplicates by reading, judges readiness and writes a reasoned ordering. Bounded judgment over structured data."
@@ -25,6 +25,7 @@
25
25
  "rag",
26
26
  "stakeholder-update"
27
27
  ],
28
+ "permissions": {"network": [], "commands": [], "files": "named-paths"},
28
29
  "model": {
29
30
  "tier": "standard",
30
31
  "rationale": "A script computes every count and the suggested RAG; the model writes plain-language summaries, attributes outside facts and frames risks and asks. Bounded writing over computed numbers."
@@ -33,6 +33,7 @@
33
33
  "traceability",
34
34
  "drift"
35
35
  ],
36
+ "permissions": {"network": [], "commands": [], "files": "named-paths", "notes": "The github target writes a shell script of gh commands for the user to run; it runs nothing itself."},
36
37
  "model": {
37
38
  "tier": "standard",
38
39
  "rationale": "Maps doc sections to items and writes acceptance criteria that restate requirements; a script checks the spec, writes the import file and computes drift. Bounded transformation with a deterministic check."