@bongos/core 1.19.526 → 1.19.528

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 (50) hide show
  1. package/.bongos-core.json +81 -51
  2. package/.claude/skills/blocker-solve/SKILL.md +2 -1
  3. package/.claude/skills/builder-box/SKILL.md +2 -1
  4. package/.claude/skills/builder-connect/SKILL.md +2 -1
  5. package/.claude/skills/builder-end/SKILL.md +2 -1
  6. package/.claude/skills/builder-exit/SKILL.md +2 -1
  7. package/.claude/skills/builder-key/SKILL.md +2 -1
  8. package/.claude/skills/builder-sequence/SKILL.md +2 -1
  9. package/.claude/skills/builder-stage/SKILL.md +2 -1
  10. package/.claude/skills/design/SKILL.md +2 -1
  11. package/.claude/skills/goal-create/SKILL.md +2 -1
  12. package/.claude/skills/goal-review/SKILL.md +2 -1
  13. package/.claude/skills/grade-audit/SKILL.md +2 -1
  14. package/.claude/skills/grade-recover/SKILL.md +2 -1
  15. package/.claude/skills/ideate/SKILL.md +2 -1
  16. package/.claude/skills/new-project/SKILL.md +2 -1
  17. package/.claude/skills/otb-character-review/SKILL.md +2 -1
  18. package/.claude/skills/paint/SKILL.md +2 -1
  19. package/.claude/skills/scan-before-install/SKILL.md +2 -1
  20. package/.claude/skills/ship-check/SKILL.md +44 -0
  21. package/.claude/skills/strand-fix/SKILL.md +2 -1
  22. package/.claude/skills/worktree-clean/SKILL.md +2 -1
  23. package/.gitattributes +6 -2
  24. package/docs/file-map.md +4 -1
  25. package/docs/module-api-changelog.md +4 -0
  26. package/docs/recipes/windows-builders.md +46 -0
  27. package/modules/grading/generated-provenance.js +1 -1
  28. package/modules/grading/grader-subagent.js +20 -1
  29. package/modules/grading/grader.js +24 -1
  30. package/modules/lifecycle/cascade.js +86 -4
  31. package/modules/lifecycle/tests/cascade.mjs +63 -6
  32. package/package-lock.json +2 -2
  33. package/package.json +1 -1
  34. package/scripts/gds/claude-materialize.js +7 -2
  35. package/scripts/gds/fitness.js +40 -1
  36. package/scripts/gds/gen-repo-map.js +3 -1
  37. package/scripts/gds/session-cost.js +10 -7
  38. package/scripts/gds/session-digest.js +7 -4
  39. package/scripts/gds/ship-check.js +78 -0
  40. package/scripts/gds/ship-regen.js +2 -2
  41. package/scripts/gds/skill-lint.js +171 -0
  42. package/scripts/gds/strand-watch.js +3 -0
  43. package/src/module-api.js +1 -1
  44. package/tests/claude_materialize.mjs +10 -0
  45. package/tests/edit_hints.mjs +78 -0
  46. package/tests/git_merge_regen.mjs +1 -1
  47. package/tests/grader_root_outage.mjs +109 -0
  48. package/tests/hooks_drain.mjs +8 -2
  49. package/tests/repo_map.mjs +1 -1
  50. package/tests/skill_lint.mjs +73 -0
@@ -331,13 +331,13 @@ async function main() {
331
331
  if (!transcriptPath) transcriptPath = newestTranscriptFor(cwd);
332
332
  if (!transcriptPath || !fs.existsSync(transcriptPath)) {
333
333
  // nothing to price — quiet skip
334
- process.exit(0);
334
+ return;
335
335
  }
336
336
  if (!sessionId) sessionId = path.basename(transcriptPath, '.jsonl');
337
337
 
338
338
  // No GDS session → nothing to attribute spend to; skip quietly.
339
339
  const session = await loadSession();
340
- if (!session || !session.token) process.exit(0);
340
+ if (!session || !session.token) return;
341
341
 
342
342
  const inter = priceTranscript(transcriptPath);
343
343
 
@@ -347,7 +347,7 @@ async function main() {
347
347
  const sub = priceSubagents(sessionDir);
348
348
 
349
349
  const total = inter.total + sub.subagentTotal;
350
- if (!(total > 0)) process.exit(0);
350
+ if (!(total > 0)) return;
351
351
 
352
352
  // task 1344: split into call-site-tagged, tier-carrying rows; cron sessions
353
353
  // are tagged via OTB_COST_CRON_ROUTINE.
@@ -362,7 +362,7 @@ async function main() {
362
362
  console.log(` ${r.source} (${r.source_ref}) $${r.amount_usd.toFixed(4)} tiers: fresh $${r.tiers.fresh_input_usd} / cache_read $${r.tiers.cache_read_usd} / cache_write $${r.tiers.cache_write_usd} / output $${r.tiers.output_usd}`);
363
363
  }
364
364
  console.log(` reconcile: prior-single $${built.priorSingle.toFixed(4)} vs rows-sum $${built.rowsTotal.toFixed(4)} (Δ $${Math.abs(built.priorSingle - built.rowsTotal).toFixed(4)})`);
365
- process.exit(0);
365
+ return;
366
366
  }
367
367
 
368
368
  const api = await cliClient({ allowUnauthenticated: true });
@@ -389,18 +389,21 @@ async function main() {
389
389
  console.error(`session-cost: skipped (non-fatal): HTTP ${res.status} for ${r.source}`);
390
390
  }
391
391
  }
392
- process.exit(0);
392
+ return;
393
393
  } catch (err) {
394
394
  // Best-effort: never break session exit.
395
395
  console.error('session-cost: skipped (non-fatal):', err && err.message ? err.message : err);
396
- process.exit(0);
396
+ return;
397
397
  }
398
398
  }
399
399
 
