@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.
Files changed (40) hide show
  1. package/.bongos-core.json +70 -40
  2. package/docs/module-api-changelog.md +4 -0
  3. package/migrations/core_247_discipline_required.sql +58 -0
  4. package/modules/agents/lib/spend-ceiling.js +126 -37
  5. package/modules/agents/migrations/agents_004_spend_cap.sql +53 -0
  6. package/modules/agents/pollers/event-router.js +29 -2
  7. package/modules/agents/routes/agents.js +38 -1
  8. package/modules/agents/spawn.js +73 -1
  9. package/modules/copy-desk/routes/copy-desk.js +6 -0
  10. package/modules/economy/cost-classify.js +11 -0
  11. package/modules/economy/cost.js +21 -3
  12. package/modules/economy/reward.js +7 -0
  13. package/modules/hall-ui/public/goals.states.json +2 -2
  14. package/modules/hall-ui/public/idea.states.json +1 -1
  15. package/modules/hall-ui/public/ideas.states.json +1 -1
  16. package/modules/hall-ui/public/index.states.json +1 -1
  17. package/modules/hall-ui/public/pair.states.json +1 -1
  18. package/modules/hall-ui/public/settings.states.json +1 -1
  19. package/modules/hall-ui/public/task.states.json +1 -1
  20. package/modules/hall-ui/public/work.states.json +1 -1
  21. package/modules/ideas/inbox.js +4 -2
  22. package/modules/lifecycle/db-tasks.js +28 -2
  23. package/modules/lifecycle/lifecycle.js +6 -0
  24. package/modules/lifecycle/routes/goals.js +17 -4
  25. package/modules/lifecycle/routes/task-write-routes.js +25 -6
  26. package/modules/lifecycle/task-classifier.js +39 -0
  27. package/package-lock.json +2 -2
  28. package/package.json +1 -1
  29. package/scripts/gds/backfill-disciplines.js +144 -0
  30. package/scripts/gds/fitness-ratchets.js +59 -2
  31. package/scripts/gds/init.js +6 -1
  32. package/scripts/gds/triage.js +7 -0
  33. package/src/module-api.js +1 -1
  34. package/tests/agents_spend_guard.mjs +203 -2
  35. package/tests/hall_states_regex_escapes.mjs +98 -0
  36. package/tests/knip_ratchet_ambient.mjs +101 -0
  37. package/tests/rank_floor_wiring.mjs +4 -0
  38. package/tests/reward_gate.mjs +4 -4
  39. package/tests/task_discipline_required.mjs +163 -0
  40. package/tests/task_goal_required.mjs +4 -4
@@ -0,0 +1,53 @@
1
+ -- agents_004_spend_cap.sql — the PER-AGENT monthly spend cap (task 1002499).
2
+ --
3
+ -- agents_002 recorded who PAYS for a fire, and modules/agents/lib/spend-ceiling.js
4
+ -- bounds what a payer may spend in a month. Both ask "how much has this WALLET
5
+ -- spent", and neither can answer "how much has this AGENT spent" — so under a
6
+ -- single project payer one runaway definition consumes the whole instance ceiling
7
+ -- and every other agent is refused for a bill it did not run up. The owner's
8
+ -- decision (2026-07-05, recorded on task 1002499) is ~$5/month per agent, layered
9
+ -- under the ~$30/month global autonomy brake: a per-agent cap bounds the blast
10
+ -- radius of ONE bad definition, and the global brake bounds the fleet.
11
+ --
12
+ -- NULL MEANS "USE THE INSTANCE DEFAULT", NOT "UNCAPPED". The default lives in
13
+ -- lib/spend-ceiling.js (BONGOS_AGENT_SPEND_CAP_USD, default $5) so a definition
14
+ -- that never thought about money still has a ceiling, and an instance can move
15
+ -- every unset agent with one env var. Storing the default on each row instead
16
+ -- would freeze today's number into every definition ever synced — the same trap
17
+ -- agents_002's header names for deriving payer_kind, in the other direction.
18
+ -- Nothing may read this column as "no cap": the CHECK below forbids a zero or a
19
+ -- negative, so the only two states are a positive number and "ask the default".
20
+ --
21
+ -- WHY NO NEW INDEX. The read this backs — SUM(cost_usd) for one agent this month
22
+ -- — is already served by agents_runs_agent_idx (agent_name, created_at DESC),
23
+ -- which agents_001 created with the comment "the read behind a per-agent spend
24
+ -- rollup". This migration is the rollup that index was built for.
25
+ --
26
+ -- Module migrations are ADDITIVE and NAMESPACED (ADR 0083 §Decision #5): every
27
+ -- object is agents_-prefixed, there is no down-migration, and the filename is
28
+ -- agents_-prefixed (ADR 0108 §5). Idempotent: safe to re-run.
29
+
30
+ BEGIN;
31
+
32
+ -- The per-agent monthly ceiling in USD. NULL = fall back to the instance default.
33
+ -- numeric(10,4) mirrors agents_runs.cost_usd, so a cap and the spend it bounds are
34
+ -- the same kind of number and a comparison cannot lose a fraction of a cent.
35
+ ALTER TABLE agents_definitions ADD COLUMN IF NOT EXISTS spend_cap_usd numeric(10, 4);
36
+
37
+ DO $$
38
+ BEGIN
39
+ -- A stored cap must be a real ceiling. Zero would read as "this agent may never
40
+ -- fire", which is what `enabled = false` already says and says better; a
41
+ -- negative is meaningless. Both are refused at the schema rather than left for
42
+ -- the guard to interpret, because a guard that has to decide what 0 means is a
43
+ -- guard with two possible answers.
44
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'agents_definitions_spend_cap_positive_chk') THEN
45
+ ALTER TABLE agents_definitions ADD CONSTRAINT agents_definitions_spend_cap_positive_chk
46
+ CHECK (spend_cap_usd IS NULL OR spend_cap_usd > 0);
47
+ END IF;
48
+ END $$;
49
+
50
+ COMMENT ON COLUMN agents_definitions.spend_cap_usd IS
51
+ 'Per-agent monthly spend ceiling in USD (task 1002499). NULL = use the instance default from lib/spend-ceiling.js (BONGOS_AGENT_SPEND_CAP_USD, default $5). Never "uncapped" — a positive value is the only storable cap.';
52
+
53
+ COMMIT;
@@ -113,8 +113,13 @@ const FANOUT_MAX = envInt('BONGOS_AGENTS_EVENT_FANOUT_MAX', DEFAULT_FANOUT_MAX);
113
113
  // `enabled = false` by agents_definitions_flagged_not_armed_chk, so the WHERE
