@bongos/core 1.19.316 → 1.19.317

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,810 @@
1
+ // modules/lifecycle/db-tasks.js — task CRUD, the reward gate, the claimable feed + task reads.
2
+ //
3
+ // Carved out of modules/lifecycle/db.js by task 1003204 (goal 1000079, the R03
4
+ // de-monolith plan) — a STRUCTURAL move, not a rewrite: the code below is the
5
+ // original db.js lines 1374-2132 unchanged. db.js remains the facade that
6
+ // re-exports every name, so every existing db call site is unchanged.
7
+ //
8
+ // THE BOUNDARY (ADR 0093 §1): this file reaches core ONLY through the published
9
+ // doorway ../../src/module-api.js — never a core internal, never a sibling module.
10
+
11
+ 'use strict';
12
+
13
+ const api = require('../../src/module-api');
14
+ const { pool } = api;
15
+ const log = api.logger('lifecycle');
16
+ const { generalGoalTitle } = require('./goal-advisory.js');
17
+ const { matchProtected } = api.permissionPathCheck;
18
+ const { decorateGoalMembership, capNewcomerTasks } = require('./claim-feed.js');
19
+ const { deriveRequiredRank, highestRank } = require('./db-rank-authz.js');
20
+
21
+ // -------------------------------------------------------------------------
22
+ // Tasks
23
+ // -------------------------------------------------------------------------
24
+
25
+
26
+ // task 1763: resolve a version's catch-all "<version> — general" goal (created by
27
+ // migration 160, created_by NULL). This is the fallback goal a goal-less task
28
+ // defaults into (see createTask) so the "every task belongs to a goal" invariant
29
+ // holds for every create-vector. Returns the goal id, or null when the version has
30
+ // no general goal (then the caller leaves goal_id NULL rather than failing a
31
+ // create). Honors the #538 deps.pool test seam (NODE_ENV==='test') so it is
32
+ // unit-testable DB-free. The title mirrors migration 160's insert exactly (a spaced
33
+ // em-dash); it is built by ../goal-advisory.generalGoalTitle so this SQL and the
34
+ // advisory that detects the same goal can never disagree (task 1002539).
35
+ async function generalGoalIdForVersion(versionId, deps = {}) {
36
+ if (versionId == null) return null;
37
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
38
+ const { rows } = await activePool.query(
39
+ 'SELECT id FROM goals WHERE version_id = $1 AND title = $2 LIMIT 1',
40
+ [versionId, generalGoalTitle(versionId)]
41
+ );
42
+ return rows[0] ? rows[0].id : null;
43
+ }
44
+
45
+ async function createTask({
46
+ versionId,
47
+ title,
48
+ description = '',
49
+ status = 'backlog',
50
+ touches = [],
51
+ estMinutes = null,
52
+ estCostUsd = null,
53
+ manualDegree = null,
54
+ priority = null,
55
+ automationTag = null,
56
+ creditsReward = 0,
57
+ source = null,
58
+ sourceRef = null,
59
+ valueSummary = null,
60
+ kind = 'unclassified',
61
+ discipline = 'unclassified',
62
+ parallelSafe = null,
63
+ newcomerFriendly = false,
64
+ needsMigration = false,
65
+ securitySensitive = false,
66
+ requiresRank = null,
67
+ moduleKey = null,
68
+ // BV1.R60 / ADR 0086 §3: goal-authored tasks stamp the goal they were created
69
+ // under (POST /goals/:id/tasks). Nullable — every other create-vector (POST
70
+ // /tasks, capture, restock, discord-bugs, seeds) omits it and creates a
71
+ // goal-less task exactly as before. The rank floor still auto-derives above
72
+ // (requiresRank stays null for the goal vector → derived purely from touches +
73
+ // sensitivity), so goal_id never influences the requires_rank clamp.
74
+ goalId = null,
75
+ }, deps = {}) { // deps.client enlists this INSERT in the CALLER'S transaction (task 1003065, BV1.R01); the argument for it is at modules/ideas/inbox.js routeIdeaToGoal
76
+ // task 1498 / ADR 0085: every task carries a deliberate rank floor. Derive it
77
+ // from touches + sensitivity, then let an explicit requiresRank override only
78
+ // UPWARD (highestRank) — a requested rank below the derived floor is clamped
79
+ // up to the floor (fail-closed; the floor mirrors the trust-boundary paths a
80
+ // sub-Metic builder cannot push to anyway).
81
+ const requiresRankEffective = highestRank(
82
+ deriveRequiredRank(touches, securitySensitive),
83
+ requiresRank ?? 'xenos'
84
+ );
85
+ // task 1677 / ADR 0096 (2026-06-28): creation is now the PRIMARY reward-assignment
86
+ // point — every task is born with a fair drachmae value, not just at promote/claim.
87
+ // When the caller supplies NO positive credits_reward (null/undefined/0), auto-assign
88
+ // the capped (<=REWARD_SUGGESTION_CAP) suggestion from kind + est_minutes here, at the
89
+ // lowest create layer, so EVERY create-vector (POST /tasks, idea-promotion, capture,
90
+ // newcomer-restock, discord-bugs, seed scripts) inherits creation-time assignment.
91
+ // An explicitly-provided POSITIVE value is respected (author-set, never overridden).
92
+ // The promote/claim auto-assign (tasks 1670/1673) stays as a backstop for any legacy
93
+ // or edge path that still births a 0-reward row. Cost-plus session reward (ADR 0054)
94
+ // remains the ungameable primary pay driver; this per-task value is a capped floor.
95
+ let creditsRewardEffective = creditsReward;
96
+ if (rewardMissing(creditsRewardEffective)) {
97
+ creditsRewardEffective = suggestCreditsReward({ est_minutes: estMinutes, kind });
98
+ }
99
+ // task 1763: every task belongs to a goal. The goal-authored vector (POST
100
+ // /goals/:id/tasks) passes goalId; every OTHER vector (POST /tasks, capture,
101
+ // newcomer-restock, discord-bugs, seeds) omits it — so default a goal-less task
102
+ // into its version's catch-all "<version> — general" goal here, at the lowest
103
+ // create layer, so the invariant holds for all of them without touching each
104
+ // caller. If the version has no general goal the fallback is null and the task
105
+ // is created goal-less exactly as before — a creator must never be broken.
106
+ let goalIdEffective = goalId;
107
+ if (goalIdEffective == null) {
108
+ goalIdEffective = await generalGoalIdForVersion(versionId);
109
+ }
110
+ const { rows } = await (deps.client || pool).query(
111
+ `INSERT INTO tasks (version_id, title, description, status, touches,
112
+ est_minutes, est_cost_usd, manual_degree, priority,
113
+ automation_tag, credits_reward, source, source_ref, value_summary,
114
+ kind, discipline, parallel_safe, newcomer_friendly, needs_migration,
115
+ security_sensitive, requires_rank, module_key, goal_id, promoted_at)
116
+ VALUES ($1, $2, $3, $4, $5,
117
+ $6, $7, $8, $9,
118
+ $10, $11, $12, $13, $14,
119
+ $15, $16, $17, $18, $19,
120
+ $20, $21, $22, $23,
121
+ CASE WHEN $4 IN ('ready','active') THEN now() ELSE NULL END)
122
+ RETURNING *`,
123
+ [
124
+ versionId,
125
+ title,
126
+ description,
127
+ status,
128
+ touches,
129
+ estMinutes,
130
+ estCostUsd,
131
+ manualDegree,
132
+ priority,
133
+ automationTag,
134
+ creditsRewardEffective,
135
+ source,
136
+ sourceRef,
137
+ valueSummary,
138
+ kind,
139
+ discipline,
140
+ parallelSafe,
141
+ newcomerFriendly === true,
142
+ needsMigration === true,
143
+ securitySensitive === true,
144
+ requiresRankEffective,
145
+ moduleKey ?? null,
146
+ goalIdEffective ?? null,
147
+ ]
148
+ );
149
+ // task 1677 / ADR 0096: audit each creation-time auto-assignment so the trail is
150
+ // complete (the visible credits_reward on the row is the other half). Only logs
151
+ // when we actually filled an unset reward — an explicit positive value is silent.
152
+ if (rewardMissing(creditsReward) && rows[0]) {
153
+ console.log(
154
+ `[reward-gate] AUTO-ASSIGN task ${rows[0].id} credits_reward -> ${creditsRewardEffective} (cap ${REWARD_SUGGESTION_CAP}) [creation]`
155
+ );
156
+ }
157
+ return rows[0];
158
+ }
159
+
160
+ // task 386 (idea #85): app-level idempotency for the POST /tasks create-vector.
161
+ // Returns the EARLIEST existing task carrying this (source, source_ref) pair, or
162
+ // null when either is absent. Keyed on (source, source_ref) — the exact pair
163
+ // cost_log already dedups on (see logCost: `ON CONFLICT (source, source_ref)`) —
164
+ // so a re-submitted create (a retried POST, a capture re-run) resolves to the
165
+ // original row instead of silently minting a duplicate.
166
+ //
167
+ // Deliberately NOT a DB unique constraint: the tasks table already carries
168
+ // historical (source, source_ref) duplicates (the very bug this closes — e.g.
169
+ // #248 dup of #252), so `CREATE UNIQUE INDEX` would fail to build on deploy. The
170
+ // Archon-only, low-frequency POST /tasks vector makes the SELECT-then-insert race
171
+ // window irrelevant. The other create-vectors (newcomer-restock, triage
172
+ // promotion, discord-bugs) call createTask directly and own their own dedup, so
173
+ // route-level idempotency leaves them untouched. #538 test seam mirrored.
174
+
175
+ async function findTaskBySourceRef({ source, sourceRef } = {}, deps = {}) {
176
+ if (source == null || sourceRef == null || sourceRef === '') return null;
177
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
178
+ const { rows } = await activePool.query(
179
+ `SELECT * FROM tasks
180
+ WHERE source = $1 AND source_ref = $2
181
+ ORDER BY id ASC
182
+ LIMIT 1`,
183
+ [source, sourceRef]
184
+ );
185
+ return rows[0] ?? null;
186
+ }
187
+
188
+ // newcomerInstanceStats (the evergreen-chore supply read) carved into
189
+ // modules/onboarding/db.js (BV1.R81 / ADR 0093 §1) — it backs the onboarding
190
+ // module's newcomer-restock and is part of that domain's data layer.
191
+
192
+ // updateTaskColumn (task 1086, C2) — the single-column task setters below were
193
+ // byte-identical except for the column name and (security_sensitive) a boolean
194
+ // coercion. One allowlisted helper backs them all. The ALLOWLIST gates the
195
+ // interpolated column name as defense-in-depth: callers pass a hard-coded literal,
196
+ // never user input, so this can never become SQL injection.
197
+
198
+ const TASK_SETTER_COLUMNS = new Set([
199
+ 'kind', 'discipline', 'newcomer_friendly', 'security_sensitive', 'parallel_safe', 'automation_tag',
200
+ 'needs_migration', 'module_key', 'description',
201
+ ]);
202
+
203
+ async function updateTaskColumn(id, column, value) {
204
+ if (!TASK_SETTER_COLUMNS.has(column)) {
205
+ throw new Error(`updateTaskColumn: column "${column}" is not an allowed task setter`);
206
+ }
207
+ const { rows } = await pool.query(
208
+ `UPDATE tasks SET ${column} = $1, updated_at = now() WHERE id = $2 RETURNING *`,
209
+ [value, id]
210
+ );
211
+ return rows[0] ?? null;
212
+ }
213
+
214
+
215
+ // task 1763: (re)assign a task's goal_id. Deliberately NOT a TASK_SETTER_COLUMNS
216
+ // flag — goal_id is a FK the PATCH /tasks/:id route validates (goal exists + same
217
+ // version as the task) before calling this, so it stays a dedicated setter rather
218
+ // than a bare allowlisted column. updated_at bumps like the flag setters.
219
+ // createCriterion — add a done-when criterion to a version at RUNTIME (the
220
+ // POST /versions/:id/done-when Archon route). ADR 0105 moved scope-truth seeds
221
+ // out of the normal migration path, so a brand-new criterion on a running
222
+ // instance can't be added by a plain migration; this is the live vector a
223
+ // planning session uses. sort_order defaults to max(existing)+1 (mirrors the
224
+ // criterion-proposal ratify path). Throws 23505 on a duplicate (version_id,
225
+ // criterion_id) — the route maps that to 409.
226
+ async function createCriterion({ versionId, criterionId, criterionMd, goalId = null, sortOrder = null }) {
227
+ let order = sortOrder;
228
+ if (order == null) {
229
+ const { rows: maxRows } = await pool.query(
230
+ `SELECT COALESCE(MAX(sort_order), 0) + 1 AS next FROM done_when_criteria WHERE version_id = $1`,
231
+ [versionId]
232
+ );
233
+ order = maxRows[0].next;
234
+ }
235
+ const { rows } = await pool.query(
236
+ `INSERT INTO done_when_criteria (version_id, criterion_id, criterion_md, goal_id, sort_order)
237
+ VALUES ($1, $2, $3, $4, $5)
238
+ RETURNING id, version_id, criterion_id, criterion_md, goal_id, sort_order, satisfied`,
239
+ [versionId, criterionId, criterionMd, goalId, order]
240
+ );
241
+ return rows[0];
242
+ }
243
+
244
+ async function updateTaskGoal(id, goalId) {
245
+ const { rows } = await pool.query(
246
+ `UPDATE tasks SET goal_id = $1, updated_at = now() WHERE id = $2 RETURNING *`,
247
+ [goalId, id]
248
+ );
249
+ return rows[0] ?? null;
250
+ }
251
+
252
+
253
+ async function updateTaskKind(id, kind) { return updateTaskColumn(id, 'kind', kind); }
254
+
255
+ // task 1002647 (choke-point map s4.3): the task body is a load-bearing prompt —
256
+ // it was settable only at create, un-editable at every rank. Editable now under
257
+ // the same task.edit permission as the other planning fields; the global
258
+ // write-audit middleware captures each edit's new body in audit_log.
259
+ async function updateTaskDescription(id, description) { return updateTaskColumn(id, 'description', description); }
260
+
261
+
262
+ async function updateTaskDiscipline(id, discipline) { return updateTaskColumn(id, 'discipline', discipline); }
263
+
264
+ // #360 / ADR 0018 + V3.R15 #239: open (or close) a task to Xenos claims by
265
+ // marking it newcomer_friendly. Default false; an Archon sets it true to let
266
+ // the sandboxed entry rank claim+ship this task. (Column was renamed from
267
+ // xenos_claimable → newcomer_friendly in migration 039 — the property describes
268
+ // the *task*, not "who is allowed".) (The legacy updateTaskXenosClaimable alias
269
+ // was removed in the task-874 dead-code sweep — it had zero callers.)
270
+
271
+ async function updateTaskNewcomerFriendly(id, newcomerFriendly) { return updateTaskColumn(id, 'newcomer_friendly', newcomerFriendly); }
272
+
273
+ // task 990 / migration 084 — flag a task security-sensitive (or not). When set,
274
+ // its ship broadcast routes ONLY to the Archon-only #security-ship-feed channel
275
+ // (ship.js chooseBroadcastTargets), never the public #ship-news / #ship-feed.
276
+ // Explicit, never heuristic: a misfire leaks an unpatched exploit. Boolean only
277
+ // (the === true coercion below preserves the boolean-only contract).
278
+
279
+ async function updateTaskSecuritySensitive(id, securitySensitive) {
280
+ // task 1498 / ADR 0085: flagging a task security_sensitive RAISES its rank
281
+ // floor (a now-sensitive task must require Metic+). Recompute and ratchet
282
+ // requires_rank UP in the same atomic UPDATE so the two columns can't diverge;
283
+ // never lower it (an existing 'metic'/'archon' may be a protected-touch floor
284
+ // or a deliberate override — flipping the flag off doesn't drop the gate; an
285
+ // Archon can PATCH requires_rank down to the derived floor explicitly).
286
+ const sec = securitySensitive === true;
287
+ const { rows } = await pool.query(
288
+ `UPDATE tasks
289
+ SET security_sensitive = $1,
290
+ requires_rank = CASE WHEN $1 AND requires_rank IN ('xenos', 'thetes')
291
+ THEN 'metic' ELSE requires_rank END,
292
+ updated_at = now()
293
+ WHERE id = $2
294
+ RETURNING *`,
295
+ [sec, id]
296
+ );
297
+ return rows[0] ?? null;
298
+ }
299
+
300
+ // task 1498 / ADR 0085: set a task's rank floor explicitly (the Archon override).
301
+ // The DERIVED floor (touches + security_sensitive) is a HARD minimum — a request
302
+ // below it is clamped UP via highestRank, so an override can only RAISE the bar,
303
+ // never open a sensitive task to a lower rank. `requiresRank` is one live rank
304
+ // (xenos|thetes|metic|archon); the route validates the enum. Returns the updated
305
+ // row, or null when the task is missing.
306
+
307
+ async function updateTaskRequiresRank(id, requiresRank) {
308
+ const task = await getTask(id);
309
+ if (!task) return null;
310
+ const floor = deriveRequiredRank(task.touches, task.security_sensitive);
311
+ const effective = highestRank(floor, requiresRank ?? 'xenos');
312
+ const { rows } = await pool.query(
313
+ `UPDATE tasks SET requires_rank = $1, updated_at = now() WHERE id = $2 RETURNING *`,
314
+ [effective, id]
315
+ );
316
+ return rows[0] ?? null;
317
+ }
318
+
319
+ // ---------------------------------------------------------------------------
320
+ // task 1670 / ADR 0096 — require a credits_reward (drachmae) before a task is
321
+ // workable. credits_reward defaults to 0 (migration 003); historically a task
322
+ // could be promoted to 'ready' and claimed+shipped while paying 0 drachmae. The
323
+ // gate below makes a present, positive reward a precondition for the two
324
+ // workable transitions (promote-to-ready + claim). It is AUTHOR-SET, never
325
+ // auto-derived (ADR 0023 anti-gaming): we may SUGGEST a value, never apply it.
326
+ //
327
+ // Pure helpers so the route + the claim path + the unit tests all share one
328
+ // source of truth (the deriveRequiredRank/highestRank precedent).
329
+ // ---------------------------------------------------------------------------
330
+
331
+ // A "fully unrewarded" task is one whose credits_reward is NULL or <= 0.
332
+ function rewardMissing(creditsReward) {
333
+ return creditsReward === null || creditsReward === undefined || Number(creditsReward) <= 0;
334
+ }
335
+
336
+ // Suggest a starting drachmae value — derived from est_minutes scaled by a
337
+ // coarse per-kind weight (ADR 0023 kind multipliers). ~1 drachma/min of
338
+ // estimated work, floored at 5 so a tiny task still names a non-zero number,
339
+ // and CAPPED at REWARD_SUGGESTION_CAP.
340
+ //
341
+ // task 1673 / ADR 0096 amendment (2026-06-28): the gate shifted from BLOCK to
342
+ // AUTO-ASSIGN — this value is now PERSISTED onto the task at the promote/claim
343
+ // gate, not merely suggested. That reverses ADR 0023's "never auto-derive"
344
+ // stance for the *gate-time backstop only*, so the cap below is load-bearing
345
+ // anti-gaming: it bounds how much est-inflation can balloon an auto-assigned
346
+ // payout. The ungameable cost-plus session reward (ADR 0054) stays the PRIMARY
347
+ // pay driver; this per-task value is a modest, capped floor. Anyone wanting more
348
+ // than the cap must still set credits_reward by hand via PATCH /tasks/:id
349
+ // (author-set, review-gated — the ADR 0023 path is intact for explicit values).
350
+ const KIND_REWARD_WEIGHT = {
351
+ feature: 1.0, bug: 1.25, infra: 1.0, refactor: 0.85, spike: 0.75,
352
+ 'learning-capture': 0.75, cleanup: 0.6, decision: 1.0, 'blocker-resolution': 1.25,
353
+ unclassified: 1.0,
354
+ };
355
+ const REWARD_SUGGESTION_CAP = 60; // drachmae — anti-gaming ceiling on the auto-assigned value
356
+ function suggestCreditsReward({ est_minutes, kind } = {}) {
357
+ const mins = Number(est_minutes);
358
+ const base = Number.isFinite(mins) && mins > 0 ? mins : 30; // default ~30-min task
359
+ const weight = KIND_REWARD_WEIGHT[kind] ?? 1.0;
360
+ const raw = Math.max(5, Math.round(base * weight));
361
+ return Math.min(REWARD_SUGGESTION_CAP, raw); // capped so est-inflation can't balloon pay
362
+ }
363
+
364
+ // task 1673 / ADR 0096 amendment (2026-06-28): AUTO-ASSIGN at the gate instead
365
+ // of BLOCKING. The shared decision helper for the promote route + the claim
366
+ // backstop. Given a task, decide whether the reward gate needs to ACT:
367
+ // - returns { assign: N } when the task is unrewarded AND not grandfathered —
368
+ // the caller must persist credits_reward = N (the capped suggestion) and
369
+ // PROCEED (no refusal). Grandfathered/exempt rows that are still 0 also get
370
+ // a real value here, so the ~27 reward_gate_exempt tasks earn a fair payout
371
+ // when claimed.
372
+ // - returns null when the task already has a positive reward (nothing to do).
373
+ // `grandfathered` is retained for signature back-compat; it no longer suppresses
374
+ // the assignment (we WANT to value the grandfathered zero-reward tasks), so the
375
+ // reward_gate_exempt column is now inert — see the ADR amendment.
376
+ function rewardGateAssignment(task) {
377
+ if (!task) return null;
378
+ if (!rewardMissing(task.credits_reward)) return null;
379
+ return { assign: suggestCreditsReward(task) };
380
+ }
381
+
382
+ // Back-compat shim. The pre-1673 gate BLOCKED with this error object; callers and
383
+ // the unit tests referenced it. The gate now AUTO-ASSIGNS, so the wired callers
384
+ // no longer consult it — kept exported so any stale external reference resolves,
385
+ // returning null (no refusal) when a reward is present.
386
+ function rewardGateError(task, { grandfathered = false } = {}) {
387
+ if (!task) return null;
388
+ if (grandfathered || task.reward_gate_exempt === true) return null;
389
+ if (!rewardMissing(task.credits_reward)) return null;
390
+ const suggested = suggestCreditsReward(task);
391
+ return { code: 'REWARD_REQUIRED', suggested };
392
+ }
393
+
394
+ // Persist an auto-assigned credits_reward. Standalone (own pool query) variant
395
+ // for the promote route. Logs an AUDIT line so every auto-assignment is traceable
396
+ // (the visible credits_reward change is the other half of the audit trail).
397
+ async function setCreditsReward(id, value) {
398
+ const { rows } = await pool.query(
399
+ `UPDATE tasks SET credits_reward = $1, updated_at = now() WHERE id = $2 RETURNING id, credits_reward`,
400
+ [value, id]
401
+ );
402
+ console.log(`[reward-gate] AUTO-ASSIGN task ${id} credits_reward -> ${value} (cap ${REWARD_SUGGESTION_CAP}) [promote]`);
403
+ return rows[0] ?? null;
404
+ }
405
+
406
+ // migration 028 — flag a task safe (or not) to run alongside others in a
407
+ // parallel autonomous run. Accepts true | false | null (null = un-judged).
408
+
409
+ async function updateTaskParallelSafe(id, parallelSafe) { return updateTaskColumn(id, 'parallel_safe', parallelSafe); }
410
+
411
+ // Recategorize a task's automation_tag mid-life so the autonomous overnight
412
+ // runner's --fit filter (tasks_session_fit view, migration 028) can pick it
413
+ // up — or drop it. Accepts 'overnight-safe' | 'live-only' | 'needs-input' |
414
+ // null. Surfaced because at task-creation triage we are often conservative
415
+ // and leave the tag unset, then later judge that a task is safe to delegate.
416
+
417
+ async function updateTaskAutomationTag(id, automationTag) { return updateTaskColumn(id, 'automation_tag', automationTag); }
418
+
419
+ // task 1002648: correct the create-time migration heuristic's verdict — a false
420
+ // flag makes claiming reserve a migration number that goes unused (task 1002614
421
+ // burned 213 that way). Boolean only; the route validates.
422
+ async function updateTaskNeedsMigration(id, needsMigration) { return updateTaskColumn(id, 'needs_migration', needsMigration === true); }
423
+
424
+ // BV1.R53 (task 1422): assign (or clear) the module_key that declares which
425
+ // module this task's work belongs to. Drives module-disjointness at claim time
426
+ // and diff⊆module enforcement at ship time. Accepts any key in MODULE_SCOPE_MAP,
427
+ // or null to clear. Validation happens in the route; this just writes the column.
428
+
429
+ async function updateTaskModuleKey(id, moduleKey) { return updateTaskColumn(id, 'module_key', moduleKey); }
430
+
431
+ // touches[] cleanup [4/8] (task 879): updateTaskTouches() was removed with the
432
+ // drift scanner + the PATCH /tasks/:id touches branch + ship's drift-expand
433
+ // flow. touches[] is no longer mutated mid-claim — a wrong up-front prediction
434
+ // is repaired (when it matters at all) by git merge / the claim-time overlap
435
+ // guard, not by rewriting the array. The claim-time parallel-safety invariant
436
+ // it duplicated still lives in claimTask().
437
+
438
+
439
+ // task 1957: credits_awarded is the ACTUAL drachmae paid out for completing a
440
+ // task — the sum of the TASK-REWARD credit_log deltas booked against it: the
441
+ // per-task credit (= credits_reward × kind-multiplier), the idea-promotion
442
+ // bonus, and the net-negative (simplification) bonus, across all builders. The
443
+ // reason allowlist is deliberate: achievement unlocks ('achievement.*') also
444
+ // carry the shipped task's id but are GAMIFICATION, not a reward for the task,
445
+ // so they must NOT inflate the payout (session.token_reward and rank_change
446
+ // carry task_id=NULL and are excluded already).
447
+ //
448
+ // ADR 0120 "pay on land": the per-task credit reason is now 'task.shipped'
449
+ // (booked at the confirmed→shipped flip) instead of 'task.confirmed' (booked at
450
+ // confirm). BOTH strings are allow-listed: historical tasks confirmed under the
451
+ // old rule carry 'task.confirmed' rows, new landings carry 'task.shipped', and
452
+ // the one-time clawback reverses the old 'task.confirmed' deltas (a negative
453
+ // row nets them to 0) — so summing both is correct across the transition. The
454
+ // CASE still short-circuits to 0 for unsettled tasks; note a 'confirmed' task
455
+ // now shows 0 until it LANDS (the credit no longer exists at confirm) — the UI
456
+ // renders the "Est." label until the 'task.shipped' row books at land.
457
+ const CREDITS_AWARDED_SUBQUERY =
458
+ `CASE WHEN t.status IN ('confirmed', 'shipped')
459
+ THEN (SELECT COALESCE(SUM(cl.delta), 0)::int
460
+ FROM credit_log cl
461
+ WHERE cl.task_id = t.id
462
+ AND cl.reason IN ('task.confirmed', 'task.shipped', 'idea_promotion_bonus', 'ship.net_negative'))
463
+ ELSE 0 END`;
464
+
465
+ async function getTask(id) {
466
+ const { rows } = await pool.query(
467
+ `SELECT t.*, ${CREDITS_AWARDED_SUBQUERY} AS credits_awarded,
468
+ pms_calibrated_minutes(t.est_minutes, t.kind) AS est_minutes_calibrated
469
+ FROM tasks t WHERE t.id = $1`,
470
+ [id]
471
+ );
472
+ return rows[0] ?? null;
473
+ }
474
+
475
+
476
+ async function listTasks({ versionId, status, kind, discipline, goalId } = {}) {
477
+ const params = [];
478
+ const where = [];
479
+ if (versionId) {
480
+ params.push(versionId);
481
+ where.push(`version_id = $${params.length}`);
482
+ }
483
+ if (status) {
484
+ params.push(status);
485
+ where.push(`status = $${params.length}`);
486
+ }
487
+ if (kind) {
488
+ params.push(kind);
489
+ where.push(`kind = $${params.length}`);
490
+ }
491
+ if (discipline) {
492
+ params.push(discipline);
493
+ where.push(`discipline = $${params.length}`);
494
+ }
495
+ if (goalId) {
496
+ params.push(goalId);
497
+ where.push(`goal_id = $${params.length}`);
498
+ }
499
+ const sql =
500
+ `SELECT t.*, ${CREDITS_AWARDED_SUBQUERY} AS credits_awarded,
501
+ pms_calibrated_minutes(t.est_minutes, t.kind) AS est_minutes_calibrated
502
+ FROM tasks t` +
503
+ (where.length ? ` WHERE ${where.join(' AND ')}` : '') +
504
+ ` ORDER BY priority NULLS LAST, created_at`;
505
+ const { rows } = await pool.query(sql, params);
506
+ return rows;
507
+ }
508
+
509
+ // Tasks that are ready, unblocked, and have no touches[] overlap
510
+ // with currently active claims. Excludes tasks already claimed by this
511
+ // builder (so the same builder doesn't see their own active claim listed
512
+ // as "claimable").
513
+ // `fit` (optional) narrows the list to tasks that are safe to run in an
514
+ // autonomous/overnight session-runner (task #358):
515
+ // 'autonomous' — autonomous-fit per the canonical tasks_session_fit view
516
+ // ('autonomous' ∈ fits_session_modes, i.e. overnight-safe +
517
+ // manual_degree<=2 + unblocked). claimable_tasks already
518
+ // excludes blocked tasks, but we join the view so the
519
+ // definition of "autonomous" lives in exactly one place.
520
+ // 'parallel' — autonomous-fit AND tasks.parallel_safe = true (the planning
521
+ // judgment that a task is safe to run alongside others).
522
+ //
523
+ // SR-12 / finding G2 (task 994) — PROTECTED-PATH EXCLUSION for autonomous fit.
524
+ // The overnight-builder runs as Archon and direct-pushes to main; every
525
+ // write-side gate keys on the AUTHOR's rank and only blocks authors BELOW Metic,
526
+ // so an archon-authored autonomous commit sails through all of them. A task's
527
+ // `description` originates (via idea-triage promotion) from idea_inbox.body_md,
528
+ // which any xenos / unlinked Discord user can seed — so attacker-seeded text can
529
+ // reach an archon-authority agent. To stop a seeded directive from reaching the
530
+ // permission/methodology/deploy CORE unreviewed, any --fit task whose declared
531
+ // touches[] overlap a permission-sensitive path (the ADR 0043 protected set) is
532
+ // EXCLUDED from the autonomous/parallel feed. Those tasks still appear in the
533
+ // normal interactive claimable list — a human must own them; an autonomous run
534
+ // never can. This is a HARD control (a JS post-filter on the fit query), not a
535
+ // soft prompt instruction. Backstops the SKILL.md fencing (layer 1).
536
+
537
+ // task 2181 (incident, blocker 74): these are the EXACT bodies of the
538
+ // claimable_tasks and tasks_session_fit views (migration
539
+ // core_190_rebuild_claimable_task_views.sql), inlined as subqueries so the
540
+ // claimable feed reads base tables (ACCESS SHARE only) instead of the views.
541
+ // A detached backend queued an ACCESS EXCLUSIVE DDL on the views, which parks
542
+ // every view SELECT behind it in the lock queue — the hall's primary feed hung
543
+ // indefinitely while raw-table reads stayed fast. Postgres inlines simple
544
+ // views into the same plan anyway, so this is semantics- and plan-identical;
545
+ // it just stops the query's LOCKING from referencing the view relations.
546
+ // If the view bodies change in a future migration, change these in lockstep
547
+ // (or fold back to the views once per-view DDL contention is a solved problem).
548
+ const CLAIMABLE_SQL = `
549
+ SELECT t.*, pms_calibrated_minutes(t.est_minutes, t.kind) AS est_minutes_calibrated
550
+ FROM tasks t
551
+ WHERE t.status = 'ready'
552
+ AND t.title NOT LIKE '\\_\\_smoke\\_\\_%' ESCAPE '\\'
553
+ AND NOT EXISTS (
554
+ SELECT 1 FROM task_blockers tb WHERE tb.task_id = t.id
555
+ )
556
+ AND NOT EXISTS (
557
+ SELECT 1 FROM claims c
558
+ JOIN tasks t2 ON t2.id = c.task_id
559
+ WHERE c.released_at IS NULL
560
+ AND t2.touches && t.touches
561
+ )`;
562
+ const SESSION_FIT_SQL = `
563
+ SELECT t.id,
564
+ CASE
565
+ WHEN t.automation_tag = 'overnight-safe' AND COALESCE(t.manual_degree, 5) <= 2
566
+ AND NOT EXISTS (SELECT 1 FROM task_blockers tb WHERE tb.task_id = t.id)
567
+ THEN ARRAY['autonomous','check-in','active']
568
+ WHEN t.automation_tag = 'live-only' OR COALESCE(t.manual_degree, 5) >= 4
569
+ THEN ARRAY['active']
570
+ ELSE ARRAY['check-in','active']
571
+ END AS fits_session_modes
572
+ FROM tasks t`;
573
+
574
+ async function listClaimableTasks({ versionId = null, builderId = null, discipline = null, fit = null } = {}) {
575
+ const params = [];
576
+ const where = [];
577
+ let goalJoin = '';
578
+ let selectCols = 'v.*';
579
+ if (versionId) {
580
+ params.push(versionId);
581
+ where.push(`v.version_id = $${params.length}`);
582
+ }
583
+ if (discipline) {
584
+ params.push(discipline);
585
+ where.push(`v.discipline = $${params.length}`);
586
+ }
587
+ if (builderId) {
588
+ params.push(builderId);
589
+ where.push(`NOT EXISTS (
590
+ SELECT 1 FROM claims c2
591
+ WHERE c2.task_id = v.id
592
+ AND c2.builder_id = $${params.length}
593
+ AND c2.released_at IS NULL
594
+ )`);
595
+ // V3.R15 #239: view-level sandbox gate. A sandboxed builder sees ONLY
596
+ // newcomer_friendly tasks in the claimable feed; the live general-queue
597
+ // ranks see everything. The view itself is builder-agnostic (views can't
598
+ // read session context), so the filter is realized here at SELECT time.
599
+ // Belt-and-braces with the row-level check in claimTask, which still throws
600
+ // rank_too_low_for_task at claim time.
601
+ // #510: FAIL CLOSED — allowlist the general-queue ranks {thetes, metic,
602
+ // archon} (mirrors GENERAL_QUEUE_RANKS / xenosClaimAllowed in JS) rather than
603
+ // the old `<> 'xenos'` test, which failed OPEN (any reserved/unknown/typo
604
+ // rank — or a NULL from a missing lookup — was treated as privileged and
605
+ // shown the whole queue). thetes is live (#692 / ADR 0034) so it stays in.
606
+ where.push(`(
607
+ (SELECT rank FROM builders WHERE id = $${params.length}) IN ('thetes', 'metic', 'archon')
608
+ OR v.newcomer_friendly = true
609
+ )`);
610
+ // V3.R82 #301: view-level requires_rank floor. The CASE expressions map
611
+ // both sides to the live tier; a builder is shown a task iff their tier >=
612
+ // the task's required tier. The tiers MUST mirror REQUIRES_RANK_TIER in JS
613
+ // (xenos=1, thetes=2, metic=3, archon=4 — thetes added #692 / mig 068);
614
+ // any rank outside the four live ones collapses to 0 and is gated out —
615
+ // same fail-closed behaviour as rankAllowsTask in JS. Same parameter index
616
+ // as the xenos gate above ($${params.length}) because both refer to the
617
+ // same builderId.
618
+ where.push(`(
619
+ CASE (SELECT rank FROM builders WHERE id = $${params.length})
620
+ WHEN 'archon' THEN 4 WHEN 'metic' THEN 3 WHEN 'thetes' THEN 2 WHEN 'xenos' THEN 1 ELSE 0
621
+ END
622
+ ) >= (
623
+ CASE COALESCE(v.requires_rank, 'xenos')
624
+ WHEN 'archon' THEN 4 WHEN 'metic' THEN 3 WHEN 'thetes' THEN 2 WHEN 'xenos' THEN 1 ELSE 0
625
+ END
626
+ )`);
627
+ // task 1003137: ride the goal + own-membership facts along so
628
+ // decorateGoalMembership (claim-feed.js) can flag non-member rows joinable.
629
+ // Same parameter index — all three reads are the same builderId. The
630
+ // goal_members PK (goal_id, builder_id) means the LEFT JOIN never
631
+ // multiplies rows; an Archon reads as member (isMemberOrArchon convention).
632
+ goalJoin = ` LEFT JOIN goals g ON g.id = v.goal_id
633
+ LEFT JOIN goal_members gm ON gm.goal_id = g.id AND gm.builder_id = $${params.length}`;
634
+ selectCols = `v.*, g.visibility AS g_visibility, g.scope_modules AS g_scope_modules, g.title AS g_title,
635
+ (gm.builder_id IS NOT NULL
636
+ OR (SELECT rank FROM builders WHERE id = $${params.length}) = 'archon') AS g_is_member`;
637
+ }
638
+ // Autonomy / parallel-eligibility filter. Joining the session-fit relation
639
+ // (also a SELECT t.* shape) makes bare column names like priority/created_at
640
+ // ambiguous, so the ORDER BY below qualifies them with v. unconditionally.
641
+ let fitJoin = '';
642
+ if (fit === 'autonomous' || fit === 'parallel') {
643
+ fitJoin = ` JOIN (${SESSION_FIT_SQL}) sf ON sf.id = v.id`;
644
+ where.push(`'autonomous' = ANY(sf.fits_session_modes)`);
645
+ if (fit === 'parallel') {
646
+ where.push(`v.parallel_safe = true`);
647
+ }
648
+ }
649
+ // Order by calibrated estimate (falls back to raw when calibrator returns NULL
650
+ // — e.g. unestimated tasks). Calibrated reflects realistic time-to-ship given
651
+ // recent actuals; ordering on the raw estimate puts oversized tasks at the
652
+ // bottom even when their actual time-to-ship is short. See migration 015.
653
+ const sql =
654
+ `SELECT ${selectCols} FROM (${CLAIMABLE_SQL}) v` + goalJoin + fitJoin +
655
+ (where.length ? ` WHERE ${where.join(' AND ')}` : '') +
656
+ ` ORDER BY v.priority NULLS LAST,
657
+ COALESCE(v.est_minutes_calibrated, v.est_minutes) NULLS LAST,
658
+ v.created_at`;
659
+ const { rows: rawRows } = await pool.query(sql, params);
660
+ // task 1003137: strip g_* scratch columns, flag non-member rows joinable.
661
+ const rows = rawRows.map(decorateGoalMembership);
662
+ // SR-12 / G2 (task 994): when the autonomous/parallel runner is asking, drop
663
+ // any task whose declared touches[] reach the permission/methodology/deploy
664
+ // core. The runner ships as Archon straight to main, so these must never be
665
+ // claimed unsupervised — a human owns them. Applied in JS (not SQL) so it
666
+ // reuses the single canonical protected-path matcher (matchProtected) shared
667
+ // with the grader pre-pass, pre-push hook, and main-audit — no second source
668
+ // of truth to drift.
669
+ if (fit === 'autonomous' || fit === 'parallel') {
670
+ // task 1003137: membership-required rows are DROPPED, not flagged — the
671
+ // claim gate will refuse them and an autonomous runner can't answer a join
672
+ // prompt. (No builderId → no membership facts; the claim gate stays the
673
+ // hard control.)
674
+ return rows.filter((t) => !taskTouchesProtectedPath(t)
675
+ && !(t.goal_membership && t.goal_membership.required === true));
676
+ }
677
+ // Newcomer-chore display cap (task 1266, owner decision 2026-06-19). The
678
+ // newcomer-floor keeps a deep supply pool (floor: 3 artist + 2 ideator) so
679
+ // several newcomers arriving at once each have something to grab — but that
680
+ // pool depth is a CROSS-BUILDER supply concept, not one person's to-do list.
681
+ // Shown raw, a single artist+ideator sees five near-identical "Same as above"
682
+ // seats. Cap the newcomer_friendly rows in the human-facing feed to 3 so the
683
+ // menu maps to the Xenos->Thetes bar (ship 3 works). Reached only on the
684
+ // human/default path — the autonomous/parallel runner returned above and is
685
+ // untouched. The floor replenisher counts ready+backlog rows and is likewise
686
+ // unaffected (it never reads this feed).
687
+ return capNewcomerTasks(rows);
688
+ }
689
+
690
+ // SR-12 / G2 (task 994): does this task's declared touches[] overlap any
691
+ // permission-sensitive (ADR 0043) path? Pure + exported for unit testing.
692
+ // Returns true if ANY declared touch matches a protected glob — such a task is
693
+ // off-limits to the autonomous runner regardless of who would author it,
694
+ // because the runner authors as Archon and the write-side gates can't catch a
695
+ // high-rank automated author acting on attacker-seeded task text.
696
+ //
697
+ // A task with NO declared touches[] is treated as SAFE for the feed (returns
698
+ // false): touches[] is advisory at ship time (ADR 0049), so an empty list is
699
+ // the common honest case (e.g. a small game/doc task), not a signal of danger
700
+ // — and the autonomous fit pool is already narrowed to overnight-safe +
701
+ // manual_degree<=2 + unblocked tasks. The exclusion fires only on a POSITIVE
702
+ // match against a declared protected path.
703
+
704
+ function taskTouchesProtectedPath(task) {
705
+ const touches = Array.isArray(task && task.touches) ? task.touches : [];
706
+ if (touches.length === 0) return false;
707
+ return matchProtected(touches).length > 0;
708
+ }
709
+
710
+ // Cross-discipline recommendations for /builder-start (V3.R81 / #299).
711
+ //
712
+ // Given the full claimable list and the builder's preferred_disciplines,
713
+ // returns up to `limit` tasks whose discipline is OUTSIDE the builder's
714
+ // preferences — the "you might enjoy" section. Pure function (no DB round
715
+ // trip): the caller already holds the claimable rows, so we just partition
716
+ // and rank them.
717
+ //
718
+ // Ranking key (most relevant first):
719
+ // 1. priority DESC — a P5 in another lane still beats a P1
720
+ // 2. credits-per-minute DESC — best bang-for-effort among equal priority
721
+ // 3. est_minutes ASC — shorter wins the tiebreak (easier to try)
722
+ // 4. id ASC — stable final tiebreak
723
+ //
724
+ // 'unclassified' tasks are excluded: they're un-triaged, not a real lane to
725
+ // recommend exploring. If preferred_disciplines is empty (no preference), the
726
+ // builder already sees everything in the main list, so there is nothing
727
+ // "outside" to recommend — return [].
728
+
729
+ function rankCrossDisciplineRecommendations(claimable, preferredDisciplines, limit = 3) {
730
+ const rows = Array.isArray(claimable) ? claimable : [];
731
+ const prefs = Array.isArray(preferredDisciplines) ? preferredDisciplines : [];
732
+ // No stated preference → no "outside", nothing to recommend.
733
+ if (prefs.length === 0) return [];
734
+ const prefSet = new Set(prefs);
735
+ const cpm = (t) => {
736
+ const credits = Number(t.credits_reward) || 0;
737
+ const mins = Number(t.est_minutes_calibrated ?? t.est_minutes) || 0;
738
+ return mins > 0 ? credits / mins : 0;
739
+ };
740
+ const outside = rows.filter(
741
+ (t) => t.discipline && t.discipline !== 'unclassified' && !prefSet.has(t.discipline)
742
+ );
743
+ outside.sort((a, b) => {
744
+ const pa = Number(a.priority) || 0;
745
+ const pb = Number(b.priority) || 0;
746
+ if (pb !== pa) return pb - pa; // higher priority first
747
+ const ca = cpm(a);
748
+ const cb = cpm(b);
749
+ if (cb !== ca) return cb - ca; // higher credits/min first
750
+ const ma = Number(a.est_minutes_calibrated ?? a.est_minutes) || Infinity;
751
+ const mb = Number(b.est_minutes_calibrated ?? b.est_minutes) || Infinity;
752
+ if (ma !== mb) return ma - mb; // shorter first
753
+ return (Number(a.id) || 0) - (Number(b.id) || 0); // stable
754
+ });
755
+ return outside.slice(0, Math.max(0, limit));
756
+ }
757
+
758
+ // What's currently active across all builders. Used by /builder-start
759
+ // for the "in-flight" sidebar so a builder can see who else is on what.
760
+
761
+ async function listActiveClaims() {
762
+ const { rows } = await pool.query(
763
+ `SELECT c.id AS claim_id, c.task_id, c.builder_id, c.worktree_name, c.claimed_at,
764
+ t.title, t.touches, t.version_id, t.priority,
765
+ b.github_login, b.display_name
766
+ FROM claims c
767
+ JOIN tasks t ON t.id = c.task_id
768
+ JOIN builders b ON b.id = c.builder_id
769
+ WHERE c.released_at IS NULL
770
+ ORDER BY c.claimed_at`
771
+ );
772
+ return rows;
773
+ }
774
+
775
+
776
+ async function updateTaskStatus(id, status) {
777
+ await pool.query(`UPDATE tasks SET status = $2 WHERE id = $1`, [id, status]);
778
+ }
779
+
780
+ module.exports = {
781
+ CREDITS_AWARDED_SUBQUERY,
782
+ REWARD_SUGGESTION_CAP,
783
+ createCriterion,
784
+ createTask,
785
+ findTaskBySourceRef,
786
+ generalGoalIdForVersion,
787
+ getTask,
788
+ listActiveClaims,
789
+ listClaimableTasks,
790
+ listTasks,
791
+ rankCrossDisciplineRecommendations,
792
+ rewardGateAssignment,
793
+ rewardGateError,
794
+ rewardMissing,
795
+ setCreditsReward,
796
+ suggestCreditsReward,
797
+ taskTouchesProtectedPath,
798
+ updateTaskAutomationTag,
799
+ updateTaskDescription,
800
+ updateTaskDiscipline,
801
+ updateTaskGoal,
802
+ updateTaskKind,
803
+ updateTaskModuleKey,
804
+ updateTaskNeedsMigration,
805
+ updateTaskNewcomerFriendly,
806
+ updateTaskParallelSafe,
807
+ updateTaskRequiresRank,
808
+ updateTaskSecuritySensitive,
809
+ updateTaskStatus,
810
+ };