400
400
  // Run only when invoked directly (the SessionEnd hook / CLI). When required by a
401
401
  // test, expose the pure-ish helpers without firing the IIFE / network / exit.
402
402
  if (require.main === module) {
403
- main();
403
+ // Drain instead of process.exit(): an exit while a socket/child handle is still closing aborts node on
404
+ // Windows (UV_HANDLE_CLOSING, src\win\async.c) — 59 SessionStart/UserPromptSubmit runs died that way
405
+ // (task 1003548). The unref'd backstop only fires if a hung request keeps the loop alive.
406
+ main().catch(() => {}).finally(() => { process.exitCode = 0; setTimeout(() => process.exit(0), 1500).unref(); });
404
407
  }
405
408
 
406
409
  module.exports = {
@@ -127,12 +127,12 @@ async function main() {
127
127
  });
128
128
 
129
129
  if (!transcriptPath) transcriptPath = newestTranscriptFor(cwd || process.cwd());
130
- if (!transcriptPath || !fs.existsSync(transcriptPath)) process.exit(0); // nothing to capture
130
+ if (!transcriptPath || !fs.existsSync(transcriptPath)) return; // nothing to capture
131
131
  if (!sessionId) sessionId = path.basename(transcriptPath, '.jsonl');
132
132
 
133
133
  // No GDS session → nothing to attribute the digest to; skip quietly.
134
134
  const session = await loadSession();
135
- if (!session || !session.token) process.exit(0);
135
+ if (!session || !session.token) return;
136
136
 
137
137
  // Secret hygiene: buildSessionDigest (ship.js) is the PRIMARY scrubber — it
138
138
  // redacts known secret PATTERNS + a high-entropy catch-all (task 1003).
@@ -180,10 +180,13 @@ async function main() {
180
180
  } catch (e) {
181
181
  console.error('session-digest: skipped —', e && e.message ? e.message : e);
182
182
  } finally {
183
- process.exit(0);
183
+ return;
184
184
  }
185
185
  }
186
186
 
187
- if (require.main === module) main();
187
+ // Drain instead of process.exit(): an exit while a socket/child handle is still closing aborts node on
188
+ // Windows (UV_HANDLE_CLOSING, src\win\async.c) — 59 SessionStart/UserPromptSubmit runs died that way
189
+ // (task 1003548). The unref'd backstop only fires if a hung request keeps the loop alive.
190
+ if (require.main === module) main().catch(() => {}).finally(() => { process.exitCode = 0; setTimeout(() => process.exit(0), 1500).unref(); });
188
191
 
