@bongos/core 1.19.841 → 1.19.843
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 +49 -24
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_247_discipline_required.sql +58 -0
- package/modules/copy-desk/routes/copy-desk.js +6 -0
- package/modules/ideas/inbox.js +4 -2
- package/modules/lifecycle/db-tasks.js +28 -2
- package/modules/lifecycle/lifecycle.js +6 -0
- package/modules/lifecycle/routes/goals.js +17 -4
- package/modules/lifecycle/routes/task-write-routes.js +25 -6
- package/modules/lifecycle/task-classifier.js +39 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/backfill-disciplines.js +144 -0
- package/scripts/gds/fitness-ratchets.js +59 -2
- package/scripts/gds/init.js +6 -1
- package/scripts/gds/triage.js +7 -0
- package/src/module-api.js +1 -1
- package/tests/agents_roster.mjs +132 -0
- package/tests/knip_ratchet_ambient.mjs +101 -0
- package/tests/rank_floor_wiring.mjs +4 -0
- package/tests/reward_gate.mjs +4 -4
- package/tests/task_discipline_required.mjs +163 -0
- package/tests/task_goal_required.mjs +4 -4
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/backfill-disciplines.js — task 1002988: give every role-less task a craft.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS SEPARATELY FROM THE MIGRATION. core_247 narrows
|
|
5
|
+
// tasks_discipline_check to {engineer, artist, ideator, ui} and carries a
|
|
6
|
+
// last-resort `UPDATE ... SET discipline='engineer'` so a fresh install can never
|
|
7
|
+
// be blocked on a human remembering to run a script. But that net is a blunt
|
|
8
|
+
// guess. The real classification is classifyTaskDiscipline() — a JS heuristic over
|
|
9
|
+
// title + description + touches that SQL cannot express — so the good pass runs
|
|
10
|
+
// here, first, and the migration only catches what is left.
|
|
11
|
+
//
|
|
12
|
+
// Run this BEFORE deploying core_247. Order matters: rows this script resolves keep
|
|
13
|
+
// a reasoned craft; rows it does not are parked on 'engineer' by the migration.
|
|
14
|
+
//
|
|
15
|
+
// USAGE
|
|
16
|
+
// node scripts/gds/backfill-disciplines.js # dry run — the spot-check list
|
|
17
|
+
// node scripts/gds/backfill-disciplines.js --apply # commit the PATCHes
|
|
18
|
+
// node scripts/gds/backfill-disciplines.js --sample 40 # widen the printed sample
|
|
19
|
+
//
|
|
20
|
+
// The dry run is the default on purpose: the task required a spot-check list for the
|
|
21
|
+
// owner BEFORE anything was applied, and 1,189 rows is not a diff anyone reviews in
|
|
22
|
+
// their head.
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const { cliClient, requireSession } = require('./cli-lib');
|
|
27
|
+
const { resolveWriteDiscipline, WRITABLE_DISCIPLINES } = require('../../modules/lifecycle/task-classifier.js');
|
|
28
|
+
|
|
29
|
+
// Rows whose title marks them as test debris. The classifier already skips these
|
|
30
|
+
// (they are not real work), but they still carry 'unclassified' and therefore still
|
|
31
|
+
// violate the narrowed constraint — so they must be resolved, not skipped, or the
|
|
32
|
+
// migration's net silently becomes the thing that handles 164 rows.
|
|
33
|
+
const SMOKE_RE = /__smoke__/;
|
|
34
|
+
|
|
35
|
+
// The fallback for anything the heuristic refuses. Stated as a constant with its
|
|
36
|
+
// reasoning rather than inlined, because this value is a GUESS and every report
|
|
37
|
+
// below labels it as one.
|
|
38
|
+
const FALLBACK = 'engineer';
|
|
39
|
+
|
|
40
|
+
function classify(task) {
|
|
41
|
+
const resolved = resolveWriteDiscipline({
|
|
42
|
+
title: task.title, description: task.description, touches: task.touches,
|
|
43
|
+
});
|
|
44
|
+
if (resolved.discipline) {
|
|
45
|
+
return { discipline: resolved.discipline, reasoned: true, confidence: resolved.confidence ?? null };
|
|
46
|
+
}
|
|
47
|
+
return { discipline: FALLBACK, reasoned: false, confidence: null };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
51
|
+
|
|
52
|
+
// PATCH one row, WAITING OUT the per-session rate limit rather than counting it as a
|
|
53
|
+
// failure. The first run of this backfill treated a 429 as a failed row and stopped
|
|
54
|
+
// having written 120 of 1,189 — the limiter was doing its job and the script was
|
|
55
|
+
// reading "slow down" as "no". The server tells us how long to wait; honour it.
|
|
56
|
+
// Re-running is safe either way: the query only returns rows still unclassified.
|
|
57
|
+
async function patchWithBackoff(api, id, discipline, maxAttempts = 6) {
|
|
58
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
59
|
+
const res = await api.tasks.patchTasksId({ id, body: { discipline } });
|
|
60
|
+
if (res.status !== 429 || attempt >= maxAttempts) return res;
|
|
61
|
+
const retryAfter = Number(res.data?.error?.details?.retry_after_seconds);
|
|
62
|
+
const waitMs = (Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : 2 ** attempt) * 1000;
|
|
63
|
+
console.log(` rate limited — waiting ${Math.round(waitMs / 1000)}s (attempt ${attempt}/${maxAttempts - 1})`);
|
|
64
|
+
await sleep(waitMs + 500);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function main() {
|
|
69
|
+
const args = process.argv.slice(2);
|
|
70
|
+
const apply = args.includes('--apply');
|
|
71
|
+
const sampleIdx = args.indexOf('--sample');
|
|
72
|
+
const sampleSize = sampleIdx >= 0 ? Number(args[sampleIdx + 1]) || 20 : 20;
|
|
73
|
+
|
|
74
|
+
await requireSession();
|
|
75
|
+
const api = await cliClient();
|
|
76
|
+
|
|
77
|
+
const r = await api.tasks.getTasks({ query: { discipline: 'unclassified' } });
|
|
78
|
+
if (!r.ok) {
|
|
79
|
+
console.error(`failed to read tasks (${r.status}):`, r.data);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
const tasks = r.data?.tasks || [];
|
|
83
|
+
if (!tasks.length) {
|
|
84
|
+
console.log('Nothing to do — no task carries discipline=unclassified.');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const plan = tasks.map((t) => ({ task: t, ...classify(t), smoke: SMOKE_RE.test(t.title || '') }));
|
|
89
|
+
const reasoned = plan.filter((p) => p.reasoned);
|
|
90
|
+
const guessed = plan.filter((p) => !p.reasoned);
|
|
91
|
+
const byDiscipline = {};
|
|
92
|
+
for (const p of plan) byDiscipline[p.discipline] = (byDiscipline[p.discipline] || 0) + 1;
|
|
93
|
+
|
|
94
|
+
console.log(`\n${apply ? 'Applying' : 'DRY RUN —'} discipline backfill over ${plan.length} role-less task(s)\n`);
|
|
95
|
+
console.log(' Resolved by the classifier (a reasoned answer):');
|
|
96
|
+
for (const [d, n] of Object.entries(byDiscipline).sort((a, b) => b[1] - a[1])) {
|
|
97
|
+
const reasonedN = reasoned.filter((p) => p.discipline === d).length;
|
|
98
|
+
console.log(` ${d.padEnd(12)} ${String(reasonedN).padStart(5)}`);
|
|
99
|
+
}
|
|
100
|
+
console.log(`\n BEST GUESS (classifier refused → ${FALLBACK}): ${guessed.length}`);
|
|
101
|
+
console.log(` of which __smoke__ test debris: ${guessed.filter((p) => p.smoke).length}`);
|
|
102
|
+
console.log(' These are NOT determined answers. A later reader must not treat');
|
|
103
|
+
console.log(' them as evidence that anyone classified the row (owner decision 2026-09-17).\n');
|
|
104
|
+
|
|
105
|
+
console.log(` Spot-check sample — reasoned (first ${sampleSize}):`);
|
|
106
|
+
for (const p of reasoned.slice(0, sampleSize)) {
|
|
107
|
+
console.log(` ${p.discipline.padEnd(9)} ${p.confidence != null ? `(${p.confidence.toFixed(2)})` : ' '} ${String(p.task.title).slice(0, 72)}`);
|
|
108
|
+
}
|
|
109
|
+
console.log(`\n Spot-check sample — GUESSED (first ${sampleSize}):`);
|
|
110
|
+
for (const p of guessed.filter((x) => !x.smoke).slice(0, sampleSize)) {
|
|
111
|
+
console.log(` ${p.discipline.padEnd(9)} ${String(p.task.title).slice(0, 72)}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!apply) {
|
|
115
|
+
console.log(`\nDry run — nothing was written. Re-run with --apply to commit.\n`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let ok = 0;
|
|
120
|
+
const failures = [];
|
|
121
|
+
for (const p of plan) {
|
|
122
|
+
const res = await patchWithBackoff(api, p.task.id, p.discipline);
|
|
123
|
+
if (res.ok) ok += 1;
|
|
124
|
+
else failures.push({ id: p.task.id, status: res.status, detail: JSON.stringify(res.data).slice(0, 160) });
|
|
125
|
+
if (ok % 100 === 0 && res.ok) console.log(` … ${ok}/${plan.length}`);
|
|
126
|
+
}
|
|
127
|
+
console.log(`\nApplied: ${ok}/${plan.length}`);
|
|
128
|
+
if (failures.length) {
|
|
129
|
+
console.log(`Failed: ${failures.length}`);
|
|
130
|
+
for (const f of failures.slice(0, 20)) console.log(` #${f.id} → ${f.status} ${f.detail}`);
|
|
131
|
+
process.exitCode = 1;
|
|
132
|
+
}
|
|
133
|
+
// Report the residue against the same query, so the number quoted afterwards is
|
|
134
|
+
// measured rather than assumed.
|
|
135
|
+
const after = await api.tasks.getTasks({ query: { discipline: 'unclassified' } });
|
|
136
|
+
const left = after.ok ? (after.data?.tasks || []).length : null;
|
|
137
|
+
console.log(`Remaining unclassified: ${left == null ? '(re-read failed)' : left}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (require.main === module) {
|
|
141
|
+
main().catch((e) => { console.error(e && e.message ? e.message : e); process.exit(1); });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = { classify, FALLBACK, SMOKE_RE };
|
|
@@ -197,14 +197,65 @@ function docMetrics() {
|
|
|
197
197
|
|
|
198
198
|
// ---------- knip (CI-only, fail-open on infra) ----------
|
|
199
199
|
|
|
200
|
+
// WHY A LOCAL knip IS REQUIRED BEFORE SCORING (task 1003987).
|
|
201
|
+
//
|
|
202
|
+
// `npx --no-install` does not only look in THIS project: it walks up the directory
|
|
203
|
+
// tree. A git worktree has no node_modules of its own, so the lookup climbs out and
|
|
204
|
+
// finds the main checkout's copy — the command succeeds, and the number it produces is
|
|
205
|
+
// wrong in a specific, repeatable way.
|
|
206
|
+
//
|
|
207
|
+
// MEASURED, not inferred, on 2026-09-17 against one tree:
|
|
208
|
+
// main checkout (knip local) -> 221
|
|
209
|
+
// worktree (knip ambient) -> 222, the extra being `binaries: package.json knip`
|
|
210
|
+
// Diffed by name, the exports lists were IDENTICAL (221 both sides). knip was
|
|
211
|
+
// reporting ITSELF as an unlisted binary, because from the worktree's point of view it
|
|
212
|
+
// is not a declared dependency of anything resolvable.
|
|
213
|
+
//
|
|
214
|
+
// So the ratchet failed CLOSED on a phantom: 222 > baseline 221, on a tree whose real
|
|
215
|
+
// findings had not moved. That cost a full elimination pass on task 1003848, where the
|
|
216
|
+
// count was the loudest signal pointing away from the actual defect.
|
|
217
|
+
//
|
|
218
|
+
// This module's contract is fail-OPEN on infrastructure and fail-CLOSED on findings.
|
|
219
|
+
// A count measured through an ambient knip is an infrastructure artefact, not a
|
|
220
|
+
// finding, so it must not be scored at all. CI is unaffected: `npm ci` puts knip in
|
|
221
|
+
// the project's own node_modules, which is exactly what this checks for.
|
|
222
|
+
//
|
|
223
|
+
// The row also offered "compare findings by NAME" as the fuller cure. That is a
|
|
224
|
+
// baseline-format change (one integer today) and is deliberately NOT done here; naming
|
|
225
|
+
// the unresolvable case is the "at minimum" it asked for, and it removes the false
|
|
226
|
+
// failure without pretending the count is now environment-proof.
|
|
227
|
+
function localKnipPath() {
|
|
228
|
+
for (const rel of ['node_modules/knip/package.json', 'node_modules/.bin/knip']) {
|
|
229
|
+
const p = path.join(ROOT, rel);
|
|
230
|
+
try { if (fs.existsSync(p)) return p; } catch { /* unreadable → treat as absent */ }
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Why the last knip score was skipped, or null when it was scored. Module-scoped so
|
|
236
|
+
// the caller can NAME the cause instead of printing one generic line for every
|
|
237
|
+
// unrelated reason knip might not run.
|
|
238
|
+
let knipSkipReason = null;
|
|
239
|
+
function lastKnipSkipReason() { return knipSkipReason; }
|
|
240
|
+
|
|
200
241
|
function knipIssueCount() {
|
|
242
|
+
knipSkipReason = null;
|
|
243
|
+
if (!localKnipPath()) {
|
|
244
|
+
// Not "knip is missing" — an ambient one may well run. The point is that its
|
|
245
|
+
// answer is not comparable to the baseline, which was measured with a local one.
|
|
246
|
+
knipSkipReason = `no knip in ${path.join(ROOT, 'node_modules')} — `
|
|
247
|
+
+ 'npx would resolve one from outside this tree and report it as an unlisted '
|
|
248
|
+
+ 'binary, scoring one higher than the same code scores in CI (task 1003987)';
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
201
251
|
let raw;
|
|
202
252
|
try {
|
|
203
253
|
raw = execSync('npx --no-install knip --reporter json --no-exit-code', {
|
|
204
254
|
cwd: ROOT, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'],
|
|
205
255
|
});
|
|
206
256
|
} catch (e) {
|
|
207
|
-
if (e && e.stdout) raw = e.stdout.toString();
|
|
257
|
+
if (e && e.stdout) { raw = e.stdout.toString(); }
|
|
258
|
+
else { knipSkipReason = `npx knip failed to run in ${ROOT}`; return null; }
|
|
208
259
|
}
|
|
209
260
|
try {
|
|
210
261
|
const j = JSON.parse(raw);
|
|
@@ -219,6 +270,7 @@ function knipIssueCount() {
|
|
|
219
270
|
}
|
|
220
271
|
return files + items;
|
|
221
272
|
} catch {
|
|
273
|
+
knipSkipReason = 'knip produced output this script could not parse as JSON';
|
|
222
274
|
return null;
|
|
223
275
|
}
|
|
224
276
|
}
|
|
@@ -426,7 +478,10 @@ function main() {
|
|
|
426
478
|
if (knipOnly) {
|
|
427
479
|
const value = knipIssueCount();
|
|
428
480
|
if (value === null) {
|
|
429
|
-
|
|
481
|
+
// Name WHY (task 1003987). "not runnable here" sent readers looking for a
|
|
482
|
+
// broken install when the real answer was that the number would have been
|
|
483
|
+
// wrong — a different problem with a different fix.
|
|
484
|
+
console.warn(`fitness-ratchets: WARN — knip not scored: ${lastKnipSkipReason() || 'knip unavailable'}; failing OPEN.`);
|
|
430
485
|
process.exit(0);
|
|
431
486
|
}
|
|
432
487
|
const { metrics: knipBaselines, error: knipBaselineError } = readBaselines();
|
|
@@ -466,6 +521,8 @@ module.exports = {
|
|
|
466
521
|
consoleCallCount,
|
|
467
522
|
isServerPath,
|
|
468
523
|
knipIssueCount,
|
|
524
|
+
localKnipPath,
|
|
525
|
+
lastKnipSkipReason,
|
|
469
526
|
OVERSIZE_LINES,
|
|
470
527
|
WINDOW,
|
|
471
528
|
BASELINE_PATH,
|
package/scripts/gds/init.js
CHANGED
|
@@ -763,7 +763,12 @@ async function seedViaApi(spec, { dryRun, log = console.log, api = apiCall } = {
|
|
|
763
763
|
// 2. starter task(s) — linked to the first goal so /status rolls them up
|
|
764
764
|
let created = 0;
|
|
765
765
|
for (const t of starters) {
|
|
766
|
-
|
|
766
|
+
// task 1002988: `discipline: undefined` used to sit in this extras object, and
|
|
767
|
+
// because `...extra` spreads AFTER postTask's own `t.discipline || 'engineer'`
|
|
768
|
+
// it clobbered that default back to nothing — which is why a freshly scaffolded
|
|
769
|
+
// instance was born with role-less starter tasks. Dropped, so starters inherit
|
|
770
|
+
// the same default every other seeded task already got.
|
|
771
|
+
if (await postTask(t, { kind: t.kind || 'unclassified', criterion_ids: [criterionId] })) created++;
|
|
767
772
|
}
|
|
768
773
|
// 3. kickoff steps as tracked tasks
|
|
769
774
|
for (const t of kickoff) {
|
package/scripts/gds/triage.js
CHANGED
|
@@ -59,6 +59,7 @@ const readline = require('node:readline');
|
|
|
59
59
|
const fs = require('node:fs');
|
|
60
60
|
const { cliClient, requireSession } = require('./cli-lib');
|
|
61
61
|
const { resolveDefaultVersion } = require('./version-select.js');
|
|
62
|
+
const { resolveWriteDiscipline } = require('../../modules/lifecycle/task-classifier.js');
|
|
62
63
|
|
|
63
64
|
function prompt(rl, question) {
|
|
64
65
|
return new Promise((resolve) => rl.question(question, (a) => resolve(a)));
|
|
@@ -276,6 +277,12 @@ async function createTask({ versionId, title, description, priority, sourceRef,
|
|
|
276
277
|
priority: priority ?? null,
|
|
277
278
|
source: 'idea_inbox',
|
|
278
279
|
source_ref: sourceRef ?? null,
|
|
280
|
+
// task 1002988: the server now refuses a task whose craft it cannot infer, and
|
|
281
|
+
// a 400 in the middle of an interactive triage loop would cost the operator the
|
|
282
|
+
// promotion they just decided on. So resolve it here with the SAME function the
|
|
283
|
+
// server uses, and name a fallback rather than letting the loop break — the
|
|
284
|
+
// identical trade modules/ideas/inbox.js makes for capture.
|
|
285
|
+
discipline: resolveWriteDiscipline({ title, description }).discipline || 'engineer',
|
|
279
286
|
};
|
|
280
287
|
if (goalId == null) {
|
|
281
288
|
throw new Error('createTask: goal_id is required — every task belongs to a goal (task 1003598 deleted the catch-all fallback).');
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.843'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// tests/agents_roster.mjs — the SHIPPED roster in `.claude/agents/` (task
|
|
2
|
+
// 1002496, goal 1000095).
|
|
3
|
+
//
|
|
4
|
+
// The definitions are prose files, so nothing about them is checked by the type
|
|
5
|
+
// system, and the sync that imports them is fail-open at deploy (a bad file makes
|
|
6
|
+
// the registry smaller, never aborts the deploy — tests/agents_sync.mjs pins
|
|
7
|
+
// that). The consequence is that a malformed definition is INVISIBLE until
|
|
8
|
+
// someone goes looking in the registry for an agent that quietly never imported.
|
|
9
|
+
// This file is the guard: every roster file must survive the REAL parser and the
|
|
10
|
+
// REAL validator, not a hand-built object that resembles one.
|
|
11
|
+
//
|
|
12
|
+
// Tested at the round-trip altitude on purpose. A test over parseAgentFile alone
|
|
13
|
+
// passes on a key the validator later drops, which is exactly the defect task
|
|
14
|
+
// 1004007 records: a top-level `touches:` validates ok and is silently normalized
|
|
15
|
+
// away. Parse and validate together, or the test agrees with the bug.
|
|
16
|
+
//
|
|
17
|
+
// In tests/ rather than modules/agents/tests/ for the same reason as
|
|
18
|
+
// tests/agents_sync.mjs: the module is default:false and module-local tests are
|
|
19
|
+
// discovered only when the module is ENABLED, so they would never run here.
|
|
20
|
+
//
|
|
21
|
+
// Run: node tests/agents_roster.mjs
|
|
22
|
+
|
|
23
|
+
import { strict as assert } from 'node:assert';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
const sync = require('../scripts/gds/agents-sync.js');
|
|
30
|
+
const validate = require('../modules/agents/lib/validate.js');
|
|
31
|
+
|
|
32
|
+
const validateDefinition = validate.validateAgentDefinition || validate;
|
|
33
|
+
|
|
34
|
+
let passed = 0;
|
|
35
|
+
let failed = 0;
|
|
36
|
+
function t(name, fn) {
|
|
37
|
+
try { fn(); console.log(` PASS ${name}`); passed += 1; }
|
|
38
|
+
catch (err) { console.log(` FAIL ${name}\n ${err.message}`); failed += 1; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const ROSTER_DIR = new URL('../.claude/agents/', import.meta.url);
|
|
42
|
+
const files = fs.readdirSync(ROSTER_DIR)
|
|
43
|
+
.filter((f) => f.endsWith('.md'))
|
|
44
|
+
.sort();
|
|
45
|
+
|
|
46
|
+
// Parse once, reuse across the cases below. The name comes from the FILENAME,
|
|
47
|
+
// which is how agents-sync identifies a file-sourced definition.
|
|
48
|
+
const roster = files.map((f) => {
|
|
49
|
+
const name = f.replace(/\.md$/, '');
|
|
50
|
+
const text = fs.readFileSync(new URL(f, ROSTER_DIR), 'utf8');
|
|
51
|
+
return { name, file: f, candidate: sync.parseAgentFile(text, { name }) };
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
console.log('every shipped definition survives parse + validate:');
|
|
55
|
+
|
|
56
|
+
// An empty roster would pass every per-file assertion below by vacuity — the
|
|
57
|
+
// failure mode where a green suite means "nothing ran". Pin the floor.
|
|
58
|
+
t('the roster is not empty', () => {
|
|
59
|
+
assert.ok(roster.length > 0, '.claude/agents/ holds no definitions — a vacuous pass');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
for (const { name, file, candidate } of roster) {
|
|
63
|
+
t(`${file} validates against the real validator`, () => {
|
|
64
|
+
// authorRank archon so a scoped definition is judged on its shape rather
|
|
65
|
+
// than refused for rank; allowedModules mirrors what the sync passes.
|
|
66
|
+
const verdict = validateDefinition(candidate, {
|
|
67
|
+
authorRank: 'archon',
|
|
68
|
+
allowedModules: ['agents'],
|
|
69
|
+
protectedModules: [],
|
|
70
|
+
});
|
|
71
|
+
assert.ok(
|
|
72
|
+
verdict.ok,
|
|
73
|
+
`${file} would not import: ${JSON.stringify(verdict.errors)}`,
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
t(`${file} carries a persona worth sending`, () => {
|
|
78
|
+
// The BODY is the persona, and it is sent to a paid model on every fire. A
|
|
79
|
+
// file whose frontmatter parsed but whose body did not is the quiet failure
|
|
80
|
+
// this catches — it validates as a string and says nothing.
|
|
81
|
+
const persona = String(candidate.persona || '');
|
|
82
|
+
assert.ok(persona.length > 200, `${file}: persona is ${persona.length} chars — too thin to be a role prompt`);
|
|
83
|
+
assert.match(persona, new RegExp(name.replace(/-/g, '[- ]')), `${file}: the persona never names the agent it defines`);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
console.log('\nthe on-demand roster this task shipped:');
|
|
88
|
+
|
|
89
|
+
t('historian, planner and onboarding-buddy are all present', () => {
|
|
90
|
+
const names = roster.map((r) => r.name);
|
|
91
|
+
for (const want of ['historian', 'planner', 'onboarding-buddy']) {
|
|
92
|
+
assert.ok(names.includes(want), `${want} is missing from the roster`);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
t('each of the three is on-demand', () => {
|
|
97
|
+
for (const want of ['historian', 'planner', 'onboarding-buddy']) {
|
|
98
|
+
const entry = roster.find((r) => r.name === want);
|
|
99
|
+
assert.equal(entry.candidate.trigger_type, 'on-demand',
|
|
100
|
+
`${want} must be on-demand — an event trigger here would fire on ships nobody asked it about`);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
console.log('\nthe trap an event definition would walk into:');
|
|
105
|
+
|
|
106
|
+
// TRIPWIRE for task 1004007, and the reason it is HERE rather than in that task:
|
|
107
|
+
// this directory is where the mistake gets made. A file cannot express
|
|
108
|
+
// `trigger_spec.touches` today (agents-sync.js parses a flat frontmatter and
|
|
109
|
+
// folds only `event:` into the spec), and event-router.js:168 reads an absent
|
|
110
|
+
// filter as FIRES ON EVERY SHIP at roughly $0.90 a fire. So the first
|
|
111
|
+
// file-authored event agent is a standing bill, silently.
|
|
112
|
+
//
|
|
113
|
+
// This reds the moment someone adds one, which is the moment the warning is
|
|
114
|
+
// worth something. Once 1004007 lands, tighten it to assert the filter SURVIVED
|
|
115
|
+
// into trigger_spec rather than refusing event definitions outright.
|
|
116
|
+
t('no file-authored event agent until a file can declare its filter', () => {
|
|
117
|
+
const eventBacked = roster.filter((r) => r.candidate.trigger_type === 'event');
|
|
118
|
+
for (const entry of eventBacked) {
|
|
119
|
+
const spec = entry.candidate.trigger_spec;
|
|
120
|
+
const globs = spec && Array.isArray(spec.touches) ? spec.touches
|
|
121
|
+
: spec && Array.isArray(spec.touches_globs) ? spec.touches_globs
|
|
122
|
+
: [];
|
|
123
|
+
assert.ok(
|
|
124
|
+
globs.length > 0,
|
|
125
|
+
`${entry.file} is an event definition with no touches filter, so it would fire on EVERY ship (~$0.90 each). `
|
|
126
|
+
+ 'A file cannot declare one until task 1004007 lands — see tests/agents_roster.mjs.',
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
console.log(`\nagents_roster: ${passed} passed, ${failed} failed`);
|
|
132
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// tests/knip_ratchet_ambient.mjs — the knip ratchet refuses to score through an
|
|
2
|
+
// ambient knip, instead of failing CLOSED on a number it cannot compare (task 1003987).
|
|
3
|
+
//
|
|
4
|
+
// THE DEFECT. `npx --no-install` walks UP the directory tree. A git worktree has no
|
|
5
|
+
// node_modules of its own, so the lookup climbs out to the main checkout and succeeds —
|
|
6
|
+
// and knip, unable to resolve itself as a declared dependency of the tree it is
|
|
7
|
+
// scanning, reports ITSELF as an unlisted binary.
|
|
8
|
+
//
|
|
9
|
+
// MEASURED on one tree, 2026-09-17:
|
|
10
|
+
// main checkout (knip local) -> 221, PASS
|
|
11
|
+
// worktree (knip ambient) -> 222, FAIL against baseline 221
|
|
12
|
+
// The extra was `binaries: package.json knip`. Diffed BY NAME the exports lists were
|
|
13
|
+
// identical — 221 both sides. Nothing about the code had moved.
|
|
14
|
+
//
|
|
15
|
+
// That false red cost a full elimination pass on task 1003848: the count was the
|
|
16
|
+
// loudest signal on the board and it pointed away from the real defect.
|
|
17
|
+
//
|
|
18
|
+
// The module's contract is fail-OPEN on infrastructure, fail-CLOSED on findings. A
|
|
19
|
+
// count taken through an ambient knip is an infrastructure artefact, so it must not be
|
|
20
|
+
// scored. CI is unaffected — `npm ci` puts knip in the project's own node_modules.
|
|
21
|
+
//
|
|
22
|
+
// Run: node --test tests/knip_ratchet_ambient.mjs
|
|
23
|
+
|
|
24
|
+
import { test } from 'node:test';
|
|
25
|
+
import assert from 'node:assert/strict';
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { readFileSync } from 'node:fs';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
import { createRequire } from 'node:module';
|
|
32
|
+
|
|
33
|
+
const require = createRequire(import.meta.url);
|
|
34
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
35
|
+
const ratchets = require('../scripts/gds/fitness-ratchets.js');
|
|
36
|
+
const SRC = readFileSync(path.join(REPO_ROOT, 'scripts/gds/fitness-ratchets.js'), 'utf8');
|
|
37
|
+
|
|
38
|
+
test('the local-knip check runs BEFORE npx, so an ambient knip is never scored', () => {
|
|
39
|
+
const fn = SRC.slice(SRC.indexOf('function knipIssueCount()'));
|
|
40
|
+
const body = fn.slice(0, fn.indexOf('\n}\n'));
|
|
41
|
+
const guard = body.indexOf('localKnipPath()');
|
|
42
|
+
const spawn = body.indexOf('execSync(');
|
|
43
|
+
assert.ok(guard > 0 && spawn > 0, 'both the guard and the spawn must be present');
|
|
44
|
+
assert.ok(guard < spawn,
|
|
45
|
+
'checking after spawning would score the ambient count first — the whole bug');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('it returns null (skip) rather than a number when knip is not local', () => {
|
|
49
|
+
// The fail direction matters: a number here is compared against a baseline measured
|
|
50
|
+
// with a LOCAL knip, so returning one is worse than returning nothing.
|
|
51
|
+
const fn = SRC.slice(SRC.indexOf('function knipIssueCount()'));
|
|
52
|
+
const body = fn.slice(0, fn.indexOf('\n}\n'));
|
|
53
|
+
assert.match(body, /if \(!localKnipPath\(\)\)[\s\S]{0,600}return null;/,
|
|
54
|
+
'the unresolvable case must skip, not score');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('the skip NAMES its cause — a generic line sent readers hunting a broken install', () => {
|
|
58
|
+
assert.equal(typeof ratchets.lastKnipSkipReason, 'function');
|
|
59
|
+
ratchets.knipIssueCount();
|
|
60
|
+
const reason = ratchets.lastKnipSkipReason();
|
|
61
|
+
// In this repo the test runs where knip IS local (CI, main checkout), so the reason
|
|
62
|
+
// is null; where it is NOT local the reason must be specific. Assert whichever case
|
|
63
|
+
// this machine is in, so the test is meaningful on both.
|
|
64
|
+
if (ratchets.localKnipPath()) {
|
|
65
|
+
assert.equal(reason, null, 'a scored run must not claim it was skipped');
|
|
66
|
+
} else {
|
|
67
|
+
assert.ok(reason && /knip/i.test(reason), 'the skip must say what was missing');
|
|
68
|
+
assert.match(reason, /node_modules/, 'and where it looked');
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('localKnipPath finds a knip that IS local, and reports none when it is not', () => {
|
|
73
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'knipprobe-'));
|
|
74
|
+
try {
|
|
75
|
+
// The predicate is about THIS repo's root, so prove it against the real answer
|
|
76
|
+
// rather than a reimplementation: it must agree with the filesystem.
|
|
77
|
+
const expected = ['node_modules/knip/package.json', 'node_modules/.bin/knip']
|
|
78
|
+
.some((rel) => fs.existsSync(path.join(REPO_ROOT, rel)));
|
|
79
|
+
assert.equal(!!ratchets.localKnipPath(), expected,
|
|
80
|
+
'localKnipPath must agree with what is actually on disk');
|
|
81
|
+
} finally {
|
|
82
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('the caller fails OPEN on a skip — an unscorable metric is not a violation', () => {
|
|
87
|
+
assert.match(SRC, /knip not scored: \$\{lastKnipSkipReason\(\)/,
|
|
88
|
+
'the warning must carry the specific reason');
|
|
89
|
+
const warnIdx = SRC.indexOf('knip not scored:');
|
|
90
|
+
const after = SRC.slice(warnIdx, warnIdx + 300);
|
|
91
|
+
assert.match(after, /process\.exit\(0\)/,
|
|
92
|
+
'a skip must exit 0 — failing closed here is what this task removed');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('a REAL regression still fails closed — the fix narrows nothing', () => {
|
|
96
|
+
// Guard against "fixed" by making the whole metric advisory.
|
|
97
|
+
const knipOnly = SRC.slice(SRC.indexOf('if (knipOnly)'));
|
|
98
|
+
const block = knipOnly.slice(0, knipOnly.indexOf('return;'));
|
|
99
|
+
assert.match(block, /violations\.length/, 'violations must still be evaluated');
|
|
100
|
+
assert.match(block, /process\.exit\(1\)/, 'and still fail the gate when present');
|
|
101
|
+
});
|
|
@@ -52,6 +52,10 @@ async function createdFloor({ scopeModules, needsMigration = false, requiresRank
|
|
|
52
52
|
await createTask({
|
|
53
53
|
versionId: 'BONGOS-V2',
|
|
54
54
|
title: 'a task',
|
|
55
|
+
// Stated rather than inferred (task 1002988): createTask now refuses a craft it
|
|
56
|
+
// cannot work out, and "a task" says nothing. This fixture is about the RANK
|
|
57
|
+
// FLOOR, so it should not be at the mercy of the discipline heuristic.
|
|
58
|
+
discipline: 'engineer',
|
|
55
59
|
goalId: '1000111',
|
|
56
60
|
needsMigration,
|
|
57
61
|
requiresRank,
|
package/tests/reward_gate.mjs
CHANGED
|
@@ -210,7 +210,7 @@ async function withStubbedCreatePool(run) {
|
|
|
210
210
|
|
|
211
211
|
t('a task CREATED with NO reward is born with a positive, capped value', async () => {
|
|
212
212
|
await withStubbedCreatePool(async (captured) => {
|
|
213
|
-
const row = await db.createTask({ versionId: 1, goalId: 77, title: 't', kind: 'feature', estMinutes: 40 });
|
|
213
|
+
const row = await db.createTask({ versionId: 1, goalId: 77, title: 't', discipline: 'engineer', kind: 'feature', estMinutes: 40 });
|
|
214
214
|
assert.ok(captured.creditsReward > 0, 'INSERT must bind a positive credits_reward');
|
|
215
215
|
assert.equal(captured.creditsReward, 40, 'feature @40min -> 40 drachmae');
|
|
216
216
|
assert.ok(captured.creditsReward <= db.REWARD_SUGGESTION_CAP, 'must respect the cap');
|
|
@@ -220,7 +220,7 @@ t('a task CREATED with NO reward is born with a positive, capped value', async (
|
|
|
220
220
|
|
|
221
221
|
t('a task CREATED with credits_reward 0 (the route default) is auto-assigned', async () => {
|
|
222
222
|
await withStubbedCreatePool(async (captured) => {
|
|
223
|
-
await db.createTask({ versionId: 1, goalId: 77, title: 't', creditsReward: 0, kind: 'bug', estMinutes: 40 });
|
|
223
|
+
await db.createTask({ versionId: 1, goalId: 77, title: 't', discipline: 'engineer', creditsReward: 0, kind: 'bug', estMinutes: 40 });
|
|
224
224
|
// bug weight 1.25 -> 50; 0 is treated as "unset" so the default kicks in.
|
|
225
225
|
assert.equal(captured.creditsReward, 50);
|
|
226
226
|
});
|
|
@@ -228,14 +228,14 @@ t('a task CREATED with credits_reward 0 (the route default) is auto-assigned', a
|
|
|
228
228
|
|
|
229
229
|
t('a huge-est new task is CAPPED at creation, not ballooned', async () => {
|
|
230
230
|
await withStubbedCreatePool(async (captured) => {
|
|
231
|
-
await db.createTask({ versionId: 1, goalId: 77, title: 't', kind: 'feature', estMinutes: 9999 });
|
|
231
|
+
await db.createTask({ versionId: 1, goalId: 77, title: 't', discipline: 'engineer', kind: 'feature', estMinutes: 9999 });
|
|
232
232
|
assert.equal(captured.creditsReward, db.REWARD_SUGGESTION_CAP);
|
|
233
233
|
});
|
|
234
234
|
});
|
|
235
235
|
|
|
236
236
|
t('an EXPLICIT positive credits_reward is PRESERVED (never overridden)', async () => {
|
|
237
237
|
await withStubbedCreatePool(async (captured) => {
|
|
238
|
-
await db.createTask({ versionId: 1, goalId: 77, title: 't', creditsReward: 17, kind: 'feature', estMinutes: 40 });
|
|
238
|
+
await db.createTask({ versionId: 1, goalId: 77, title: 't', discipline: 'engineer', creditsReward: 17, kind: 'feature', estMinutes: 40 });
|
|
239
239
|
assert.equal(captured.creditsReward, 17, 'author-set positive value must survive untouched');
|
|
240
240
|
});
|
|
241
241
|
});
|