@bongos/core 1.19.525 → 1.19.527
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +89 -49
- package/.claude/skills/blocker-solve/SKILL.md +2 -1
- package/.claude/skills/builder-box/SKILL.md +2 -1
- package/.claude/skills/builder-connect/SKILL.md +2 -1
- package/.claude/skills/builder-end/SKILL.md +2 -1
- package/.claude/skills/builder-exit/SKILL.md +2 -1
- package/.claude/skills/builder-key/SKILL.md +2 -1
- package/.claude/skills/builder-sequence/SKILL.md +2 -1
- package/.claude/skills/builder-stage/SKILL.md +2 -1
- package/.claude/skills/design/SKILL.md +2 -1
- package/.claude/skills/goal-create/SKILL.md +2 -1
- package/.claude/skills/goal-review/SKILL.md +2 -1
- package/.claude/skills/grade-audit/SKILL.md +2 -1
- package/.claude/skills/grade-recover/SKILL.md +2 -1
- package/.claude/skills/ideate/SKILL.md +2 -1
- package/.claude/skills/new-project/SKILL.md +2 -1
- package/.claude/skills/otb-character-review/SKILL.md +2 -1
- package/.claude/skills/paint/SKILL.md +2 -1
- package/.claude/skills/scan-before-install/SKILL.md +2 -1
- package/.claude/skills/ship-check/SKILL.md +44 -0
- package/.claude/skills/strand-fix/SKILL.md +2 -1
- package/.claude/skills/worktree-clean/SKILL.md +2 -1
- package/.gitattributes +6 -2
- package/docs/adr/0242-a-cascade-is-a-declaration-table-on-the-event-that-already-exists.md +107 -0
- package/docs/adr/README.md +1 -0
- package/docs/file-map.md +4 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/windows-builders.md +46 -0
- package/modules/grading/generated-provenance.js +1 -1
- package/modules/lifecycle/cascade.js +238 -0
- package/modules/lifecycle/routes/lifecycle.js +25 -0
- package/modules/lifecycle/tests/cascade.mjs +197 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/claude-materialize.js +7 -2
- package/scripts/gds/fitness.js +40 -1
- package/scripts/gds/gen-repo-map.js +3 -1
- package/scripts/gds/session-cost.js +10 -7
- package/scripts/gds/session-digest.js +7 -4
- package/scripts/gds/ship-check.js +78 -0
- package/scripts/gds/ship-regen.js +2 -2
- package/scripts/gds/skill-lint.js +171 -0
- package/scripts/gds/strand-watch.js +3 -0
- package/src/module-api.js +1 -1
- package/tests/claude_materialize.mjs +10 -0
- package/tests/edit_hints.mjs +78 -0
- package/tests/git_merge_regen.mjs +1 -1
- package/tests/hooks_drain.mjs +8 -2
- package/tests/repo_map.mjs +1 -1
- package/tests/skill_lint.mjs +73 -0
|
@@ -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.
|
|
48
|
+
const CORE_VERSION = '1.19.527'; // 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 = '
|
|
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).
|
package/tests/hooks_drain.mjs
CHANGED
|
@@ -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 });
|
package/tests/repo_map.mjs
CHANGED
|
@@ -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'
|
|
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`);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// tests/skill_lint.mjs — the SKILL.md frontmatter linter reproduces the YAML failure modes that
|
|
2
|
+
// silently broke ten skills (task 1003548), and the committed skills all pass it.
|
|
3
|
+
import { strict as assert } from 'node:assert';
|
|
4
|
+
import { test } from 'node:test';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
11
|
+
const lint = require('../scripts/gds/skill-lint.js');
|
|
12
|
+
|
|
13
|
+
const fm = (desc, extra = '') => `---\nname: demo\ndescription: ${desc}\n${extra}---\n\nBody text.\n`;
|
|
14
|
+
const run = (text) => lint.lintSkillFile(text, { file: path.join('x', '.claude', 'skills', 'demo', 'SKILL.md') });
|
|
15
|
+
|
|
16
|
+
test('a plain description containing ": " is an error (the whole frontmatter would fail to parse)', () => {
|
|
17
|
+
const r = run(fm('Show the box and help them get on it: reconnect or wake.'));
|
|
18
|
+
assert.equal(r.errors.length, 1, r.errors.join('; '));
|
|
19
|
+
assert.match(r.errors[0], /colon-space/);
|
|
20
|
+
assert.match(r.errors[0], /description: >-/, 'the message carries the fix');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('a plain description containing " #" is an error (YAML would truncate it as a comment)', () => {
|
|
24
|
+
const r = run(fm('Connect the box (ADR 0031, #701) and open the terminal.'));
|
|
25
|
+
assert.equal(r.errors.length, 1);
|
|
26
|
+
assert.match(r.errors[0], / #/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('the same text as a folded block scalar is clean', () => {
|
|
30
|
+
const r = run('---\nname: demo\ndescription: >-\n Show the box and help them get on it: reconnect (ADR 0031, #701) or wake.\n---\n\nBody.\n');
|
|
31
|
+
assert.deepEqual(r.errors, []);
|
|
32
|
+
assert.match(r.description, /reconnect \(ADR 0031, #701\)/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('a double-quoted description is clean; an unclosed quote is an error', () => {
|
|
36
|
+
assert.deepEqual(run(fm('"Do it: now"')).errors, []);
|
|
37
|
+
assert.equal(run(fm('"Do it: now')).errors.length, 1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('a flow list in requires is valid YAML and not flagged', () => {
|
|
41
|
+
const r = run(fm('Fine text.', 'requires: [gh, main-checkout]\n'));
|
|
42
|
+
assert.deepEqual(r.errors, []);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('missing name or description is an error; a folder/name mismatch is a warning', () => {
|
|
46
|
+
assert.match(run('---\nname: demo\n---\nBody\n').errors.join(' '), /missing or empty "description"/);
|
|
47
|
+
assert.match(run('---\ndescription: x\n---\nBody\n').errors.join(' '), /missing "name"/);
|
|
48
|
+
const r = lint.lintSkillFile('---\nname: other\ndescription: x\n---\nBody\n', { file: path.join('x', '.claude', 'skills', 'demo', 'SKILL.md') });
|
|
49
|
+
assert.match(r.warnings.join(' '), /differs from its folder/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a long description warns; an over-cap one errors', () => {
|
|
53
|
+
assert.equal(run(fm('a'.repeat(450))).warnings.filter((w) => /chars/.test(w)).length, 1);
|
|
54
|
+
assert.equal(run(fm('a'.repeat(1100))).errors.filter((w) => /cap/.test(w)).length, 1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('no frontmatter at all is an error', () => {
|
|
58
|
+
assert.equal(run('# Title\n\nBody.\n').errors.length, 1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('the hook path filter matches only .claude/skills/<name>/SKILL.md', () => {
|
|
62
|
+
assert.ok(lint.isSkillMd('C:\\repo\\.claude\\skills\\dev\\SKILL.md'));
|
|
63
|
+
assert.ok(lint.isSkillMd('/repo/.claude/skills/dev/SKILL.md'));
|
|
64
|
+
assert.ok(!lint.isSkillMd('/repo/.claude/skills/dev/notes.md'));
|
|
65
|
+
assert.ok(!lint.isSkillMd('/repo/modules/ui-design/skills/dev/SKILL.md'));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('every committed skill parses (the ten that did not on 2026-09-03 must stay fixed)', () => {
|
|
69
|
+
const report = lint.lintSkills(lint.listSkillFiles(ROOT));
|
|
70
|
+
const broken = report.results.filter((r) => r.errors.length).map((r) => path.relative(ROOT, r.file) + ': ' + r.errors.join('; '));
|
|
71
|
+
assert.deepEqual(broken, [], broken.join('\n'));
|
|
72
|
+
assert.ok(report.results.length >= 40, 'the repo ships dozens of skills; the lister found ' + report.results.length);
|
|
73
|
+
});
|