189
192
  module.exports = { parseHookInput };
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/ship-check.js — run every freshness/fitness check ship.js will run, in one command,
3
+ // BEFORE you ship, and print the healing command for each stale thing.
4
+ //
5
+ // Why (task 1003548): a scan of 140 sessions found builders running these generators and checks by
6
+ // hand 223 times in three days (fitness.js ×84, gen-repo-map ×41, copy-inventory ×42, gen-api-docs
7
+ // ×16, linkify-refs ×13, docs-entropy ×12, gen-file-map ×9, gen-session-index ×6), one Bash call at
8
+ // a time, because a ship that fails preflight costs a grader run. The last 30 days of session logs
9
+ // name a stale generated file in roughly a quarter of all sessions. This is that sequence as one
10
+ // call with one table. It CHANGES NOTHING: every step is a --check; the "fix" column is what to run.
11
+ //
12
+ // Usage:
13
+ // node scripts/gds/ship-check.js checks only (fast; no tests)
14
+ // node scripts/gds/ship-check.js --tests also run the DB-free unit lane (npm test) and list failures
15
+ // node scripts/gds/ship-check.js --json
16
+ // Exit 1 when any check is stale/failing, 0 when everything is fresh.
17
+ 'use strict';
18
+ const { spawnSync } = require('node:child_process');
19
+ const fs = require('node:fs');
20
+ const path = require('node:path');
21
+
22
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
23
+
24
+ // Each step: a label, the argv to run (node scripts run with THIS node), the command that heals it,
25
+ // and how to read a non-zero exit. `optional` steps are skipped when their script is absent.
26
+ const STEPS = [
27
+ { key: 'fitness', label: 'fitness.js (architecture + budgets + freshness gate)', argv: ['scripts/gds/fitness.js'], fix: 'read the ✗ lines above; each names its fix' },
28
+ { key: 'repo-map', label: 'nested CLAUDE.md symbol blocks fresh', argv: ['scripts/gds/gen-repo-map.js', '--check-block'], fix: 'node scripts/gds/gen-repo-map.js' },
29
+ { key: 'session-index', label: 'CLAUDE.md §13 session snippet fresh', argv: ['scripts/gds/gen-session-index.js', '--check-block'], fix: 'node scripts/gds/gen-session-index.js' },
30
+ { key: 'file-map', label: 'docs/file-map.md skills/routines sections fresh', argv: ['scripts/gds/gen-file-map.js', '--check'], fix: 'node scripts/gds/gen-file-map.js (+ a note in docs/file-map.notes.json for a new skill)' },
31
+ { key: 'api-docs', label: 'docs/api/openapi.json + api-reference fresh', argv: ['scripts/gds/gen-api-docs.js', '--check'], fix: 'node scripts/gds/gen-api-docs.js' },
32
+ { key: 'copy-registry', label: 'docs/copy-registry.json fresh', argv: ['scripts/gds/copy-inventory.js', '--check'], fix: 'node scripts/gds/copy-inventory.js', optional: true },
33
+ { key: 'linkify', label: 'no bare #NNN task refs in committed markdown', argv: ['scripts/gds/linkify-refs.js', '--check'], fix: 'node scripts/gds/linkify-refs.js', optional: true },
34
+ { key: 'skill-lint', label: 'SKILL.md frontmatter parses', argv: ['scripts/gds/skill-lint.js'], fix: 'fix the ✗ lines (description: >- block scalars)' },
35
+ { key: 'tests', label: 'DB-free unit lane (npm test)', argv: ['scripts/gds/run-unit-tests.js'], fix: 'read the FAIL lines; compare against origin/main before calling any of them environmental', onlyWith: '--tests', slow: true },
36
+ ];
37
+
38
+ function runStep(step, { cwd = REPO_ROOT } = {}) {
39
+ const script = path.join(cwd, step.argv[0]);
40
+ if (!fs.existsSync(script)) return { ...step, status: step.optional ? 'skipped' : 'missing', ms: 0, tail: `${step.argv[0]} not present` };
41
+ const t0 = Date.now();
42
+ const r = spawnSync(process.execPath, [script, ...step.argv.slice(1)], { cwd, encoding: 'utf8', timeout: step.slow ? 15 * 60 * 1000 : 5 * 60 * 1000, env: { ...process.env, GDS_QUIET: '1' } });
43
+ const out = ((r.stdout || '') + (r.stderr || '')).trim().split(/\r?\n/);
44
+ const failing = out.filter((l) => /^\s*(✗|FAIL|not ok|STALE|✘)/.test(l) || /STALE|is stale|would update|failed/i.test(l));
45
+ return { ...step, status: r.status === 0 ? 'ok' : 'fail', code: r.status, signal: r.signal, ms: Date.now() - t0, tail: (failing.length ? failing : out.slice(-4)).slice(0, 12).join('\n') };
46
+ }
47
+
48
+ function summarize(results) {
49
+ const stale = results.filter((r) => r.status === 'fail' || r.status === 'missing');
50
+ return { ok: stale.length === 0, stale: stale.map((r) => r.key), results };
51
+ }
52
+
53
+ function format(summary) {
54
+ const lines = ['ship-check — what ship.js will trip on', ''];
55
+ for (const r of summary.results) {
56
+ const mark = r.status === 'ok' ? '✓' : r.status === 'skipped' ? '·' : '✗';
57
+ lines.push(`${mark} ${r.label} (${r.ms} ms)`);
58
+ if (r.status !== 'ok' && r.status !== 'skipped') {
59
+ for (const t of String(r.tail).split('\n')) lines.push(` ${t}`);
60
+ lines.push(` fix → ${r.fix}`);
61
+ }
62
+ }
63
+ lines.push('');
64
+ lines.push(summary.ok ? 'all fresh — ship.js preflight should pass; the grader and CI are the remaining gates.' : `${summary.stale.length} stale: ${summary.stale.join(', ')} — heal, commit, re-run.`);
65
+ return lines.join('\n');
66
+ }
67
+
68
+ function main() {
69
+ const argv = process.argv.slice(2);
70
+ const steps = STEPS.filter((s) => !s.onlyWith || argv.includes(s.onlyWith));
71
+ const results = steps.map((s) => runStep(s));
72
+ const summary = summarize(results);
73
+ console.log(argv.includes('--json') ? JSON.stringify(summary, null, 1) : format(summary));
74
+ process.exitCode = summary.ok ? 0 : 1;
75
+ }
76
+
77
+ module.exports = { STEPS, runStep, summarize, format };
78
+ if (require.main === module) main();
@@ -87,12 +87,12 @@ function regenerateRepoMap() {
87
87
  console.log(' repo-map regen failed (non-fatal) — CI --check-block backstops the tracked blocks');
88
88
  return 'regen-failed';
89
89
  }
90
- const dirty = gitOk('git status --porcelain src/bongos/CLAUDE.md src/bongos/routes/CLAUDE.md scripts/gds/CLAUDE.md');
90
+ const dirty = gitOk('git status --porcelain src/bongos/CLAUDE.md src/bongos/routes/CLAUDE.md');
91
91
  if (!dirty) {
92
92
  console.log(' nested-doc symbol blocks already current — nothing to commit (docs/repo-map.md is gitignored, ADR 0110)');
93
93
  return 'current';
94
94
  }
