@bongos/core 1.19.842 → 1.19.844
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 +70 -40
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_247_discipline_required.sql +58 -0
- package/modules/agents/lib/spend-ceiling.js +126 -37
- package/modules/agents/migrations/agents_004_spend_cap.sql +53 -0
- package/modules/agents/pollers/event-router.js +29 -2
- package/modules/agents/routes/agents.js +38 -1
- package/modules/agents/spawn.js +73 -1
- package/modules/copy-desk/routes/copy-desk.js +6 -0
- package/modules/economy/cost-classify.js +11 -0
- package/modules/economy/cost.js +21 -3
- package/modules/economy/reward.js +7 -0
- package/modules/hall-ui/public/goals.states.json +2 -2
- package/modules/hall-ui/public/idea.states.json +1 -1
- package/modules/hall-ui/public/ideas.states.json +1 -1
- package/modules/hall-ui/public/index.states.json +1 -1
- package/modules/hall-ui/public/pair.states.json +1 -1
- package/modules/hall-ui/public/settings.states.json +1 -1
- package/modules/hall-ui/public/task.states.json +1 -1
- package/modules/hall-ui/public/work.states.json +1 -1
- 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_spend_guard.mjs +203 -2
- package/tests/hall_states_regex_escapes.mjs +98 -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
|
@@ -163,6 +163,42 @@ function classifyTaskKind(title, description) {
|
|
|
163
163
|
// /design work-mode (discipline-modes.json) instead of /paint.
|
|
164
164
|
const VALID_DISCIPLINES = new Set(['engineer', 'artist', 'ideator', 'ui', 'unclassified']);
|
|
165
165
|
|
|
166
|
+
// The disciplines a WRITE may set (task 1002988). 'unclassified' is deliberately
|
|
167
|
+
// absent: it is a triage state, not a craft, and a create that lands one produces
|
|
168
|
+
// a row no craft is accountable for — which is how 1,189 of them accumulated.
|
|
169
|
+
// VALID_DISCIPLINES above stays the READ-side set so existing rows and the
|
|
170
|
+
// ?discipline=unclassified filter keep resolving while the backfill runs.
|
|
171
|
+
// 'ui' stays: ADR 0272 keeps it as a module-contributed discipline, and the owner
|
|
172
|
+
// reconfirmed that on 2026-09-17 when abandoning task 1002987.
|
|
173
|
+
const WRITABLE_DISCIPLINES = new Set(['engineer', 'artist', 'ideator', 'ui']);
|
|
174
|
+
|
|
175
|
+
// Mirrors the `kind` branch in the create routes: act on the classifier only when
|
|
176
|
+
// it is at least half sure, otherwise treat it as no answer at all.
|
|
177
|
+
const DISCIPLINE_CONFIDENCE_FLOOR = 0.5;
|
|
178
|
+
|
|
179
|
+
// Resolve the discipline for a task WRITE. Honours an explicit valid value,
|
|
180
|
+
// otherwise INFERS from title + description + touches, and reports failure so the
|
|
181
|
+
// caller can 400 — per the owner decision of 2026-09-17, a bare 400 on a missing
|
|
182
|
+
// discipline was rejected because four live paths (idea capture, idea promotion,
|
|
183
|
+
// adopt-import and the new-instance scaffold) create tasks without one.
|
|
184
|
+
//
|
|
185
|
+
// Shared deliberately: POST /tasks and POST /goals/:id/tasks BOTH carried the
|
|
186
|
+
// `body.discipline ?? 'unclassified'` line, so a fix applied to one of them would
|
|
187
|
+
// have left the other quietly minting the very rows this task exists to remove.
|
|
188
|
+
// Returns { discipline, inferred?, confidence? } or { error, unclassifiable? }.
|
|
189
|
+
function resolveWriteDiscipline({ discipline, title, description, touches } = {}) {
|
|
190
|
+
if (discipline != null && discipline !== 'unclassified') {
|
|
191
|
+
return WRITABLE_DISCIPLINES.has(discipline) ? { discipline } : { error: 'bad_discipline' };
|
|
192
|
+
}
|
|
193
|
+
// Absent, null, or an explicit 'unclassified' (what modules/ideas/inbox.js sends
|
|
194
|
+
// on promotion) all mean the same thing: nobody told us, so work it out.
|
|
195
|
+
const guess = classifyTaskDiscipline(title, description || '', touches);
|
|
196
|
+
if (guess.confidence >= DISCIPLINE_CONFIDENCE_FLOOR && WRITABLE_DISCIPLINES.has(guess.discipline)) {
|
|
197
|
+
return { discipline: guess.discipline, inferred: true, confidence: guess.confidence };
|
|
198
|
+
}
|
|
199
|
+
return { error: 'bad_discipline', unclassifiable: true };
|
|
200
|
+
}
|
|
201
|
+
|
|
166
202
|
// Numeric field bounds for task creation (POST /tasks). The single source of
|
|
167
203
|
// truth: the route schema in routes/tasks.js references these so the validator
|
|
168
204
|
// and the claim-time context pack (V4.R04 / sessions-start-knowing) can never
|
|
@@ -516,6 +552,9 @@ module.exports = {
|
|
|
516
552
|
_resetVendorCacheForTests,
|
|
517
553
|
VALID_KINDS,
|
|
518
554
|
VALID_DISCIPLINES,
|
|
555
|
+
WRITABLE_DISCIPLINES,
|
|
556
|
+
DISCIPLINE_CONFIDENCE_FLOOR,
|
|
557
|
+
resolveWriteDiscipline,
|
|
519
558
|
VOTABLE_TASK_STATUSES,
|
|
520
559
|
TASK_FIELD_BOUNDS,
|
|
521
560
|
};
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.844",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.844",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.844",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -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.844'; // 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');
|
|
@@ -25,8 +25,11 @@ import { validateManifest } from '../src/module-loader/manifest-schema.js';
|
|
|
25
25
|
import ceilingMod from '../modules/agents/lib/spend-ceiling.js';
|
|
26
26
|
import spawnMod from '../modules/agents/spawn.js';
|
|
27
27
|
|
|
28
|
-
const {
|
|
29
|
-
|
|
28
|
+
const {
|
|
29
|
+
createSpendCeiling, payerFor, monthStartUtc, agentCapFor,
|
|
30
|
+
DEFAULT_CAP_USD, DEFAULT_AGENT_CAP_USD,
|
|
31
|
+
} = ceilingMod;
|
|
32
|
+
const { plannedRow, mirrorCostToLedger, costSourceTag } = spawnMod;
|
|
30
33
|
|
|
31
34
|
// A registry with one metered module and one ordinary one, so every assertion can
|
|
32
35
|
// show that the guard bites the declaring module and NOTHING else.
|
|
@@ -270,3 +273,201 @@ test('the ceiling RECORDS NOTHING — the ledger is the only tally', async () =>
|
|
|
270
273
|
assert.doesNotMatch(sql, /INSERT|UPDATE|DELETE/i);
|
|
271
274
|
}
|
|
272
275
|
});
|
|
276
|
+
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
// 4. the per-AGENT monthly ceiling (task 1002499)
|
|
279
|
+
//
|
|
280
|
+
// Its own section, and its own tests against the same hostile pools section 3
|
|
281
|
+
// uses, DELIBERATELY. checkAgent and check now share sumOrNull, and a shared
|
|
282
|
+
// helper's documented safety property is a claim until each caller is shown to
|
|
283
|
+
// honour it — a refactor that made checkAgent swallow a null would pass every
|
|
284
|
+
// test written only against check.
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
const HISTORIAN = { name: 'historian', spend_cap_usd: null };
|
|
288
|
+
|
|
289
|
+
test('a per-agent cap is SMALLER than the payer cap — it bounds a definition, not a wallet', () => {
|
|
290
|
+
assert.ok(DEFAULT_AGENT_CAP_USD < DEFAULT_CAP_USD,
|
|
291
|
+
'if the per-agent default ever reaches the payer default it stops bounding anything: '
|
|
292
|
+
+ 'one agent could spend the whole instance ceiling, which is the case this exists for');
|
|
293
|
+
assert.equal(DEFAULT_AGENT_CAP_USD, 5, 'the owner decision of 2026-07-05, recorded on task 1002499');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('under its cap: allowed, with the arithmetic shown', async () => {
|
|
297
|
+
const c = createSpendCeiling({ agentCapUsd: 5, now: () => AT });
|
|
298
|
+
const r = await c.checkAgent(poolOf('1.8000'), HISTORIAN);
|
|
299
|
+
assert.equal(r.ok, true);
|
|
300
|
+
assert.equal(r.spentUsd, 1.8);
|
|
301
|
+
assert.equal(r.capUsd, 5);
|
|
302
|
+
assert.equal(r.remainingUsd, 3.2);
|
|
303
|
+
assert.equal(r.reason, null);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('at or past its cap: refused with its OWN code, never the payer one', async () => {
|
|
307
|
+
const c = createSpendCeiling({ agentCapUsd: 5, now: () => AT });
|
|
308
|
+
for (const spent of ['5', '5.0001', '90']) {
|
|
309
|
+
const r = await c.checkAgent(poolOf(spent), HISTORIAN);
|
|
310
|
+
assert.equal(r.ok, false, spent);
|
|
311
|
+
assert.equal(r.reason, 'agent_spend_cap_reached', spent);
|
|
312
|
+
assert.notEqual(r.reason, 'spend_cap_reached',
|
|
313
|
+
'the two refusals must stay distinguishable: one says raise the instance cap, '
|
|
314
|
+
+ 'the other says raise THIS agent — and under a project payer the wrong advice '
|
|
315
|
+
+ 'raises a number that was never the constraint');
|
|
316
|
+
assert.equal(r.remainingUsd, 0, spent);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('a definition own cap beats the instance default, in BOTH directions', async () => {
|
|
321
|
+
const c = createSpendCeiling({ agentCapUsd: 5, now: () => AT });
|
|
322
|
+
const generous = await c.checkAgent(poolOf('7'), { name: 'a', spend_cap_usd: '20.0000' });
|
|
323
|
+
assert.equal(generous.ok, true, '$7 spent against its own $20 cap is fine, though it is past the default');
|
|
324
|
+
assert.equal(generous.capUsd, 20);
|
|
325
|
+
const stingy = await c.checkAgent(poolOf('2'), { name: 'b', spend_cap_usd: '1.5000' });
|
|
326
|
+
assert.equal(stingy.ok, false, '$2 spent against its own $1.50 cap is over, though it is under the default');
|
|
327
|
+
assert.equal(stingy.reason, 'agent_spend_cap_reached');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('agentCapFor: only a positive number overrides; anything else takes the default', () => {
|
|
331
|
+
assert.equal(agentCapFor({ spend_cap_usd: '12.5' }, 5), 12.5, 'pg NUMERIC arrives as a string');
|
|
332
|
+
assert.equal(agentCapFor({ spend_cap_usd: 12.5 }, 5), 12.5);
|
|
333
|
+
assert.equal(agentCapFor({ spend_cap_usd: null }, 5), 5, 'NULL means use the default, never uncapped');
|
|
334
|
+
assert.equal(agentCapFor({}, 5), 5, 'a projection that dropped the column must not read as uncapped');
|
|
335
|
+
for (const bad of [0, -1, 'nonsense', NaN, Infinity]) {
|
|
336
|
+
assert.equal(agentCapFor({ spend_cap_usd: bad }, 5), 5,
|
|
337
|
+
String(bad) + ' is not a cap this code wrote (agents_004 CHECK forbids it), so it takes the default');
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test('AN UNREADABLE LEDGER REFUSES HERE TOO — checkAgent does not inherit the fix, it must honour it', async () => {
|
|
342
|
+
const c = createSpendCeiling({ agentCapUsd: 5, now: () => AT });
|
|
343
|
+
const throwing = { query: async () => { throw new Error('connection terminated'); } };
|
|
344
|
+
assert.equal((await c.checkAgent(throwing, HISTORIAN)).reason, 'ledger_unreadable');
|
|
345
|
+
// The shapes that would coerce to a permissive 0. Each is asserted against
|
|
346
|
+
// checkAgent specifically, because the whole hazard is a sum that LOOKS fine.
|
|
347
|
+
for (const rows of [[{ spent: null }], [{ spent: undefined }], [{}], [], [{ spent: 'nonsense' }], [{ spent: '-1' }]]) {
|
|
348
|
+
const r = await c.checkAgent({ query: async () => ({ rows }) }, HISTORIAN);
|
|
349
|
+
assert.equal(r.ok, false, JSON.stringify(rows));
|
|
350
|
+
assert.equal(r.reason, 'ledger_unreadable', JSON.stringify(rows));
|
|
351
|
+
assert.equal(r.spentUsd, null, 'a refusal that could not read must not claim a sum');
|
|
352
|
+
}
|
|
353
|
+
assert.equal((await c.checkAgent(null, HISTORIAN)).reason, 'ledger_unreadable', 'no pool at all');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test('no usable cap and no agent identity both REFUSE — an unbounded agent is the defect wearing a cap name', async () => {
|
|
357
|
+
for (const cap of [0, -5, NaN, 'nonsense']) {
|
|
358
|
+
const c = createSpendCeiling({ agentCapUsd: cap, now: () => AT });
|
|
359
|
+
const r = await c.checkAgent(poolOf('0'), { name: 'x', spend_cap_usd: null });
|
|
360
|
+
assert.equal(r.ok, false, String(cap));
|
|
361
|
+
assert.equal(r.reason, 'no_agent_spend_cap_configured', String(cap));
|
|
362
|
+
}
|
|
363
|
+
const c = createSpendCeiling({ agentCapUsd: 5, now: () => AT });
|
|
364
|
+
for (const definition of [null, {}, { name: '' }, { name: ' ' }, { name: 42 }]) {
|
|
365
|
+
const r = await c.checkAgent(poolOf('0'), definition);
|
|
366
|
+
assert.equal(r.ok, false, JSON.stringify(definition));
|
|
367
|
+
assert.equal(r.reason, 'no_agent_identity', JSON.stringify(definition));
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test('the agent sum is keyed on agent_name and the calendar month — not definition_id', async () => {
|
|
372
|
+
const c = createSpendCeiling({ agentCapUsd: 5, now: () => AT });
|
|
373
|
+
let sql = null; let params = null;
|
|
374
|
+
await c.checkAgent({ query: async (s, p) => { sql = s; params = p; return { rows: [{ spent: '0' }] }; } }, HISTORIAN);
|
|
375
|
+
assert.match(sql, /agent_name\s*=\s*\$2/, 'keyed on the NAME');
|
|
376
|
+
assert.doesNotMatch(sql, /definition_id/,
|
|
377
|
+
'definition_id is ON DELETE SET NULL, so keying on it would zero an agent month '
|
|
378
|
+
+ 'when its definition is deleted and hand a re-created agent a fresh cap');
|
|
379
|
+
assert.doesNotMatch(sql, /payer_kind/, 'a per-agent cap spans every payer — that is the point');
|
|
380
|
+
assert.equal(params[0].toISOString(), '2026-09-01T00:00:00.000Z');
|
|
381
|
+
assert.equal(params[1], 'historian');
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('checkAgent RECORDS NOTHING either — agents_runs stays the only tally', async () => {
|
|
385
|
+
const c = createSpendCeiling({ agentCapUsd: 5, now: () => AT });
|
|
386
|
+
const statements = [];
|
|
387
|
+
const pool = { query: async (s) => { statements.push(s); return { rows: [{ spent: '1' }] }; } };
|
|
388
|
+
await c.checkAgent(pool, HISTORIAN);
|
|
389
|
+
await c.checkAgent(pool, HISTORIAN);
|
|
390
|
+
assert.equal(statements.length, 2);
|
|
391
|
+
for (const s of statements) {
|
|
392
|
+
assert.match(s, /^\s*SELECT/i);
|
|
393
|
+
assert.doesNotMatch(s, /INSERT|UPDATE|DELETE/i);
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
// 5. the cost mirror (task 1002499) — what makes the fleet-wide brake able to
|
|
399
|
+
// see agent spend at all
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
const mirrorPort = () => {
|
|
403
|
+
const rows = [];
|
|
404
|
+
return { rows, logCost: async (r) => { rows.push(r); return { id: 1 }; } };
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
test('the source tag is the agent: family the MTD brake matches on', () => {
|
|
408
|
+
assert.equal(costSourceTag('historian'), 'agent:historian');
|
|
409
|
+
assert.ok(costSourceTag('x').startsWith('agent:'),
|
|
410
|
+
'the prefix is a CONTRACT: modules/economy/cost.js widens the autonomous MTD sum to '
|
|
411
|
+
+ 'source LIKE agent:%, so a tag outside the family is spend the brake cannot see');
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test('a completed fire mirrors its cost, keyed so a retry cannot double-post', async () => {
|
|
415
|
+
const economy = mirrorPort();
|
|
416
|
+
const wrote = await mirrorCostToLedger(
|
|
417
|
+
{ runId: 77, definition: { name: 'historian' }, costUsd: 0.9123, builderId: 25 }, { economy },
|
|
418
|
+
);
|
|
419
|
+
assert.equal(wrote, true);
|
|
420
|
+
assert.equal(economy.rows.length, 1);
|
|
421
|
+
const row = economy.rows[0];
|
|
422
|
+
assert.equal(row.amountUsd, 0.9123);
|
|
423
|
+
assert.equal(row.category, 'api', 'the category the brake filters on, beside the source');
|
|
424
|
+
assert.equal(row.source, 'agent:historian');
|
|
425
|
+
assert.equal(row.sourceRef, 'agent-run-77',
|
|
426
|
+
'logCost is insert-once on (source, source_ref), and the run id is the one value '
|
|
427
|
+
+ 'unique per fire — the alternative to a stable key here is double-counting money');
|
|
428
|
+
assert.equal(row.builderId, 25, 'who ASKED; the PAYER lives on the agents_runs row');
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test('nothing to mirror is not an error, and nothing is written', async () => {
|
|
432
|
+
for (const args of [
|
|
433
|
+
{ runId: 1, definition: { name: 'a' }, costUsd: 0 },
|
|
434
|
+
{ runId: 1, definition: { name: 'a' }, costUsd: null },
|
|
435
|
+
{ runId: 1, definition: { name: 'a' }, costUsd: -1 },
|
|
436
|
+
{ runId: 1, definition: { name: 'a' }, costUsd: '0.9' },
|
|
437
|
+
{ runId: null, definition: { name: 'a' }, costUsd: 0.9 },
|
|
438
|
+
{ runId: 1, definition: {}, costUsd: 0.9 },
|
|
439
|
+
{ runId: 1, definition: null, costUsd: 0.9 },
|
|
440
|
+
]) {
|
|
441
|
+
const economy = mirrorPort();
|
|
442
|
+
assert.equal(await mirrorCostToLedger(args, { economy }), false, JSON.stringify(args));
|
|
443
|
+
assert.equal(economy.rows.length, 0, 'a cost_log row with no fire behind it must not be possible');
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test('the mirror FAILS OPEN and says so — the opposite direction from the ceilings, on purpose', async () => {
|
|
448
|
+
// The ceilings fail closed because they decide whether to SPEND. This runs after
|
|
449
|
+
// the money is gone and the agents_runs row is written, so refusing here would
|
|
450
|
+
// discard a completed fire result to fix a reporting row.
|
|
451
|
+
const economy = { logCost: async () => { throw new Error('cost_log unreachable'); } };
|
|
452
|
+
const warnings = [];
|
|
453
|
+
const log = { warn: (fields, msg) => warnings.push({ fields, msg }) };
|
|
454
|
+
assert.equal(await mirrorCostToLedger(
|
|
455
|
+
{ runId: 9, definition: { name: 'historian' }, costUsd: 0.9 }, { economy, log },
|
|
456
|
+
), false);
|
|
457
|
+
assert.equal(warnings.length, 1, 'silence would leave the brake under-counting with nothing to notice it');
|
|
458
|
+
// STRUCTURED, not a formatted string: the run id and the agent are fields an
|
|
459
|
+
// operator can filter the journal by, which is the point of naming them at all.
|
|
460
|
+
assert.equal(warnings[0].fields.run_id, 9, 'names the run');
|
|
461
|
+
assert.equal(warnings[0].fields.agent, 'historian', 'names the agent');
|
|
462
|
+
assert.match(warnings[0].fields.err, /cost_log unreachable/, 'and what actually went wrong');
|
|
463
|
+
assert.match(warnings[0].msg, /under-counting/, 'and says what the consequence is');
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test('no economy port: no throw, no write — an instance may run agents without economy', async () => {
|
|
467
|
+
assert.equal(await mirrorCostToLedger(
|
|
468
|
+
{ runId: 1, definition: { name: 'a' }, costUsd: 0.9 }, { economy: null },
|
|
469
|
+
), false);
|
|
470
|
+
assert.equal(await mirrorCostToLedger(
|
|
471
|
+
{ runId: 1, definition: { name: 'a' }, costUsd: 0.9 }, { economy: {} },
|
|
472
|
+
), false, 'a port without logCost is the same as no port — ADR 0083 degrades, it does not crash');
|
|
473
|
+
});
|