114
114
  // clause below already excludes it and a second check here would only be a
115
115
  // second place to get it wrong.
116
+ // `spend_cap_usd` joins the list for step (g2) — the per-agent ceiling reads the
117
+ // definition's own cap (task 1002499). It has to be selected here or every agent
118
+ // silently takes the instance default, which is the quiet-wrong-answer shape this
119
+ // spelled-out list otherwise protects against: a cap set on a definition would be
120
+ // stored, shown by the API, and ignored by the only code that enforces it.
116
121
  const DEF_COLUMNS = `
117
- id, name, persona, trigger_type, trigger_spec, model_tier
122
+ id, name, persona, trigger_type, trigger_spec, model_tier, spend_cap_usd
118
123
  `;
119
124
 
120
125
  // ---------------------------------------------------------------------------
@@ -590,10 +595,32 @@ async function fanOut(payload, deps = {}) {
590
595
  return report;
591
596
  }
592
597
 
598
+ // (g2) THE PER-AGENT CEILING (task 1002499), once PER AGENT rather than once
599
+ // for the fan-out. That is the whole difference from (g) above: the payer
600
+ // ceiling asks a question about the wallet, so one answer covers every agent
601
+ // sharing it, while this one asks about each definition and so has as many
602
+ // answers as there are agents. An agent over its share is skipped with its own
603
+ // ledger row; its siblings still fire, which is the behaviour a per-agent cap
604
+ // exists to produce — one runaway definition must not silence the fleet.
605
+ const stillAdmitted = [];
606
+ for (const a of admitted) {
607
+ const agentRoom = await ceiling.checkAgent(pool, a.definition);
608
+ if (agentRoom.ok) { stillAdmitted.push(a); continue; }
609
+ const reason = agentRoom.reason === 'agent_spend_cap_reached'
610
+ ? `agent '${a.definition && a.definition.name}' has reached its $${agentRoom.capUsd} monthly cap — the instance still has room, but this agent has spent its share`
611
+ : `this agent's spend ceiling could not be established (${agentRoom.reason}) — a ceiling that cannot be read must not be assumed`;
612
+ await ledgerNoGo(deps, { definition: a.definition, taskId, reason, spendPayer });
613
+ report.skipped += 1;
614
+ }
615
+ if (!stillAdmitted.length) {
616
+ report.reason = 'agent_spend_ceiling';
617
+ return report;
618
+ }
619
+
593
620
  // (h) FIRE. Sequentially, so N agents on one ship are N model calls in a row
594
621
  // rather than N at once — the spend ceiling's own blind spot is fires already
595
622
  // in flight, and serialising is what keeps that blind spot one fire wide.