95
- shellOk('git add src/bongos/CLAUDE.md src/bongos/routes/CLAUDE.md scripts/gds/CLAUDE.md');
95
+ shellOk('git add src/bongos/CLAUDE.md src/bongos/routes/CLAUDE.md');
96
96
  if (shellOk('git commit -m "chore(repo-map): auto-regenerate nested-doc symbol blocks (task 1224)"')) {
97
97
  console.log(' committed auto-regenerated nested-doc symbol blocks');
98
98
  return 'committed';
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/skill-lint.js — lint SKILL.md frontmatter the way Claude Code's YAML parser reads it.
3
+ //
4
+ // Why (task 1003548): nine of this repo's fifty skills shipped with an unquoted
5
+ // `description:` that contained ": " (e.g. "on it: (re)connect", "HARD RULE: never").
6
+ // YAML reads a colon-space inside a plain scalar as a new mapping key and the whole
7
+ // frontmatter fails to parse; Claude Code then loads the skill with EVERY field
8
+ // dropped — the name falls back to the folder, the description to the body's first
9
+ // line — and nothing warns. Nothing in CI caught it. This linter reproduces the
10
+ // parser's failure modes without a YAML dependency, so it can run in fitness.js
11
+ // (CI), as a PostToolUse hook on SKILL.md edits, and by hand.
12
+ //
13
+ // It also reports the context cost: the skill listing is budgeted at ~1% of the
14
+ // context window, and a description is resident in EVERY session, so long ones
15
+ // crowd the others out and get truncated (skill routing degrades).
16
+ //
17
+ // NOT A YAML PARSER, on purpose. These are targeted regex checks for the hazards that
18
+ // actually bit us (a plain scalar carrying ": " or " #", an unclosed quote or flow
19
+ // collection, a tab, a continuation line), so the repo gains a CI gate with no new
20
+ // dependency. It can therefore under-report an exotic frontmatter shape. The reference
21
+ // parser is `claude plugin validate .claude`, which uses Claude Code's own YAML reader —
22
+ // run it when a file looks fine here but the skill still loads wrong, and treat its
23
+ // verdict as authoritative over this one.
24
+ //
25
+ // Usage:
26
+ // node scripts/gds/skill-lint.js lint every .claude/skills/*/SKILL.md; exit 1 on any error
27
+ // node scripts/gds/skill-lint.js <file...> lint specific files
28
+ // node scripts/gds/skill-lint.js --hook PostToolUse mode: read the hook JSON on stdin, lint the
29
+ // edited file if it is a SKILL.md, print findings, exit 0
30
+ // node scripts/gds/skill-lint.js --json machine-readable
31
+ 'use strict';
32
+ const fs = require('node:fs');
33
+ const path = require('node:path');
34
+
35
+ const DESC_WARN_CHARS = 400; // longer than this and the listing entry is likely truncated
36
+ const DESC_HARD_CHARS = 1024; // Claude Code's own cap on a description
37
+ const LISTING_BUDGET_CHARS = 8000; // ~2k tokens ≈ 1% of a 200k window (names + descriptions, all skills)
38
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
39
+
40
+ // The YAML plain-scalar hazards that make the WHOLE frontmatter unparseable or
41
+ // silently change a value. Each returns a message or null.
42
+ const PLAIN_SCALAR_CHECKS = [
43
+ (v) => (/: /.test(v) || /:$/.test(v)) ? 'contains ": " (colon-space) — YAML reads it as a new key and the whole frontmatter fails to parse' : null,
44
+ (v) => / #/.test(v) ? 'contains " #" — YAML starts a comment there and silently truncates the value' : null,
45
+ (v) => (/^\[/.test(v) && !/\]$/.test(v)) || (/^\{/.test(v) && !/\}$/.test(v)) ? 'opens a flow sequence/map it never closes on the same line' : null,
46
+ (v) => /^[\]}>|*&!%@`,]/.test(v) ? `starts with "${v[0]}" — a YAML indicator; the value will not be read as text` : null,
47
+ (v) => /^-\s/.test(v) ? 'starts with "- " — YAML reads a list, not text' : null,
48
+ ];
49
+
50
+ // Split the leading frontmatter block. Returns null when the file has none.
51
+ function splitFrontmatter(text) {
52
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
53
+ const lines = text.split(eol);
54
+ if (lines[0] !== '---') return null;
55
+ const end = lines.indexOf('---', 1);
56
+ if (end < 0) return null;
57
+ return { lines: lines.slice(1, end), bodyStart: end + 1, eol };
58
+ }
59
+
60
+ // Lint one frontmatter block (array of lines). Returns { errors, warnings, fields }.
61
+ // `fields` holds each top-level key's raw value; block scalars are joined.
62
+ function lintFrontmatter(lines) {
63
+ const errors = [], warnings = [], fields = {};
64
+ let cur = null, curKind = null; // curKind: 'plain' | 'block' | 'quoted'
65
+ lines.forEach((ln, i) => {
66
+ const n = i + 2; // 1-based line number in the file (line 1 is the opening ---)
67
+ if (!ln.trim()) return;
68
+ if (/^\t/.test(ln)) { errors.push(`line ${n}: tab indentation — YAML forbids tabs`); return; }
69
+ const kv = /^([A-Za-z0-9_-]+):(?:\s+(.*))?$/.exec(ln);
70
+ if (kv && !/^\s/.test(ln)) {
71
+ cur = kv[1];
72
+ const v = (kv[2] || '').trim();
73
+ if (fields[cur] !== undefined) errors.push(`line ${n}: duplicate key "${cur}"`);
74
+ if (v === '' ) { fields[cur] = ''; curKind = 'nested'; return; }
75
+ if (/^[>|][+-]?\d*$/.test(v)) { fields[cur] = ''; curKind = 'block'; return; }
76
+ if (/^(["']).*\1$/.test(v)) { fields[cur] = v.slice(1, -1); curKind = 'quoted'; return; }
77
+ if (/^["']/.test(v)) { errors.push(`line ${n}: "${cur}" opens a quote it never closes on the same line`); fields[cur] = v; curKind = 'plain'; return; }
78
+ curKind = 'plain'; fields[cur] = v;
79
+ for (const chk of PLAIN_SCALAR_CHECKS) { const m = chk(v); if (m) { errors.push(`line ${n}: "${cur}" ${m}. Fix: write it as a block scalar — "${cur}: >-" then the text indented two spaces`); break; } }
80
+ return;
81
+ }
82
+ if (/^\s+\S/.test(ln)) {
83
+ if (!cur) { errors.push(`line ${n}: indented text before any key`); return; }
84
+ if (curKind === 'block' || curKind === 'nested') { fields[cur] = (fields[cur] ? fields[cur] + ' ' : '') + ln.trim(); return; }
85
+ errors.push(`line ${n}: continuation under "${cur}" — a plain value cannot span lines; use "${cur}: >-" with the text indented`);
86
+ return;
87
+ }
88
+ errors.push(`line ${n}: not "key: value" — "${ln.slice(0, 60)}"`);
89
+ });
90
+ return { errors, warnings, fields };
91
+ }
92
+
93
+ // Lint one SKILL.md file's text. `file` is used for the name check and messages.
94
+ function lintSkillFile(text, { file = 'SKILL.md' } = {}) {
95
+ const dirName = path.basename(path.dirname(file));
96
+ const fm = splitFrontmatter(text);
97
+ const res = { file, errors: [], warnings: [], name: null, description: '', listingChars: 0 };
98
+ if (!fm) { res.errors.push('no YAML frontmatter (the file must start with --- and carry name + description)'); return res; }
99
+ const { errors, warnings, fields } = lintFrontmatter(fm.lines);
100
+ res.errors.push(...errors); res.warnings.push(...warnings);
101
+ const name = fields.name || '', desc = fields.description || '';
102
+ if (!name) res.errors.push('missing "name"');
103
+ else if (name !== dirName && dirName !== 'SKILL.md') res.warnings.push(`name "${name}" differs from its folder "${dirName}" — the folder name wins in listings`);
104
+ if (!desc) res.errors.push('missing or empty "description" — the skill would match on its body text');
105
+ if (desc.length > DESC_HARD_CHARS) res.errors.push(`description is ${desc.length} chars — over Claude Code's ${DESC_HARD_CHARS}-char cap`);
106
+ else if (desc.length > DESC_WARN_CHARS) res.warnings.push(`description is ${desc.length} chars — over the ~${DESC_WARN_CHARS} we aim for; every char is resident in every session`);
107
+ res.name = name || dirName; res.description = desc; res.listingChars = res.name.length + desc.length;
108
+ return res;
109
+ }
110
+
111
+ // Every SKILL.md under .claude/skills/ (one level deep).
112
+ function listSkillFiles(root = REPO_ROOT) {
113
+ const dir = path.join(root, '.claude', 'skills');
114
+ if (!fs.existsSync(dir)) return [];
115
+ return fs.readdirSync(dir, { withFileTypes: true })
116
+ .filter((d) => d.isDirectory())
117
+ .map((d) => path.join(dir, d.name, 'SKILL.md'))
118
+ .filter((f) => fs.existsSync(f));
119
+ }
120
+
121
+ // Lint a set of files; returns per-file results plus the listing total vs budget.
122
+ function lintSkills(files) {
123
+ const results = files.map((f) => lintSkillFile(fs.readFileSync(f, 'utf8'), { file: f }));
124
+ const listingChars = results.reduce((a, r) => a + r.listingChars, 0);
125
+ return { results, listingChars, budgetChars: LISTING_BUDGET_CHARS, overBudget: listingChars > LISTING_BUDGET_CHARS };
126
+ }
127
+
128
+ function isSkillMd(p) { return /[\\/]\.claude[\\/]skills[\\/][^\\/]+[\\/]SKILL\.md$/.test(String(p || '')); }
129
+
130
+ function format(report, { relTo = REPO_ROOT } = {}) {
131
+ const out = [];
132
+ for (const r of report.results) {
133
+ const rel = path.relative(relTo, r.file).replace(/\\/g, '/');
134
+ for (const e of r.errors) out.push(`✗ ${rel}: ${e}`);
135
+ for (const w of r.warnings) out.push(`! ${rel}: ${w}`);
136
+ }
137
+ const errs = report.results.reduce((a, r) => a + r.errors.length, 0);
138
+ const warns = report.results.reduce((a, r) => a + r.warnings.length, 0);
139
+ out.push(`skill-lint: ${report.results.length} skill(s), ${errs} error(s), ${warns} warning(s); listing ${report.listingChars} chars (~${Math.round(report.listingChars / 4)} tokens) vs ~${report.budgetChars}-char budget${report.overBudget ? ' — OVER: entries get truncated and routing degrades' : ''}`);
140
+ return out.join('\n');
141
+ }
142
+
143
+ function main() {
144
+ const argv = process.argv.slice(2);
145
+ const json = argv.includes('--json');
146
+ if (argv.includes('--hook')) {
147
+ // PostToolUse: stdin carries {tool_input:{file_path}}. Only speak when a SKILL.md was touched.
148
+ let raw = '';
149
+ process.stdin.setEncoding('utf8');
150
+ process.stdin.on('data', (d) => { raw += d; });
151
+ process.stdin.on('end', () => {
152
+ try {
153
+ const j = JSON.parse(raw || '{}');
154
+ const fp = (j.tool_input && (j.tool_input.file_path || j.tool_input.path)) || '';
155
+ if (!isSkillMd(fp) || !fs.existsSync(fp)) return;
156
+ const report = lintSkills([fp]);
157
+ const r = report.results[0];
158
+ if (r.errors.length || r.warnings.length) console.log('[skill-lint] ' + format(report).split('\n').slice(0, -1).join('\n[skill-lint] '));
159
+ } catch (_) { /* a hook never breaks the edit */ }
160
+ process.exitCode = 0;
161
+ });
162
+ return;
163
+ }
164
+ const files = argv.filter((a) => !a.startsWith('--'));
165
+ const report = lintSkills(files.length ? files.map((f) => path.resolve(f)) : listSkillFiles());
166
+ if (json) console.log(JSON.stringify(report, null, 1)); else console.log(format(report));
167
+ process.exitCode = report.results.some((r) => r.errors.length) ? 1 : 0;
168
+ }
169
+
170
+ module.exports = { lintFrontmatter, lintSkillFile, lintSkills, listSkillFiles, splitFrontmatter, isSkillMd, format, DESC_WARN_CHARS, DESC_HARD_CHARS, LISTING_BUDGET_CHARS };
171
+ if (require.main === module) main();
@@ -202,6 +202,9 @@ function line(r, { max = 3 } = {}) {
202
202
  }[worst.shape] || 'parked mid-pipeline';
