@bongos/core 1.19.357 → 1.19.359

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.
@@ -0,0 +1,190 @@
1
+ // tests/agents_spawn.mjs — the spawn seam (task 1002489, goal 1000038).
2
+ //
3
+ // Gates two criteria: agents-spawn-via-grade-port and agents-model-tier-default.
4
+ //
5
+ // Everything is dependency-injected — a fake pool, a fake runner, a fake clock. No
6
+ // database, no subprocess, no model call. What is proven here is the DECISIONS: the
7
+ // tier mapping, the ledger's shape, and the refusals.
8
+ //
9
+ // Run: node tests/agents_spawn.mjs
10
+
11
+ import { strict as assert } from 'node:assert';
12
+ import { createRequire } from 'node:module';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const { spawnAgent, resolveModel, plannedRow, completedRow } = require('../modules/agents/spawn.js');
16
+
17
+ let passed = 0;
18
+ let failed = 0;
19
+ async function test(name, fn) {
20
+ try { await fn(); passed++; console.log(` ok ${name}`); }
21
+ catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
22
+ }
23
+
24
+ const MODELS = { main: 'opus', subagentDefault: 'opus', subagentRoutine: 'haiku' };
25
+ const DEF = { id: 7, name: 'historian', model_tier: 'default', persona: 'observe and report' };
26
+
27
+ // A pool that records every statement, so the LEDGER can be asserted on directly.
28
+ function fakePool() {
29
+ const calls = [];
30
+ return {
31
+ calls,
32
+ async query(sql, params) {
33
+ calls.push({ sql, params });
34
+ if (/^INSERT INTO agents_runs/.test(sql)) return { rows: [{ id: 42 }] };
35
+ return { rows: [] };
36
+ },
37
+ inserted() {
38
+ const c = calls.find((x) => /^INSERT INTO agents_runs/.test(x.sql));
39
+ if (!c) return null;
40
+ const cols = c.sql.match(/\(([^)]+)\) VALUES/)[1].split(',').map((s) => s.trim());
41
+ return Object.fromEntries(cols.map((k, i) => [k, c.params[i]]));
42
+ },
43
+ updated() {
44
+ const c = calls.find((x) => /^UPDATE agents_runs/.test(x.sql));
45
+ if (!c) return null;
46
+ const cols = [...c.sql.matchAll(/(\w+) = \$\d+/g)].map((m) => m[1]).filter((k) => k !== 'updated_at');
47
+ return Object.fromEntries(cols.map((k, i) => [k, c.params[i + 1]]));
48
+ },
49
+ };
50
+ }
51
+
52
+ // --- THE TIER MAPPING (criterion agents-model-tier-default) -----------------
53
+
54
+ await test('tier resolves through BRANDING, not a constant in the module', () => {
55
+ assert.equal(resolveModel('default', MODELS), 'opus');
56
+ assert.equal(resolveModel('routine', MODELS), 'haiku');
57
+ // An instance that retunes its tiers must move the agents with it.
58
+ assert.equal(resolveModel('routine', { subagentDefault: 'sonnet', subagentRoutine: 'sonnet' }), 'sonnet');
59
+ });
60
+
61
+ await test('an UNKNOWN tier falls back to DEFAULT, never to the cheap tier', () => {
62
+ // Silently downgrading a malformed definition presents much later as
63
+ // mysteriously poor agent output — far harder to trace than an unexpected
64
+ // cost line. Fail toward capability.
65
+ for (const bad of ['cheap', '', null, undefined, 'ROUTINE']) {
66
+ assert.equal(resolveModel(bad, MODELS), 'opus', `${bad} must not resolve to the routine tier`);
67
+ }
68
+ });
69
+
70
+ await test('a missing branding map yields null rather than inventing a model id', () => {
71
+ assert.equal(resolveModel('default', {}), null);
72
+ });
73
+
74
+ // --- THE LEDGER SHAPE -------------------------------------------------------
75
+
76
+ await test('a no-go is TERMINAL: skipped, no model, no output, no cost', () => {
77
+ const row = plannedRow({ definition: DEF, gate: { decision: 'no-go', reason: 'autonomy off' }, model: 'opus' });
78
+ assert.equal(row.gate_decision, 'no-go');
79
+ assert.equal(row.status, 'skipped', "'pending' would read as a run still in flight");
80
+ assert.equal(row.model, null, 'nothing ran, so no model was used');
81
+ assert.equal(row.gate_reason, 'autonomy off');
82
+ });
83
+
84
+ await test('a go starts PENDING so a crashed spawn is visible as unfinished', () => {
85
+ const row = plannedRow({ definition: DEF, gate: { decision: 'go' }, model: 'opus' });
86
+ assert.equal(row.status, 'pending');
87
+ assert.equal(row.model, 'opus', 'the CONCRETE model, so an old row is never re-read through a new tier map');
88
+ });
89
+
90
+ await test('the run stays attributable when the definition is gone', () => {
91
+ const row = plannedRow({ definition: DEF, gate: { decision: 'go' } });
92
+ assert.equal(row.agent_name, 'historian', 'denormalized name survives ON DELETE SET NULL');
93
+ });
94
+
95
+ await test('an errored run ALWAYS carries a code — the schema refuses otherwise', () => {
96
+ const c = completedRow({ ok: false });
97
+ assert.equal(c.status, 'error');
98
+ assert.ok(c.error_code, 'never null, even when the thrower named nothing');
99
+ assert.equal(c.output_ref, null, "a failed run's partial output is not a result");
100
+ });
101
+
102
+ // --- SPAWNING (criterion agents-spawn-via-grade-port) -----------------------
103
+
104
+ await test('a go spawns through the injected grade port and completes the row', async () => {
105
+ const pool = fakePool();
106
+ let seen = null;
107
+ const r = await spawnAgent(
108
+ { definition: DEF, trigger: { type: 'event', ref: 'task.shipped:1' }, gate: { decision: 'go' } },
109
+ { pool, brandingModels: MODELS, now: (() => { let t = 1000; return () => (t += 250); })(),
110
+ runSubagentCached: async (a) => { seen = a; return { ok: true, cost_usd: 0.42, output_ref: 'grade:9' }; } },
111
+ );
112
+ assert.equal(r.ok, true);
113
+ assert.equal(r.ran, true);
114
+ assert.equal(seen.opts.model, 'opus', 'the resolved model reaches the port');
115
+ assert.equal(pool.updated().status, 'ok');
116
+ assert.equal(Number(pool.updated().cost_usd), 0.42, 'spend lands in the ledger');
117
+ });
118
+
119
+ await test('the port receives NO tool or permission knobs — v1 agents are read-only', async () => {
120
+ // Not policy: grader.js hardcodes `--tools Read`. Anything here that looked
121
+ // like granting a capability would be a bug, so assert the absence.
122
+ let seen = null;
123
+ await spawnAgent({ definition: DEF, gate: { decision: 'go' } }, {
124
+ pool: fakePool(), brandingModels: MODELS,
125
+ runSubagentCached: async (a) => { seen = a; return { ok: true }; },
126
+ });
127
+ for (const forbidden of ['tools', 'allowedTools', 'permissions', 'permissionMode', 'bypassPermissions']) {
128
+ assert.ok(!(forbidden in seen.opts), `opts must not carry ${forbidden}`);
129
+ }
130
+ });
131
+
132
+ await test('a NO-GO spawns nothing at all', async () => {
133
+ const pool = fakePool();
134
+ let ran = false;
135
+ const r = await spawnAgent({ definition: DEF, gate: { decision: 'no-go', reason: 'capped' } }, {
136
+ pool, brandingModels: MODELS, runSubagentCached: async () => { ran = true; return { ok: true }; },
137
+ });
138
+ assert.equal(ran, false, 'a refusal must not reach the model');
139
+ assert.equal(r.ran, false);
140
+ assert.equal(r.skipped, true);
141
+ assert.equal(pool.updated(), null, 'terminal at insert — nothing to complete');
142
+ assert.equal(pool.inserted().status, 'skipped');
143
+ });
144
+
145
+ await test('a refusal is still LEDGERED — an untraced no-go is unexplainable later', async () => {
146
+ const pool = fakePool();
147
+ await spawnAgent({ definition: DEF, gate: { decision: 'no-go', reason: 'autonomy gate closed' } },
148
+ { pool, brandingModels: MODELS });
149
+ const row = pool.inserted();
150
+ assert.ok(row, 'the row exists even though nothing ran');
151
+ assert.equal(row.gate_reason, 'autonomy gate closed', 'and says why');
152
+ });
153
+
154
+ await test('a missing grade port is a named ledger row, not a crash', async () => {
155
+ // An instance may run `agents` with `grading` disabled. resolveOptional is what
156
+ // makes that a refusal instead of a boot-time require failure.
157
+ const pool = fakePool();
158
+ const r = await spawnAgent({ definition: DEF, gate: { decision: 'go' } },
159
+ { pool, brandingModels: MODELS, runSubagentCached: null });
160
+ assert.equal(r.ok, false);
161
+ assert.equal(r.error_code, 'grade_port_unavailable');
162
+ assert.equal(pool.updated().status, 'error');
163
+ assert.equal(pool.updated().error_code, 'grade_port_unavailable');
164
+ });
165
+
166
+ await test('a throwing subagent is recorded, and never propagates', async () => {
167
+ // An event fabric must not be taken down by one agent's bad day.
168
+ const pool = fakePool();
169
+ const r = await spawnAgent({ definition: DEF, gate: { decision: 'go' } }, {
170
+ pool, brandingModels: MODELS,
171
+ runSubagentCached: async () => { throw Object.assign(new Error('boom'), { code: 'timeout' }); },
172
+ });
173
+ assert.equal(r.ok, false);
174
+ assert.equal(r.error_code, 'timeout');
175
+ assert.equal(pool.updated().status, 'error');
176
+ });
177
+
178
+ await test('an unledgerable fire does NOT spawn — unaccounted spend is worse', async () => {
179
+ let ran = false;
180
+ const r = await spawnAgent({ definition: DEF, gate: { decision: 'go' } }, {
181
+ pool: { query: async () => { throw new Error('db down'); } },
182
+ brandingModels: MODELS,
183
+ runSubagentCached: async () => { ran = true; return { ok: true }; },
184
+ });
185
+ assert.equal(ran, false, 'money must never be spent with nowhere to record it');
186
+ assert.equal(r.error_code, 'ledger_write_failed');
187
+ });
188
+
189
+ console.log(`\nagents_spawn: ${passed} passed, ${failed} failed`);
190
+ process.exit(failed ? 1 : 0);
@@ -204,16 +204,14 @@ t('every PROTECTED_GLOB is either topology-excluded or publishable (the methodol
204
204
  // Modules deliberately not published, on the record. An entry here is a decision
205
205
  // someone made, not a gap someone forgot — and every entry needs the task or idea that
206
206
  // records the decision, so this can never become a quiet parking lot.
207
- const KNOWN_UNPUBLISHED_MODULES = new Map([
208
- // Same gap as copy-desk, found by the guard below the moment it was written. LATENT
209
- // rather than live only because agents is default:false — the day a released instance
210
- // enables it, the loader MODULE_NOT_FOUNDs, which is exactly what the provisioning
211
- // (task 2135) and platform-identity (task 1002235) allowlist entries exist to prevent.
212
- // Left for goal 1000038 rather than fixed here: deciding what the published core
213
- // contains for the agents module is that goal's call, not a drive-by from a copy-desk
214
- // task. Filed as idea 1000797 with the one-line fix.
215
- ['agents', 'idea 1000797'],
216
- ]);
207
+ // Currently EMPTY, and that is the healthy state — every module on disk is either
208
+ // allowlisted or denied on the record. The one entry it ever held ('agents', idea
209
+ // 1000797) was retired by task 1002489: the copy-desk task that found the gap
210
+ // deliberately left the call to goal 1000038, and task 1002489 IS that goal, so it
211
+ // made it — agents is core-maintained, so it joined PUBLISH_ALLOWLIST rather than a
212
+ // deny list. Keep the map (and the two guards below): the next module added without
213
+ // a publish decision is caught the same way this one was.
214
+ const KNOWN_UNPUBLISHED_MODULES = new Map([]);
217
215
 
218
216
  t('every module on disk is either publishable or deliberately unpublished (a new module cannot be silently invisible)', () => {
219
217
  const modulesDir = path.join(ROOT, 'modules');
@@ -0,0 +1,297 @@
1
+ // tests/task_recommendations.mjs — passing a task RECOMMENDS it; it never
2
+ // transfers the claim (task 1003173 / C03, goal 1000075; criterion
3
+ // collab-pass-a-task, ADR 0187 §3).
4
+ //
5
+ // The criterion names four testable promises, and this file is organised around
6
+ // them, because each one is a thing the code could plausibly have done instead:
7
+ // 1. a pass creates a recommendation and never a claim;
8
+ // 2. the recipient's claim is their own action;
9
+ // 3. the recommendation and its author are readable afterwards;
10
+ // 4. declining leaves no penalty on either party.
11
+ //
12
+ // Promises 1 and 4 are ABSENCE claims — "this code does not do X" — so they are
13
+ // asserted against the source, which is the only place an absence is visible. A
14
+ // behavioural test can show that a claim was not created on one path; it cannot
15
+ // show that no path creates one.
16
+ import { strict as assert } from 'node:assert';
17
+ import { test } from 'node:test';
18
+ import { createRequire } from 'node:module';
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const require = createRequire(import.meta.url);
24
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
25
+ const recs = require(path.join(ROOT, 'modules', 'lifecycle', 'task-recommendations.js'));
26
+ const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8');
27
+
28
+ const SRC = read('modules', 'lifecycle', 'task-recommendations.js');
29
+ const ROUTES = read('modules', 'lifecycle', 'routes', 'recommendations.js');
30
+ const SQL = read('modules', 'lifecycle', 'migrations', 'lifecycle_005_task_recommendations.sql');
31
+
32
+ const SENDER = 7;
33
+ const good = { task_id: 42, recommended_to: 9, reason: 'you shipped the last three hall surfaces' };
34
+ const reasons = (input, sender = SENDER) => recs.validateRecommendation(input, sender).errors.map((e) => e.reason);
35
+
36
+ // ── PROMISE 1: a pass creates a recommendation and NEVER a claim ────────────
37
+
38
+ test('THE DECISION: nothing in this surface writes a claim', () => {
39
+ // The criterion exists because a pass that moved a claim would be a task
40
+ // quietly changing hands. Asserted as an absence over the whole surface: any
41
+ // INSERT/UPDATE against claims, from either file, is the thing forbidden.
42
+ for (const [name, src] of [['data layer', SRC], ['routes', ROUTES]]) {
43
+ assert.ok(!/\bINSERT\s+INTO\s+claims\b/i.test(src), `${name} must not insert a claim`);
44
+ assert.ok(!/\bUPDATE\s+claims\b/i.test(src), `${name} must not update a claim`);
45
+ assert.ok(!/\bDELETE\s+FROM\s+claims\b/i.test(src), `${name} must not delete a claim`);
46
+ }
47
+ });
48
+
49
+ test('and it does not reach the claim path indirectly either', () => {
50
+ // A require of the claim machinery would be the seam the decision refuses,
51
+ // even if today it went unused.
52
+ for (const [name, src] of [['data layer', SRC], ['routes', ROUTES]]) {
53
+ assert.ok(!/require\([^)]*claim/i.test(src), `${name} must not require the claim path`);
54
+ assert.ok(!/claimTask/.test(src), `${name} must not call claimTask`);
55
+ }
56
+ });
57
+
58
+ test('THE UNUSED SEAM: there is no transfer function, and no name promising one', () => {
59
+ // "Do NOT build a transfer path or a seam for one; the decision was
60
+ // recommend-only, and an unused seam is a cost." A function called transfer/
61
+ // reassign/handOff would read to the next author as an intention.
62
+ const exported = Object.keys(recs);
63
+ const suspect = exported.filter((k) => /transfer|reassign|handoff|hand_off|takeover/i.test(k));
64
+ assert.deepEqual(suspect, [], `no transfer-shaped export: ${suspect}`);
65
+ });
66
+
67
+ test('the schema has NO accepted status — accepting is claiming, which lives elsewhere', () => {
68
+ // A status saying "accepted" would be a second, weaker record of holding work,
69
+ // and would make this table look like the place claims come from.
70
+ assert.match(SQL, /status IN \('open', 'declined', 'withdrawn'\)/);
71
+ // Comments STRIPPED before asserting: both files DISCUSS why there is no
72
+ // accepted status, and matching the prose that explains the rule as a
73
+ // violation of it is the over-broad-source-assertion reflex of learning
74
+ // 1000196 — the same trap the sibling C01 test documents.
75
+ const sqlCode = SQL.replace(/^\s*--.*$/gm, '');
76
+ const routeCode = ROUTES.replace(/^\s*\/\/.*$/gm, '');
77
+ assert.ok(!/'accepted'/.test(sqlCode), 'no accepted status');
78
+ assert.ok(!/\baccept\b/i.test(routeCode), 'and no accept route');
79
+ });
80
+
81
+ test('the schema carries no transfer column', () => {
82
+ const body = SQL.slice(SQL.indexOf('CREATE TABLE'), SQL.indexOf('\n);'));
83
+ const columns = body.split('\n')
84
+ .map((l) => l.replace(/--.*$/, '').trim())
85
+ .filter((l) => l && !/^(CONSTRAINT|CHECK|\))/.test(l));
86
+ const suspect = columns.filter((l) => /transferred|claimed_by|assigned_to|accepted/i.test(l));
87
+ assert.deepEqual(suspect, [], `no transfer-shaped column: ${suspect}`);
88
+ });
89
+
90
+ // ── PROMISE 2: the recipient's claim is their own action ────────────────────
91
+
92
+ test('the recipient read reports the TASK STATUS, not a claim grant', () => {
93
+ // What the recipient gets is enough to judge, and nothing that says they now
94
+ // hold it. Claimability stays the claim path's answer — duplicating that
95
+ // judgement here would be a second, staler copy of a rule that has an owner.
96
+ const fn = SRC.slice(SRC.indexOf('async function listForRecipient'));
97
+ const body = fn.slice(0, fn.indexOf('\n}'));
98
+ assert.match(body, /t\.status\s+AS task_status/, 'the task\'s own status is reported');
99
+ assert.ok(!/can_claim|claimable|is_claimable/i.test(body), 'and no claimability verdict is invented');
100
+ });
101
+
102
+ test('a recommendation to yourself is refused', () => {
103
+ // Not a recommendation. Checked against the AUTHENTICATED sender, never a
104
+ // client-supplied id — which is why validateRecommendation takes it as an
105
+ // argument rather than reading it out of the body.
106
+ assert.ok(reasons({ ...good, recommended_to: SENDER }).includes('cannot_recommend_to_self'));
107
+ assert.match(ROUTES, /validateRecommendation\(req\.body, req\.builder\.id\)/,
108
+ 'the sender comes from the session');
109
+ });
110
+
111
+ test('the not-self rule is in the schema too, so neither guard is the only one', () => {
112
+ assert.match(SQL, /recommended_by <> recommended_to/);
113
+ });
114
+
115
+ // ── PROMISE 3: the recommendation and its author are readable afterwards ────
116
+
117
+ test('THE LEDGER: the reason is required, not optional', () => {
118
+ // The criterion's own argument: an untraced recommendation is indistinguishable
119
+ // from a task quietly changing hands. A reason the sender must write is what
120
+ // makes this a record of a judgement rather than a nudge.
121
+ for (const v of [undefined, null, '', ' ', '\n\t ']) {
122
+ assert.ok(reasons({ ...good, reason: v }).includes('required'),
123
+ `${JSON.stringify(v)} is not a reason`);
124
+ }
125
+ assert.deepEqual(reasons(good), [], 'a real reason validates');
126
+ });
127
+
128
+ test('the reason is enforced in the schema and the route body as well', () => {
129
+ // Three guards that agree: the CHECK is the authority, the validator names the
130
+ // field, and the route schema refuses before either runs.
131
+ assert.match(SQL, /reason\s+text NOT NULL/);
132
+ assert.match(SQL, /length\(btrim\(reason\)\) > 0/);
133
+ assert.match(ROUTES, /reason: \{ required: true, type: 'string', minLength: 1/);
134
+ });
135
+
136
+ test('an over-long reason is refused rather than truncated', () => {
137
+ assert.ok(reasons({ ...good, reason: 'x'.repeat(recs.MAX_REASON + 1) }).includes('too_long'));
138
+ });
139
+
140
+ test('the per-task history reads EVERY status, so a declined pass stays on the record', () => {
141
+ // "Readable afterwards" is the point. A history filtered to open would erase
142
+ // exactly the case the ledger exists to document.
143
+ const fn = SRC.slice(SRC.indexOf('async function listForTask'));
144
+ const body = fn.slice(0, fn.indexOf('\n}'));
145
+ assert.match(body, /WHERE r\.task_id = \$1/);
146
+ assert.ok(!/status = 'open'/.test(body), 'the per-task history is not filtered to open');
147
+ });
148
+
149
+ test('the history names the AUTHOR, not just the recommendation', () => {
150
+ const fn = SRC.slice(SRC.indexOf('async function listForTask'));
151
+ const body = fn.slice(0, fn.indexOf('\n}'));
152
+ assert.match(body, /sender\.github_login/, 'who recommended it');
153
+ assert.match(body, /r\.reason/, 'and why');
154
+ });
155
+
156
+ test("the sender's own list is unfiltered by default", () => {
157
+ const fn = SRC.slice(SRC.indexOf('async function listFromSender'));
158
+ const body = fn.slice(0, fn.indexOf('\n}'));
159
+ assert.match(body, /\$2::text IS NULL OR r\.status = \$2/, 'status is an optional narrowing');
160
+ });
161
+
162
+ // ── PROMISE 4: declining leaves no penalty on either party ──────────────────
163
+
164
+ test('THE NO-PENALTY RULE: declining touches nothing but its own row', () => {
165
+ // Asserted as an absence, because that is what "costs nothing" means. If a
166
+ // penalty ever appears for declining, it did not come from here.
167
+ const fn = SRC.slice(SRC.indexOf('async function declineRecommendation'));
168
+ const body = fn.slice(0, fn.indexOf('\n}'));
169
+ assert.match(body, /UPDATE lifecycle_task_recommendations/);
170
+ assert.ok(!/builders|credits|karma|credit_log|peer_votes/i.test(body),
171
+ 'declining writes nothing about either person');
172
+ });
173
+
174
+ test('and no penalty machinery is reachable from this surface at all', () => {
175
+ for (const [name, src] of [['data layer', SRC], ['routes', ROUTES]]) {
176
+ const code = src.replace(/^\s*\/\/.*$/gm, ''); // comments may DISCUSS karma
177
+ for (const forbidden of ['karma', 'credit', 'penalt', 'cooldown', 'strike']) {
178
+ assert.ok(!new RegExp(forbidden, 'i').test(code),
179
+ `${name} must not reach ${forbidden} machinery`);
180
+ }
181
+ }
182
+ });
183
+
184
+ test('a decline is NOT final — the same task can be recommended again', () => {
185
+ // A permanent unique key would quietly make declining final, which is a cost.
186
+ // The uniqueness is partial, on open rows only.
187
+ assert.match(SQL, /CREATE UNIQUE INDEX[^;]*lifecycle_task_recommendations_open_uniq[^;]*WHERE status = 'open'/s);
188
+ });
189
+
190
+ test('a duplicate OPEN recommendation is a named conflict, not a 500', () => {
191
+ // The same judgement repeated. Not something the sender can fix by changing
192
+ // the body, so it is not a validation error.
193
+ assert.match(SRC, /ON CONFLICT DO NOTHING/);
194
+ assert.match(SRC, /reason: 'already_open'/);
195
+ assert.match(ROUTES, /recommendation_already_open', 409/);
196
+ });
197
+
198
+ // ── who may settle what ─────────────────────────────────────────────────────
199
+
200
+ test('the RECIPIENT declines and the SENDER withdraws — each scoped in the WHERE', () => {
201
+ const dec = SRC.slice(SRC.indexOf('async function declineRecommendation'));
202
+ assert.match(dec.slice(0, 600), /recommended_to = \$2/, 'ownership is in the WHERE, not in rank');
203
+ const wd = SRC.slice(SRC.indexOf('async function withdrawRecommendation'));
204
+ assert.match(wd.slice(0, 600), /recommended_by = \$2/);
205
+ for (const fn of [dec, wd]) {
206
+ assert.match(fn.slice(0, 600), /status = 'open'/, 'a settled recommendation cannot be re-settled');
207
+ }
208
+ });
209
+
210
+ test('the settle refusal does not let you enumerate ids', () => {
211
+ // ONE refusal for absent / not-yours / already-settled. Looking the row up to
212
+ // say which would leak existence — the lesson help-requests learned from its
213
+ // own grader round (task 1003171).
214
+ const patch = ROUTES.slice(ROUTES.indexOf("'PATCH /task-recommendations/:id'"));
215
+ const body = patch.slice(0, patch.indexOf('errorCode'));
216
+ assert.match(body, /recommendation_not_settleable', 404/);
217
+ assert.ok(!/getRecommendation|SELECT/i.test(body), 'no lookup before the refusal');
218
+ });
219
+
220
+ // ── the shape the module contract requires ──────────────────────────────────
221
+
222
+ test('the table is module-owned and lifecycle-namespaced', () => {
223
+ // A NEW table the module owns outright, unlike tasks/builders which are core
224
+ // tables it only references — so fitness check 11 permits it (ADR 0083 §5).
225
+ assert.match(SQL, /CREATE TABLE IF NOT EXISTS lifecycle_task_recommendations/);
226
+ assert.ok(!/ALTER TABLE (?!lifecycle_)/.test(SQL), 'it alters no core table');
227
+ });
228
+
229
+ test('the migration registers itself', () => {
230
+ assert.match(SQL, /INSERT INTO schema_migrations \(version\) VALUES \('lifecycle_005_task_recommendations'\)/);
231
+ });
232
+
233
+ test('the route is registered in the module manifest', () => {
234
+ // A route file nothing mounts is a file, not a surface.
235
+ const manifest = JSON.parse(read('modules', 'lifecycle', 'module.json'));
236
+ assert.ok(manifest.contributes.routes.includes('recommendations'));
237
+ });
238
+
239
+ test('core reaches the count through the PORT, never by importing the module', () => {
240
+ // ADR 0093: core imports no module. /me needs the count for the needs roster,
241
+ // so the lifecycle port exposes it the same way it exposes the help-request one.
242
+ const lifecycle = read('modules', 'lifecycle', 'lifecycle.js');
243
+ assert.match(lifecycle, /openTaskRecommendationsFor:/);
244
+ const me = read('src', 'bongos', 'routes', 'me.js');
245
+ assert.match(me, /lifecycle\.openTaskRecommendationsFor/);
246
+ assert.ok(!/require\([^)]*task-recommendations/.test(me), 'core does not import the module');
247
+ });
248
+
249
+ // ── it reaches someone, rather than merely being stored ─────────────────────
250
+
251
+ const needs = require(path.join(ROOT, 'modules', 'builder-settings', 'builder-needs.js'));
252
+
253
+ test('a waiting recommendation produces a need, so it is surfaced and not just stored', () => {
254
+ // C01's grader round established the rule for its sibling: storing is not
255
+ // reaching. It lands in the needs roster the hall card and the CLI nudge
256
+ // already render, rather than in a second notification system beside it.
257
+ const n = needs.taskRecommendationsNeed({ openTaskRecommendations: 2 });
258
+ assert.ok(n, 'a pending recommendation produces a need');
259
+ assert.match(n.message, /2 tasks/);
260
+ assert.ok(n.action && n.action.href, 'and it says where to go');
261
+ });
262
+
263
+ test('the need says plainly that nothing has been assigned', () => {
264
+ // Wording that pressured the reader would BE the penalty the decision forbids.
265
+ const n = needs.taskRecommendationsNeed({ openTaskRecommendations: 1 });
266
+ assert.match(n.message, /Nothing has been assigned/);
267
+ assert.match(n.message, /declining costs nothing/);
268
+ assert.match(n.what, /suggestion, not an assignment/);
269
+ });
270
+
271
+ test('no recommendations produces NO need, not a cheerful empty state', () => {
272
+ for (const ctx of [{ openTaskRecommendations: 0 }, {}, { openTaskRecommendations: undefined }]) {
273
+ assert.equal(needs.taskRecommendationsNeed(ctx), null);
274
+ }
275
+ });
276
+
277
+ test('the need is registered, so every renderer picks it up', () => {
278
+ const roster = needs.computeNeeds({ openTaskRecommendations: 1, ownKeySet: true, rank: 'metic' });
279
+ assert.ok(roster.items.some((i) => i.id === 'task_recommendations'), 'it is in the roster');
280
+ });
281
+
282
+ // ── bounds ──────────────────────────────────────────────────────────────────
283
+
284
+ test('reads are bounded, so an unsettled pile degrades the page and not the server', () => {
285
+ assert.equal(recs.capRows(0), recs.MAX_ROWS);
286
+ assert.equal(recs.capRows(-1), recs.MAX_ROWS);
287
+ assert.equal(recs.capRows(recs.MAX_ROWS + 1), recs.MAX_ROWS);
288
+ assert.equal(recs.capRows(1.5), recs.MAX_ROWS);
289
+ assert.equal(recs.capRows(25), 25);
290
+ });
291
+
292
+ test('task_id and recommended_to are required and must be positive ids', () => {
293
+ assert.ok(reasons({ reason: 'x' }).includes('required'));
294
+ assert.ok(reasons({ ...good, task_id: 0 }).includes('bad_task_id'));
295
+ assert.ok(reasons({ ...good, task_id: 1.5 }).includes('bad_task_id'));
296
+ assert.ok(reasons({ ...good, recommended_to: -3 }).includes('bad_builder_id'));
297
+ });