596
- for (const a of admitted) {
623
+ for (const a of stillAdmitted) {
597
624
  const fired = await fireOne(a, { task, taskId, spendPayer }, deps);
598
625
  if (fired && fired.ran) report.fired += 1; else report.skipped += 1;
599
626
  }
@@ -122,7 +122,7 @@ const COLUMNS = `
122
122
  id, name, title, trigger_type, trigger_spec, model_tier,
123
123
  scope_modules, scope_paths, scope_violation,
124
124
  source, provenance, author_rank,
125
- source_path, last_synced_at, enabled, created_at, updated_at
125
+ source_path, last_synced_at, enabled, spend_cap_usd, created_at, updated_at
126
126
  `;
127
127
 
128
128
  // PURE. Row → wire shape, so the projection is unit-testable without a database.
@@ -168,6 +168,14 @@ function serializeDefinition(row, { includePersona = false } = {}) {
168
168
  // is the one disarm the registry can explain; anything else is simply off.
169
169
  disarmed_reason: row.enabled === true ? null : (row.scope_violation ? 'scope_violation' : null),
170
170
  last_synced_at: row.last_synced_at || null,
171
+ // The agent's own monthly ceiling (task 1002499), or null when it takes the
172
+ // instance default. Served as a NUMBER, not the pg NUMERIC string, so a caller
173
+ // comparing it against a spend does not silently compare strings. Null here
174
+ // never means "uncapped" — agents_004's CHECK makes a positive value the only
175
+ // storable cap, and an unset one resolves to BONGOS_AGENT_SPEND_CAP_USD.
176
+ spend_cap_usd: row.spend_cap_usd === null || row.spend_cap_usd === undefined
177
+ ? null
178
+ : Number(row.spend_cap_usd),
171
179
  created_at: row.created_at,
172
180
  updated_at: row.updated_at,
173
181
  };
@@ -605,6 +613,35 @@ module.exports = function agentsRoutes() {
605
613
  );
606
614
  if (!rows.length) return res.fail('agent_not_found', 404);
607
615
  const definition = rows[0];
616
+
617
+ // THE PER-AGENT CEILING (task 1002499), after the payer's and before the
618
+ // gate. It cannot run any earlier: it needs the definition, both for its own
619
+ // spend_cap_usd and for the name it sums by.
620
+ //
621
+ // A THIRD refusal with a THIRD code, for the same reason the first two are
622
+ // separate: 429 says "too fast", `spend_cap_reached` says "this wallet's
623
+ // month is gone", and this one says "this wallet has room, but THIS AGENT
624
+ // has had its share". Collapsing them would tell an operator staring at a
625
+ // refused fire to go raise the wrong number — and under a single project
626
+ // payer the wrong number is the one that refuses every OTHER agent too.
627
+ const agentRoom = await spendCeiling.checkAgent(api.pool, definition);
628
+ if (!agentRoom.ok) {
629
+ return res.fail('agent_spend_cap_reached', {
630
+ status: 402,
631
+ message: agentRoom.reason === 'agent_spend_cap_reached'
632
+ ? `agent '${definition.name}' has reached its $${agentRoom.capUsd} monthly cap — the instance still has room, but this agent has spent its share`
633
+ : `this fire is refused because the agent's spend ceiling could not be established (${agentRoom.reason}) — a ceiling that cannot be read must not be assumed`,
634
+ details: {
635
+ scope: 'agent-spend-per-agent',
636
+ reason: agentRoom.reason,
637
+ agent: definition.name,
638
+ cap_usd: agentRoom.capUsd,
639
+ // null whenever the refusal was a failure to READ, never a claimed sum.
640
+ spent_usd: agentRoom.spentUsd,
641
+ },
642
+ });
643
+ }
644
+
608
645
  const gate = gateFor(definition);
609
646
  const trigger = { type: 'on-demand', ref: `on-demand:${req.builder.github_login || req.builder.id}` };
610
647
 
@@ -36,6 +36,11 @@
36
36
  const api = require('../../src/module-api');
37
37
  const { payerFor } = require('./lib/spend-ceiling');
38
38
 
39
+ // Namespaced + structured, like the module's other two log sites (routes/agents.js,
40
+ // pollers/event-router.js). A bare console.warn here would be the module's only
41
+ // unstructured line, and the fitness console ratchet counts it.
42
+ const log = api.logger('agents');
43
+
39
44
  // tier → concrete model id. The mapping is BRANDING, not a constant here: an
40
45
  // instance retunes its own tiers, and the DB constraint already limits the
41
46
  // vocabulary to these two.
@@ -139,6 +144,68 @@ async function insertRun(pool, row) {
139
144
  return r.rows[0].id;
140
145
  }
141
146
 
147
+ // The cost_log SOURCE TAG for one agent's spend (task 1002499). The `agent:`
148
+ // family is what modules/economy/cost.js's autonomous MTD brake matches on, beside
149
+ // `cron:`, so the prefix is a contract and not a label — see that function's header.
150
+ // The name is already schema-constrained to ^[a-z][a-z0-9-]*$ (agents_001), so
151
+ // nothing here has to sanitise it, but the tag is built in ONE place so a future
152
+ // rename cannot leave the brake matching a prefix nobody writes any more.
153
+ function costSourceTag(agentName) {
154
+ return `agent:${agentName}`;
155
+ }
156
+
157
+ // Mirror one completed fire's cost into cost_log, so the project-wide brake can
158
+ // see it (task 1002499).
159
+ //
160
+ // WHY A MIRROR AND NOT A MOVE. agents_runs.cost_usd stays the ACCOUNTING — it is
161
+ // what the per-payer and per-agent ceilings sum, it carries the payer, and it is
162
+ // the row the schema's invariants are written about. cost_log is the project-wide
163
+ // ROLL-UP: the autonomy spend-brake and the cost dashboard's by_source breakdown
164
+ // both read it, and neither knows agents_runs exists. Before this, agent spend
165
+ // appeared in exactly one of those two places, which is why the brake was blind
166
+ // to it. The mirror is a projection of the ledger, never a second ledger: it is
167
+ // written once, after the cost is final, keyed so a retry cannot double-post.
168
+ //
169
+ // IDEMPOTENT BY CONSTRUCTION. logCost is insert-once on (source, source_ref), and
170
+ // source_ref is the run id — the one value that is unique per fire and already
171
+ // exists. A re-run of this function for the same fire is a no-op at the database,
172
+ // not a second row, which matters because the alternative to a stable key here is
173
+ // double-counting money.
174
+ //
175
+ // BEST-EFFORT, DELIBERATELY. A fire that ran and was ledgered in agents_runs has
176
+ // already happened and already cost money; failing it now because the roll-up
177
+ // could not be written would discard a completed fire's result to fix a reporting
178
+ // row. The direction of that trade is the opposite of the ceilings' — they fail
179
+ // CLOSED because they decide whether to spend, this fails OPEN because the spend
180
+ // is already past. What it must never do is stay silent: an unwritten mirror
181
+ // means the brake is under-counting, so the failure is logged with the run id.
182
+ async function mirrorCostToLedger({ runId, definition, costUsd, builderId = null }, deps = {}) {
183
+ if (!(typeof costUsd === 'number' && costUsd > 0)) return false;
184
+ const name = definition && definition.name;
185
+ if (!runId || typeof name !== 'string' || !name) return false;
186
+ const economy = deps.economy
187
+ || (typeof api.resolveOptional === 'function' ? api.resolveOptional('reward', null) : null);
188
+ if (!economy || typeof economy.logCost !== 'function') return false;
189
+ try {
190
+ await economy.logCost({
191
+ amountUsd: costUsd,
192
+ category: 'api',
193
+ source: costSourceTag(name),
194
+ sourceRef: `agent-run-${runId}`,
195
+ // Who ASKED, when anyone did. Not who PAYS — payer_kind lives on the
196
+ // agents_runs row and is that question's answer; cost_log's builder_id is
197
+ // per-builder attribution, and an event fire legitimately has neither.
198
+ builderId,
199
+ description: `agent ${name} — fire ${runId}`.slice(0, 1000),
200
+ });
201
+ return true;
202
+ } catch (e) {
203
+ (deps.log || log).warn({ run_id: runId, agent: name, err: (e && e.message) || String(e) },
204
+ 'cost mirror failed — agents_runs still has the cost, but the autonomous MTD brake is under-counting by it');
205
+ return false;
206
+ }
207
+ }
208
+
142
209
  async function finishRun(pool, id, patch) {
143
210
  const cols = Object.keys(patch);
144
211
  const set = cols.map((c, i) => `${c} = $${i + 2}`).join(', ');
@@ -218,6 +285,11 @@ async function spawnAgent({
218
285
  outputRef: ok ? (deps.outputRef || (res && res.output_ref) || null) : null,
219
286
  });
220
287
  await finishRun(pool, runId, patch);
288
+ // AFTER the ledger write, never before: the mirror projects a cost that is
289
+ // already recorded, so a row in cost_log with no agents_runs row behind it
290
+ // must not be possible. A failed or zero-cost fire mirrors nothing — the
291
+ // guard is inside mirrorCostToLedger so both call shapes share it.
292
+ await mirrorCostToLedger({ runId, definition, costUsd, builderId: requestedByBuilderId }, deps);
221
293
  return { ok, ran: true, runId, model, cost_usd: costUsd, duration_ms: durationMs };
222
294
  } catch (e) {
223
295
  const durationMs = Math.max(0, now() - started);
@@ -228,4 +300,4 @@ async function spawnAgent({
228
300
  }
229
301
  }
230
302
 
231
- module.exports = { spawnAgent, resolveModel, plannedRow, completedRow, composePrompt };
303
+ module.exports = { spawnAgent, resolveModel, plannedRow, completedRow, composePrompt, mirrorCostToLedger, costSourceTag };
@@ -429,6 +429,12 @@ module.exports = function buildCopyDeskRouter() {
429
429
  // opinion mint claimable work.
430
430
  status: 'backlog',
431
431
  kind: 'feature',
432
+ // task 1002988: named explicitly rather than inferred. A copy patch is
433
+ // reader-facing wording, which is artist work — cascade.js already treats
434
+ // `source: 'copy-proposal'` as an artist surface (hasCopyOrVisualWork), so
435
+ // inferring it from the patch text would risk the two disagreeing about
436
+ // the same row.
437
+ discipline: 'artist',
432
438
  priority: 4,
433
439
  touches: [patch.file],
434
440
  source: 'copy-proposal',
@@ -46,6 +46,17 @@ const COST_WRITE_SURFACES = [
46
46
  file: 'scripts/gds/session-cost.js',
47
47
  builder_id: 'the authed builder; category=api, source_ref=<session-id>, task_id=null. Idempotent per session.',
48
48
  },
49
+ {
50
+ // task 1002499: an agent fire mirrors its cost here so the autonomous MTD
51
+ // spend-brake can see it. agents_runs.cost_usd stays the accounting; this is
52
+ // the project-wide roll-up, written once per fire after the cost is final and
53
+ // keyed source_ref=agent-run-<id> so a retry cannot double-post. It reaches
54
+ // logCost through the economy PORT (ADR 0083) — still one writer, which is the
55
+ // property this list exists to state.
56
+ surface: 'modules/agents/spawn.js (fire mirror) → reward port → db.logCost() (source=agent:<name>)',
57
+ file: 'modules/agents/spawn.js',
58
+ builder_id: 'whoever REQUESTED the fire, or null for an event fire, which nobody asked for. Not the PAYER — that is payer_kind on the agents_runs row, a different question.',
59
+ },
49
60
  {
50
61
  surface: 'migrations / direct DB',
51
62
  file: '—',
@@ -55,16 +55,34 @@ async function getBuilderCostStatus(builderId) {
55
55
  return classifyCostStatus(row.spend_mtd_usd, row.budget_usd);
56
56
  }
57
57
 
58
- // System 3 (task 1351): project-wide AUTONOMOUS api spend month-to-date — the
59
- // `cron:%`-sourced rows (System 1 / task 1344 tags cron-launched sessions). The
58
+ // System 3 (task 1351): project-wide AUTONOMOUS api spend month-to-date. The
60
59
  // autonomy precheck's hard spend-brake compares this against a ceiling so a
61
60
  // runaway autonomous fleet self-throttles. Returns a plain number (USD).
61
+ //
62
+ // TWO SOURCE FAMILIES, because there are two ways this project spends money with
63
+ // nobody watching (task 1002499):
64
+ // cron:% — cron-launched SESSIONS (System 1 / task 1344).
65
+ // agent:% — agent fires (modules/agents). Each one is a `claude -p` the server
66
+ // spawns on its own, and POST /agents/:name/invoke answers 202
67
+ // without waiting, so the caller is not present for the spend either.
68
+ //
69
+ // The agent family was the hole this closes. Agent fires recorded their cost in
70
+ // `agents_runs.cost_usd` and nowhere else, so the brake — which reads cost_log —
71
+ // could not see a dollar of it: the per-payer and per-agent ceilings in
72
+ // modules/agents/lib/spend-ceiling.js bounded each wallet and each definition,
73
+ // while the fleet-wide brake they sit under was measuring only half the fleet.
74
+ //
75
+ // THE PREFIX MATCH IS THE CONTRACT, not a convenience. `agent:<name>` is written
76
+ // by exactly one place (the mirror in modules/agents/spawn.js) and the shape is
77
+ // asserted there, so widening the brake to a family rather than a list means a
78
+ // new agent is inside the ceiling the day it is authored, with nothing to
79
+ // remember to add here.
62
80
  async function autonomousApiSpendMtd() {
63
81
  const { rows } = await pool.query(
64
82
  `SELECT COALESCE(SUM(amount_usd), 0) AS usd
65
83
  FROM cost_log
66
84
  WHERE category = 'api'
67
- AND source LIKE 'cron:%'
85
+ AND (source LIKE 'cron:%' OR source LIKE 'agent:%')
68
86
  AND recorded_at >= date_trunc('month', now())`
69
87
  );
70
88
  return Number(rows[0]?.usd) || 0;
@@ -67,6 +67,13 @@ module.exports = {
67
67
  setBuilderMonthlyBudget: (...a) => cost.setBuilderMonthlyBudget(...a),
68
68
  reconcileSessionCosts: (...a) => cost.reconcileSessionCosts(...a),
69
69
  autonomousApiSpendMtd: (...a) => cost.autonomousApiSpendMtd(...a),
70
+ // The one WRITE the agents module needs (task 1002499). cost_log is economy's
71
+ // table and modules may only cross the wall through a port (ADR 0083 / 0093 §2),
72
+ // so an agent fire mirrors its cost through here rather than by importing cost.js
73
+ // or writing the INSERT itself. It is the same logCost every other writer uses —
74
+ // not an agent-shaped variant — so there is still exactly one writer, which is
75
+ // the property modules/economy/cost-classify.js's COST_WRITE_SURFACES enumerates.
76
+ logCost: (...a) => cost.logCost(...a),
70
77
  leaderboard: (...a) => credits.leaderboard(...a),
71
78
  getLeaderboardWithAchievements: (...a) => achievements.getLeaderboardWithAchievements(...a),
72
79
  listAchievements: (...a) => achievements.listAchievements(...a),
@@ -18,8 +18,8 @@
18
18
  ],
19
19
  "ignoreConsole": [
20
20
  "/api/(gds|bongos)/",
21
- "\b401\b",
22
- "\b404\b",
21
+ "\\b401\\b",
22
+ "\\b404\\b",
23
23
  "Unauthorized",
24
24
  "Not Found",
25
25
  "load failed"
@@ -5,7 +5,7 @@
5
5
  "stub": { "prefix": "/builders/" },
6
6
  "modeQuery": false,
7
7
  "ignoreRequests": ["/api/(gds|bongos)/"],
8
- "ignoreConsole": ["/api/(gds|bongos)/", "\b401\b", "\b404\b", "Unauthorized", "Not Found", "load failed"],
8
+ "ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
9
9
  "states": {
10
10
  "record": { "auth": true, "actions": [["wait", 600]], "expect": { "visible": ["#idea-body", "#idea-h"] } },
11
11
  "missing": { "auth": true, "url": "/idea/999999", "actions": [["wait", 600]], "expect": { "visible": ["#idea-h"] } }
@@ -6,7 +6,7 @@
6
6
  "modeQuery": false,
7
7
  "ignoreRequests": ["/api/(gds|bongos)/"],
8
8
  "_console": "The 501 is the harness, not the page: the meter re-scores through POST /inbox/score on every keystroke and scripts/hall-preview/server.js answers every write with preview_readonly by design. Declared rather than hidden.",
9
- "ignoreConsole": ["/api/(gds|bongos)/", "\b401\b", "\b404\b", "\b501\b", "Unauthorized", "Not Found", "Not Implemented", "load failed"],
9
+ "ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "\\b501\\b", "Unauthorized", "Not Found", "Not Implemented", "load failed"],
10
10
  "states": {
11
11
  "compose": { "auth": true, "actions": [["wait", 700]], "expect": { "visible": ["#fi-form", "#fi-fields", "#fi-queue"] } },
12
12
  "develop": { "auth": true, "url": "/ideas?develop=1000884", "actions": [["wait", 700]], "expect": { "visible": ["#fi-develop"] } }
@@ -6,7 +6,7 @@
6
6
  "modeQuery": false,
7
7
  "_feeds": "As work.states.json: the kit's stub is not the hall's feed set, so Home renders its skeletons and empty states here. The populated dashboard is the hall-preview harness.",
8
8
  "ignoreRequests": ["/api/(gds|bongos)/me$", "/api/(gds|bongos)/me/", "/api/(gds|bongos)/tasks", "/api/(gds|bongos)/claims", "/api/(gds|bongos)/goals", "/api/(gds|bongos)/inbox", "/api/(gds|bongos)/live", "/api/(gds|bongos)/analytics/", "/api/(gds|bongos)/achievements", "/api/(gds|bongos)/versions", "/api/(gds|bongos)/leaderboard"],
9
- "ignoreConsole": ["/api/(gds|bongos)/", "\b401\b", "\b404\b", "Unauthorized", "Not Found", "load failed"],
9
+ "ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
10
10
  "_1003441": "The `leaderboard` state left with the view: the Roll is the /roster page now and #leaderboard REDIRECTS there, so there is no #view-leaderboard on this page to make visible. The redirect is proven in tests/hall_nav.mjs (it asserts location.replace('/roster') fires on the index and nowhere else) rather than by a render state, because a state whose page navigates away measures the destination, not this page. `standing-karma` replaces the coverage: task 2178 made each Standing rail section addressable and none had a measured state.",
11
11
  "states": {
12
12
  "home": { "auth": true, "expect": { "visible": ["#view-home"], "hidden": ["#view-standing"] } },
@@ -5,7 +5,7 @@
5
5
  "stub": { "prefix": "/builders/" },
6
6
  "modeQuery": false,
7
7
  "ignoreRequests": ["/api/(gds|bongos)/"],
8
- "ignoreConsole": ["/api/(gds|bongos)/", "\b401\b", "\b404\b", "Unauthorized", "Not Found", "load failed"],
8
+ "ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
9
9
  "states": {
10
10
  "request": { "auth": true, "url": "/pair?code=K7QD-4M2X", "actions": [["wait", 700]], "expect": { "visible": ["#pair-h1", "#pair-approve-btn"] } },
11
11
  "nocode": { "auth": true, "actions": [["wait", 700]], "expect": { "visible": ["#pair-h1", "#pair-scroll .pair-status--err"] } }
@@ -5,7 +5,7 @@
5
5
  "stub": { "prefix": "/builders/" },
6
6
  "modeQuery": false,
7
7
  "ignoreRequests": ["/api/(gds|bongos)/", "/downloads/devbox/"],
8
- "ignoreConsole": ["/api/(gds|bongos)/", "/downloads/devbox/", "\b401\b", "\b404\b", "Unauthorized", "Not Found", "load failed", "Password field"],
8
+ "ignoreConsole": ["/api/(gds|bongos)/", "/downloads/devbox/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed", "Password field"],
9
9
  "states": {
10
10
  "account": { "auth": true, "actions": [["wait", 900]], "expect": { "visible": ["#settings-h1", "#display-name-input", "#craft-chips", "#privacy-rows"] } },
11
11
  "access": { "auth": true, "url": "/settings#access", "actions": [["wait", 900]], "expect": { "visible": ["#cli-reissue-btn", "#sessions-list", "#artkey-card"] } },
@@ -5,7 +5,7 @@
5
5
  "stub": { "prefix": "/builders/" },
6
6
  "modeQuery": false,
7
7
  "ignoreRequests": ["/api/(gds|bongos)/"],
8
- "ignoreConsole": ["/api/(gds|bongos)/", "\b401\b", "\b404\b", "Unauthorized", "Not Found", "load failed"],
8
+ "ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
9
9
  "states": {
10
10
  "record": { "auth": true, "actions": [["wait", 600]], "expect": { "visible": ["#task-body", "#task-grade", "#task-sessions", "#copy-prompt-btn"] } },
11
11
  "from-goal": { "auth": true, "url": "/task/2166?from=%2Fgoals%23%2Fgoal%2F1000081", "actions": [["wait", 600]], "expect": { "visible": ["#task-back", "#copy-prompt-btn"], "attr": [["#task-back", "href", "/goals#/goal/1000081"]] } },
@@ -6,7 +6,7 @@
6
6
  "modeQuery": false,
7
7
  "_feeds": "The kit's stub carries a small fixture set, not the hall's task feeds, so the board renders its EMPTY and SKELETON states here — which is the point: an empty queue is a state the floors must hold in. The feed 404s are declared, never hidden.",
8
8
  "ignoreRequests": ["/api/(gds|bongos)/me$", "/api/(gds|bongos)/me/", "/api/(gds|bongos)/tasks", "/api/(gds|bongos)/claims", "/api/(gds|bongos)/goals", "/api/(gds|bongos)/inbox/", "/api/(gds|bongos)/versions"],
9
- "ignoreConsole": ["/api/(gds|bongos)/", "\b401\b", "\b404\b", "Unauthorized", "Not Found", "load failed"],
9
+ "ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
10
10
  "states": {
11
11
  "ready": { "auth": true, "expect": { "visible": ["#claim-scroll"], "hidden": ["#wip-scroll", "#backlog-scroll", "#history-scroll"] } },
12
12
  "in-progress": { "auth": true, "url": "/builders/work#in-progress", "actions": [["wait", 400]], "expect": { "visible": ["#wip-scroll"] } },
@@ -405,9 +405,11 @@ async function routeIdeaToGoal({
405
405
  // own floor and takes the HIGHER of the two, so this can raise the bar and
406
406
  // never lower it. Deliberately NOT passed at all: `creditsReward` (server
407
407
  // default — createTask auto-assigns the capped suggestion) and
408
- // `discipline` (left 'unclassified', exactly as the other non-authored
409
- // create vectors leave it).
408
+ // `discipline` — NO LONGER 'unclassified' (task 1002988). createTask refuses a
409
+ // craft it cannot infer; refusing a filer is the wrong trade for an INBOX, so we
410
+ // ask the same question and fall back to 'engineer' (free to re-triage; a lost capture is not).
410
411
  const task = await lifecycle.createTask({
412
+ discipline: (lifecycle.resolveWriteDiscipline?.({ title, description: bodyMd, touches: declaredTouches })?.discipline) || 'engineer',
411
413
  versionId: goal.version_id,
412
414
  title,
413
415
  description: bodyMd,
@@ -18,6 +18,7 @@ const { matchProtected } = api.permissionPathCheck;
18
18
  const { decorateGoalMembership, capNewcomerTasks } = require('./claim-feed.js');
19
19
  const { deriveRequiredRank, highestRank } = require('./db-rank-authz.js');
20
20
  const { executorFor } = require('./db-executor.js');
21
+ const { resolveWriteDiscipline, WRITABLE_DISCIPLINES } = require('./task-classifier.js');
21
22
 
22
23
  // -------------------------------------------------------------------------
23
24
  // Tasks
@@ -123,7 +124,13 @@ async function createTask({
123
124
  sourceRef = null,
124
125
  valueSummary = null,
125
126
  kind = 'unclassified',
126
- discipline = 'unclassified',
127
+ // task 1002988 — NOT defaulted to 'unclassified' any more. This is the single
128
+ // chokepoint every create vector passes through, including the ones that never
129
+ // touch an HTTP route (idea promotion, copy-desk, newcomer-restock, kickoff-seed,
130
+ // cascade), so resolving here is what makes "no row is born role-less" true of
131
+ // the whole path rather than of the two routes. Resolved just below; `undefined`
132
+ // means "work it out from the text".
133
+ discipline = undefined,
127
134
  parallelSafe = null,
128
135
  newcomerFriendly = false,
129
136
  needsMigration = false,
@@ -198,6 +205,25 @@ async function createTask({
198
205
  }
199
206
  goalIdEffective = await generalGoalIdForVersion(versionId);
200
207
  }
208
+
209
+ // task 1002988 (owner decision 2026-09-17): every row is born with a craft.
210
+ // An absent or 'unclassified' discipline is INFERRED from title + description +
211
+ // touches; a task whose text says nothing about its craft is REFUSED rather than
212
+ // filed under a word that means "nobody looked". Throwing here rather than
213
+ // silently defaulting is the point — 1,189 role-less rows accumulated precisely
214
+ // because the default was quiet. A caller that cannot tolerate a refusal passes
215
+ // an explicit discipline, which is then visible at its call site instead of
216
+ // hidden in this signature.
217
+ const resolvedDiscipline = resolveWriteDiscipline({ discipline, title, description, touches });
218
+ if (resolvedDiscipline.error) {
219
+ const err = new Error(resolvedDiscipline.unclassifiable
220
+ ? `Cannot infer a discipline for "${String(title).slice(0, 80)}" from its title and description. Pass discipline explicitly: ${Array.from(WRITABLE_DISCIPLINES).join(' | ')}.`
221
+ : `discipline must be one of ${Array.from(WRITABLE_DISCIPLINES).join(' | ')} (got ${JSON.stringify(discipline)}).`);
222
+ err.code = 'BAD_DISCIPLINE';
223
+ err.unclassifiable = resolvedDiscipline.unclassifiable === true;
224
+ throw err;
225
+ }
226
+ const disciplineEffective = resolvedDiscipline.discipline;
201
227
  // task 1498 / ADR 0084: every task carries a deliberate rank floor. Derive it,
202
228
  // then let an explicit requiresRank override only UPWARD (highestRank) — a
203
229
  // requested rank below the derived floor is clamped up to the floor
@@ -247,7 +273,7 @@ async function createTask({
247
273
  sourceRef,
248
274
  valueSummary,
249
275
  kind,
250
- discipline,
276
+ disciplineEffective,
251
277
  parallelSafe,
252
278
  newcomerFriendly === true,
253
279
  needsMigration === true,
@@ -37,6 +37,12 @@ module.exports = {
37
37
  // (consumer: core cascade-dispatch.js, the dormant System-3 lib).
38
38
  get VOTABLE_TASK_STATUSES() { return classifier.VOTABLE_TASK_STATUSES; },
39
39
  classifyTaskKind: (...a) => classifier.classifyTaskKind(...a),
40
+ // task 1002988: createTask now REFUSES a task whose craft it cannot work out, so
41
+ // a consumer that must not fail (idea capture, copy-desk) needs to ask the same
42
+ // question first and name its own fallback. Exposed on the port because a module
43
+ // may not require another module — the boundary rule — and a second copy of this
44
+ // heuristic living in modules/ideas is exactly the drift this port exists to stop.
45
+ resolveWriteDiscipline: (...a) => classifier.resolveWriteDiscipline(...a),
40
46
  // TASK_KINDS: the kind vocabulary, as an ordered array (consumer: GET /me,
41
47
  // which hands it to the hall so a filter can be BUILT from it instead of
42
48
  // retyped — task 1003453). VALID_KINDS is a Set and Sets do not survive
@@ -31,7 +31,7 @@ const goalEdits = require('../goal-edits.js'); // task 1003102 — goal self-edi
31
31
  const goalCategory = require('../goal-category.js'); // task 1003275 — the ADVISORY human category (gates nothing)
32
32
  const goalRollup = require('../goal-rollup.js'); // task 1003278 — per-goal rollups computed in SQL, not in the browser
33
33
  const { suggestCriteria } = require('../criterion-suggest');
34
- const { classifyTaskKind, VALID_KINDS, VALID_DISCIPLINES, TASK_FIELD_BOUNDS } = require('../task-classifier');
34
+ const { classifyTaskKind, VALID_KINDS, WRITABLE_DISCIPLINES, resolveWriteDiscipline, TASK_FIELD_BOUNDS } = require('../task-classifier');
35
35
  const { validateOrRespond, parseId, LIMITS } = api;
36
36
  const { isModuleKey, scopeIncludesProtected, moduleKeys } = api.moduleScopeMap;
37
37
  // goalScopeCheck — the PURE scope wall (BV1.R59 / ADR 0086 §3), reached through
@@ -1388,10 +1388,23 @@ module.exports = function buildGoalsRouter() {
1388
1388
  const result = classifyTaskKind(body.title, body.description || '');
1389
1389
  kind = result.confidence >= 0.5 ? result.kind : 'unclassified';
1390
1390
  }
1391
- const discipline = body.discipline ?? 'unclassified';
1392
- if (!VALID_DISCIPLINES.has(discipline)) {
1393
- return res.fail('bad_discipline', 400, { valid: Array.from(VALID_DISCIPLINES) });
1391
+ // task 1002988 — the SECOND create path. It carried the same
1392
+ // `body.discipline ?? 'unclassified'` line as POST /tasks, so a fix applied
1393
+ // only there would have left this one minting the rows the task exists to
1394
+ // remove. Both now share resolveWriteDiscipline().
1395
+ const resolvedDiscipline = resolveWriteDiscipline({
1396
+ discipline: body.discipline, title: body.title, description: body.description, touches: body.touches,
1397
+ });
1398
+ if (resolvedDiscipline.error) {
1399
+ return res.fail('bad_discipline', {
1400
+ status: 400,
1401
+ message: resolvedDiscipline.unclassifiable
1402
+ ? `discipline was not supplied and could not be inferred from the title and description. Pass one of ${Array.from(WRITABLE_DISCIPLINES).join(' | ')}.`
1403
+ : `discipline must be one of ${Array.from(WRITABLE_DISCIPLINES).join(' | ')}.`,
1404
+ details: { valid: Array.from(WRITABLE_DISCIPLINES) },
1405
+ });
1394
1406
  }
1407
+ const discipline = resolvedDiscipline.discipline;
1395
1408
 
1396
1409
  // Create. version = the goal's (not client-supplied); status 'ready' so the
1397
1410
  // author can claim it immediately; requiresRank OMITTED → auto-derived
@@ -22,7 +22,7 @@ const db = require('../db');
22
22
  const hierarchy = require('../hierarchy');
23
23
  const { suggestCriteria } = require('../criterion-suggest');
24
24
  const { buildGoalAdvisory } = require('../goal-advisory');
25
- const { classifyTaskKind, VALID_KINDS, VALID_DISCIPLINES, VOTABLE_TASK_STATUSES, TASK_FIELD_BOUNDS } = require('../task-classifier');
25
+ const { classifyTaskKind, VALID_KINDS, WRITABLE_DISCIPLINES, resolveWriteDiscipline, VOTABLE_TASK_STATUSES, TASK_FIELD_BOUNDS } = require('../task-classifier');
26
26
  const { isModuleKey, moduleKeys } = api.moduleScopeMap;
27
27
  const { validateOrRespond, LIMITS, parseId } = api;
28
28
  const { badKind, detectsMigration } = require('./task-route-helpers.js');
@@ -141,10 +141,22 @@ module.exports = function registerTaskWriteRoutes(router) {
141
141
  const result = classifyTaskKind(body.title, body.description || '');
142
142
  kind = result.confidence >= 0.5 ? result.kind : 'unclassified';
143
143
  }
144
- const discipline = body.discipline ?? 'unclassified';
145
- if (!VALID_DISCIPLINES.has(discipline)) {
146
- return res.fail('bad_discipline', 400, { valid: Array.from(VALID_DISCIPLINES) });
144
+ // task 1002988: a create must never mint a role-less row. An omitted (or
145
+ // explicitly 'unclassified') discipline is INFERRED from the text; only a
146
+ // genuinely unclassifiable task is refused.
147
+ const resolved = resolveWriteDiscipline({
148
+ discipline: body.discipline, title: body.title, description: body.description, touches: body.touches,
149
+ });
150
+ if (resolved.error) {
151
+ return res.fail('bad_discipline', {
152
+ status: 400,
153
+ message: resolved.unclassifiable
154
+ ? `discipline was not supplied and could not be inferred from the title and description. Pass one of ${Array.from(WRITABLE_DISCIPLINES).join(' | ')}.`
155
+ : `discipline must be one of ${Array.from(WRITABLE_DISCIPLINES).join(' | ')}.`,
156
+ details: { valid: Array.from(WRITABLE_DISCIPLINES), inferred: false },
157
+ });
147
158
  }
159
+ const discipline = resolved.discipline;
148
160
  // task 1498 / ADR 0084: an explicit requires_rank must be a live rank. The
149
161
  // floor auto-derives from touches + security_sensitive; createTask clamps
150
162
  // any explicit value UP to that floor (never below), so this only rejects a
@@ -496,8 +508,15 @@ module.exports = function registerTaskWriteRoutes(router) {
496
508
  if (hasKind && !VALID_KINDS.has(body.kind)) {
497
509
  return badKind(res);
498
510
  }
499
- if (hasDiscipline && !VALID_DISCIPLINES.has(body.discipline)) {
500
- return res.fail('bad_discipline', 400, { valid: Array.from(VALID_DISCIPLINES) });
511
+ // task 1002988: a PATCH may not push a row BACK to 'unclassified' — that is a
512
+ // triage state, not a craft, and letting an edit re-create one would reopen the
513
+ // hole the create path above just closed.
514
+ if (hasDiscipline && !WRITABLE_DISCIPLINES.has(body.discipline)) {
515
+ return res.fail('bad_discipline', {
516
+ status: 400,
517
+ message: `discipline must be one of ${Array.from(WRITABLE_DISCIPLINES).join(' | ')}.`,
518
+ details: { valid: Array.from(WRITABLE_DISCIPLINES) },
519
+ });
501
520
  }
502
521
  if (hasNewcomerOrXenos) {
503
522
  // V3.R15 #239: prefer newcomer_friendly (canonical); fall back to