203
203
  return `Stranded ship${r.strands.length === 1 ? '' : 's'}: ${shown.join('; ')}${more}. `
204
204
  + `Worst: ${lead} — ${worst.remedy}. `
205
+ // Name the skill, not just the diagnosis: 19 of the last 30 days' session logs mention a strand,
206
+ // and the next claim is refused REBASE_REQUIRED until one is landed or released (task 1003548).
207
+ + `Run /strand-fix ${worst.id} to walk it. `
205
208
  + 'A strand produces no signal of its own; this line is the only one.';
206
209
  }
207
210
 
package/src/module-api.js CHANGED
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
45
45
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
46
46
  // the entry to that file. Look for a version's history there, not here.
47
47
  // ---------------------------------------------------------------------------
48
- const CORE_VERSION = '1.19.526'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
48
+ const CORE_VERSION = '1.19.528'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
49
49
 
50
50
  // A namespaced logger so a module's log lines are attributable + consistent.
51
51
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -49,6 +49,16 @@ t('bongosify: $CLAUDE_PROJECT_DIR + surrounding quotes are stripped', () => {
49
49
  assert.equal(m.bongosify('node "$CLAUDE_PROJECT_DIR/scripts/gds/session-cost.js"'), 'bongos exec scripts/gds/session-cost.js');
50
50
  });
