@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.
- package/.bongos-core.json +81 -51
- 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/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/grading/grader-subagent.js +20 -1
- package/modules/grading/grader.js +24 -1
- package/modules/lifecycle/cascade.js +86 -4
- package/modules/lifecycle/tests/cascade.mjs +63 -6
- 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/grader_root_outage.mjs +109 -0
- package/tests/hooks_drain.mjs +8 -2
- package/tests/repo_map.mjs +1 -1
- 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
|
-
|
|
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)
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
|
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)
|
|
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
|
-
|
|
183
|
+
return;
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
|
|
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
|
|
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
|
|
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.
|
|
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 = '
|
|
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
|
+
});
|
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`);
|