51
51
 
52
+ t('bongosify: the run-node.sh resolver form (task 1003548) collapses to the same bongos form', () => {
53
+ const wrap = 'sh "$CLAUDE_PROJECT_DIR/.claude/hooks/run-node.sh"';
54
+ assert.equal(m.bongosify(`${wrap} "$CLAUDE_PROJECT_DIR/scripts/gds/session-cost.js"`), 'bongos exec scripts/gds/session-cost.js');
55
+ assert.equal(m.bongosify(`${wrap} "$CLAUDE_PROJECT_DIR/scripts/gds/deploy-divergence.js" --line --timeout-ms 2500`), 'bongos exec scripts/gds/deploy-divergence.js --line --timeout-ms 2500');
56
+ assert.equal(m.bongosify(`${wrap} "$CLAUDE_PROJECT_DIR/scripts/gds/ship.js" 1885`), 'bongos ship 1885');
57
+ // …and a resolver call whose target is a .claude/hooks/*.js stays instance-local, like the bare-node form.
58
+ const hook = `${wrap} "$CLAUDE_PROJECT_DIR/.claude/hooks/secret-gate.js"`;
59
+ assert.equal(m.bongosify(hook), hook);
60
+ });
61
+
52
62
  t('bongosify: a .claude/hooks/ reference is LEFT untouched (instance-local after materialize)', () => {
53
63
  const cmd = 'node "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.js"';
54
64
  assert.equal(m.bongosify(cmd), cmd);
@@ -0,0 +1,78 @@
1
+ // tests/edit_hints.mjs — the PostToolUse edit-hints hook maps an edited path to the generator it makes
2
+ // stale, reconciles the path shapes Windows hosts hand it, and stays silent on everything else
3
+ // (task 1003548; evidence: a stale generated file in ~a quarter of the last 30 days' sessions).
4
+ import { strict as assert } from 'node:assert';
5
+ import { test } from 'node:test';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { createRequire } from 'node:module';
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ const require = createRequire(import.meta.url);
14
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
15
+ const HOOK = path.join(ROOT, '.claude', 'hooks', 'edit-hints.js');
16
+ if (!fs.existsSync(HOOK)) {
17
+ console.log('edit_hints: SKIP — .claude/hooks/ is not materialized in this checkout (package-excluded).');
18
+ process.exit(0);
19
+ }
20
+ const hints = require(HOOK);
21
+
22
+ test('each generator input maps to its generator; unrelated paths map to nothing', () => {
23
+ const cases = [
24
+ ['modules/hall-ui/public/people.js', 'copy-inventory.js'],
25
+ ['modules/status-ui/public/index.html', 'copy-inventory.js'],
26
+ ['src/bongos/auth.js', 'gen-repo-map.js'],
27
+ ['modules/lifecycle/routes/claims.js', 'gen-api-docs.js'],
28
+ ['.claude/skills/dev/SKILL.md', 'gen-file-map.js'],
29
+ ['docs/session-logs/2026-09-03-x.md', 'gen-session-index.js'],
30
+ ['docs/adr/0236-something.md', 'fitness.js'],
31
+ ];
32
+ for (const [p, gen] of cases) {
33
+ const h = hints.generatorHint(p);
34
+ assert.ok(h && h.includes(gen), `${p} → expected ${gen}, got ${h}`);
35
+ }
36
+ for (const p of ['README.md', 'tests/foo.mjs', 'modules/hall-ui/CLAUDE.md', 'scripts/gds/ship.js']) {
37
+ assert.equal(hints.generatorHint(p), null, `${p} should be silent`);
38
+ }
39
+ });
40
+
41
+ test('rel() reconciles C:\\, C:/ and /c/ shapes between CLAUDE_PROJECT_DIR and file_path', () => {
42
+ const want = 'modules/hall-ui/public/people.js';
43
+ assert.equal(hints.rel('C:\\r\\repo\\modules\\hall-ui\\public\\people.js', 'C:\\r\\repo'), want);
44
+ assert.equal(hints.rel('C:\\r\\repo\\modules\\hall-ui\\public\\people.js', 'C:/r/repo'), want);
45
+ assert.equal(hints.rel('/c/r/repo/modules/hall-ui/public/people.js', 'C:\\r\\repo'), want);
46
+ assert.equal(hints.rel('c:/r/repo/modules/hall-ui/public/people.js', 'C:/r/repo/'), want);
47
+ assert.equal(hints.rel('/home/u/repo/modules/hall-ui/public/people.js', '/home/u/repo'), want);
48
+ });
49
+
50
+ test('run as a hook: prints one hint for a generator input, nothing for an unrelated file, exit 0 both times', () => {
51
+ const projectDir = ROOT;
52
+ const run = (relPath) => spawnSync(process.execPath, [HOOK], {
53
+ input: JSON.stringify({ tool_input: { file_path: path.join(projectDir, ...relPath.split('/')) } }),
54
+ encoding: 'utf8', timeout: 15000, env: { ...process.env, CLAUDE_PROJECT_DIR: projectDir },
55
+ });
56
+ const a = run('modules/hall-ui/public/people.js');
57
+ assert.equal(a.status, 0, a.stderr);
58
+ assert.match(a.stdout, /copy-registry\.json is now stale/);
59
+ assert.match(a.stdout, /copy-inventory\.js/);
60
+ const b = run('README.md');
61
+ assert.equal(b.status, 0, b.stderr);
62
+ assert.equal(b.stdout.trim(), '');
63
+ });
64
+
65
+ test('run as a hook on a SKILL.md with a broken description: names the line and the fix', () => {
66
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'edit-hints-'));
67
+ const skill = path.join(tmp, '.claude', 'skills', 'demo', 'SKILL.md');
68
+ fs.mkdirSync(path.dirname(skill), { recursive: true });
69
+ fs.writeFileSync(skill, '---\nname: demo\ndescription: Do it: now\n---\nBody\n');
70
+ const r = spawnSync(process.execPath, [HOOK], {
71
+ input: JSON.stringify({ tool_input: { file_path: skill } }),
72
+ encoding: 'utf8', timeout: 15000, env: { ...process.env, CLAUDE_PROJECT_DIR: ROOT },
73
+ });
74
+ fs.rmSync(tmp, { recursive: true, force: true });
75
+ assert.equal(r.status, 0, r.stderr);
76
+ assert.match(r.stdout, /\[skill-lint\] ✗ .*line 3/);
77
+ assert.match(r.stdout, /description: >-/);
78
+ });
@@ -64,7 +64,7 @@ const MARKERS = /^(<{7}|={7}|>{7})/m;
64
64
  }
65
65
 
66
66
  // ── helpers to build nested-CLAUDE.md fixtures from the real committed file ─────
67
- const NESTED = 'scripts/gds/CLAUDE.md';
67
+ const NESTED = 'src/bongos/CLAUDE.md'; // scripts/gds left NESTED_DIRS in task 1003548
68
68
  const realNested = fs.readFileSync(path.join(repo, NESTED), 'utf8');
69
69
  assert.ok(realNested.includes(G.BEGIN) && realNested.includes(G.END), 'fixture file must carry the generated block markers');
70
70
  // Replace the INNER block text (keep the BEGIN/END marker lines intact).
@@ -0,0 +1,109 @@
1
+ // tests/grader_root_outage.mjs — a grader that could not START must say so, and it
2
+ // must be able to start as root (task 1003574, goal 1000024).
3
+ //
4
+ // THE BUG THIS PINS. #943 added a 'unavailable' panel outcome for a total grader
5
+ // outage, so a builder sees "grader unavailable" instead of a FAIL nobody produced.
6
+ // It worked when every gating worker was an LLM subagent. Then the DETERMINISTIC
7
+ // pseudo-workers (route-rank, permission-path) were appended to the panel — they
8
+ // never spawn a subprocess, so an outage cannot touch them, and one of them passing
9
+ // made `gating.every(isMissingSignal)` permanently false. The outage branch became
10
+ // unreachable: a total subagent outage fell through to the securityLensErrored branch
11
+ // and was recorded as `FAIL · 0/10` with zero issues and every rubric axis null.
12
+ //
13
+ // That shape is indistinguishable from a real rejection in a terminal, and it routes
14
+ // a builder to /grade-recover Branch A — whose prescribed re-grade cannot clear an
15
+ // outage that is deterministic. Observed end-to-end on task 1003280, whose ship was
16
+ // blocked twice by a panel that never ran.
17
+ //
18
+ // Run: node --test tests/grader_root_outage.mjs
19
+ import { strict as assert } from 'node:assert';
20
+ import { test } from 'node:test';
21
+ import { createRequire } from 'node:module';
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ const require = createRequire(import.meta.url);
27
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
28
+ const grader = require(path.join(ROOT, 'modules', 'grading', 'grader.js'));
29
+ const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8');
30
+
31
+ // An LLM worker whose subprocess died — no signal at all.
32
+ const errored = (kind) => ({
33
+ kind, name: kind, specialty: 'x', verdict: 'error', scores: {}, issues: [],
34
+ notes_md: '', confidence: 0, model: 'claude-sonnet-5', run_ms: 10,
35
+ error: 'SUBAGENT_NONZERO_EXIT',
36
+ });
37
+ // The deterministic pseudo-worker, exactly as route-rank-check.js stamps it.
38
+ const deterministicPass = (kind = 'route-rank') => ({
39
+ kind, name: kind, specialty: 'x', verdict: 'pass', scores: {}, issues: [],
40
+ notes_md: `${kind} pass`, confidence: 1, model: 'deterministic', run_ms: 0, error: null,
41
+ });
42
+ const llmPass = (kind) => ({
43
+ kind, name: kind, specialty: 'x', verdict: 'pass', scores: { code_quality: 8 }, issues: [],
44
+ notes_md: '', confidence: 0.8, model: 'claude-sonnet-5', run_ms: 10, error: null,
45
+ });
46
+
47
+ test('the exact task-1003280 shape: every subagent dead, route-rank passing → unavailable, not fail', () => {
48
+ const r = grader.aggregatePanel([
49
+ errored('narc'), errored('quality'), errored('hacker'), errored('efficiency'),
50
+ deterministicPass('route-rank'),
51
+ ]);
52
+ assert.equal(r.signals.panel_outcome, 'unavailable',
53
+ 'a passing deterministic check is not evidence the panel ran');
54
+ assert.equal(r.passed, false, 'unavailable never auto-passes — it routes to manual-confirm');
55
+ assert.match(r.notes_md, /GRADER UNAVAILABLE/);
56
+ // The tell that made this so hard to read in a terminal: no findings at all.
57
+ assert.equal((r.issues || []).length, 0);
58
+ });
59
+
60
+ test('two deterministic pseudo-workers do not resurrect the fail either', () => {
61
+ const r = grader.aggregatePanel([
62
+ errored('narc'), errored('quality'), errored('hacker'), errored('efficiency'),
63
+ deterministicPass('route-rank'), deterministicPass('permission-path'),
64
+ ]);
65
+ assert.equal(r.signals.panel_outcome, 'unavailable');
66
+ });
67
+
68
+ test('a DETERMINISTIC FAIL still hard-blocks — the fix does not soften the gate', () => {
69
+ const detFail = { ...deterministicPass('route-rank'), verdict: 'fail',
70
+ issues: [{ severity: 'blocker', file: 'r.js', summary: 'ungated write route' }] };
71
+ const r = grader.aggregatePanel([
72
+ errored('narc'), errored('quality'), errored('hacker'), errored('efficiency'), detFail,
73
+ ]);
74
+ assert.equal(r.signals.panel_outcome, 'fail',
75
+ 'gatingFail is the first branch and must still win over the outage branch');
76
+ assert.equal(r.passed, false);
77
+ });
78
+
79
+ test('a real security-lens outage with the rest of the panel alive still hard-blocks (#574 intact)', () => {
80
+ const r = grader.aggregatePanel([
81
+ llmPass('narc'), llmPass('quality'), errored('hacker'), llmPass('efficiency'),
82
+ deterministicPass('route-rank'),
83
+ ]);
84
+ assert.equal(r.signals.panel_outcome, 'fail', 'a missing Hacker among live workers is still a block');
85
+ assert.match(r.notes_md, /SECURITY LENS MISSING/);
86
+ });
87
+
88
+ test('a healthy panel is unaffected', () => {
89
+ const r = grader.aggregatePanel([
90
+ llmPass('narc'), llmPass('quality'), llmPass('hacker'), llmPass('efficiency'),
91
+ deterministicPass('route-rank'),
92
+ ]);
93
+ assert.equal(r.signals.panel_outcome, 'pass');
94
+ assert.equal(r.passed, true);
95
+ });
96
+
97
+ // ---- the spawn half -------------------------------------------------------
98
+
99
+ test('the worker spawn picks a permission mode the CLI accepts as root', () => {
100
+ const src = read('modules', 'grading', 'grader-subagent.js');
101
+ // The refusal is euid-based, so the choice must be too.
102
+ assert.match(src, /process\.getuid\(\) === 0/);
103
+ assert.match(src, /isRoot \? 'acceptEdits' : 'bypassPermissions'/);
104
+ // The flag must be passed as the computed value, never the hardcoded wide one.
105
+ assert.match(src, /'--permission-mode', permissionMode/);
106
+ assert.doesNotMatch(src, /'--permission-mode', 'bypassPermissions'/);
107
+ // The tool list is what makes acceptEdits cost nothing: there are no edits.
108
+ assert.match(src, /'--tools', 'Read'/);
109
+ });
@@ -20,7 +20,13 @@ import path from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
 
22
22
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
- const HOOKS = ['recall-hints', 'rank-change-greeting', 'conductor'].map((n) => path.join(ROOT, '.claude', 'hooks', `${n}.js`));
23
+ const HOOKS = ['recall-hints', 'rank-change-greeting', 'conductor', 'pull-memory', 'edit-hints'].map((n) => path.join(ROOT, '.claude', 'hooks', `${n}.js`));
24
+ // The SessionStart/SessionEnd scripts that did network work and then process.exit()ed — 59 recorded
25
+ // UV_HANDLE_CLOSING aborts in three days (task 1003548). Same contract: exit 0 on their own, no abort.
26
+ // session-digest requires `pg` at load (via db-kernel), so it can only be exercised where node_modules
27
+ // is installed — the CI lane; a bare Windows worktree skips it rather than reporting a phantom fail.
28
+ const hasPg = fs.existsSync(path.join(ROOT, 'node_modules', 'pg'));
29
+ const SCRIPTS = ['fetch-sound-prefs', 'session-cost', ...(hasPg ? ['session-digest'] : [])].map((n) => path.join(ROOT, 'scripts', 'gds', `${n}.js`));
24
30
 
25
31
  if (!HOOKS.every((p) => fs.existsSync(p))) {
26
32
  console.log('hooks_drain: SKIP — .claude/hooks/ is not materialized in this checkout (package-excluded).');
@@ -42,7 +48,7 @@ const payload = JSON.stringify({ session_id: 's1', transcript_path: transcript,
42
48
  const env = { ...process.env, HOME, USERPROFILE: HOME, CLAUDE_PROJECT_DIR: ROOT };
43
49
  for (const k of Object.keys(env)) if (/_API_BASE$|^OTB_|^CLOUDBONGOS_|^GDS_|^PMS_/.test(k)) delete env[k];
44
50
 
45
- for (const hook of HOOKS) {
51
+ for (const hook of [...HOOKS, ...SCRIPTS]) {
46
52
  t(`${path.basename(hook)} exits on its own with 0 and no libuv abort (x3)`, () => {
47
53
  for (let i = 0; i < 3; i++) {
48
54
  const r = spawnSync(process.execPath, [hook], { input: payload, encoding: 'utf8', timeout: 15000, env });
@@ -67,7 +67,7 @@ test('the generated repo map has the ranked global table and real signatures', (
67
67
  test('each nested CLAUDE.md carries a delimited generated block with signatures', () => {
68
68
  const BEGIN = '<!-- BEGIN GENERATED REPO-MAP';
69
69
  const END = '<!-- END GENERATED REPO-MAP -->';
70
- for (const dir of ['src/bongos', 'src/bongos/routes', 'scripts/gds']) {
70
+ for (const dir of ['src/bongos', 'src/bongos/routes']) { // scripts/gds left NESTED_DIRS in task 1003548
71
71
  const doc = readFileSync(path.join(ROOT, dir, 'CLAUDE.md'), 'utf8');
72
72
  assert.ok(doc.includes(BEGIN), `${dir}/CLAUDE.md should have a generated-block start marker`);
73
73
  assert.ok(doc.includes(END), `${dir}/CLAUDE.md should have a generated-block end marker`);