@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.
- package/.bongos-core.json +91 -26
- package/modules/lifecycle/db-analytics.js +719 -0
- package/modules/lifecycle/db-claim-reads.js +165 -0
- package/modules/lifecycle/db-claims.js +934 -0
- package/modules/lifecycle/db-deps-criteria.js +441 -0
- package/modules/lifecycle/db-goals.js +763 -0
- package/modules/lifecycle/db-grade.js +233 -0
- package/modules/lifecycle/db-overrides.js +181 -0
- package/modules/lifecycle/db-rank-authz.js +446 -0
- package/modules/lifecycle/db-shared.js +115 -0
- package/modules/lifecycle/db-ship.js +994 -0
- package/modules/lifecycle/db-tasks.js +810 -0
- package/modules/lifecycle/db-versions.js +118 -0
- package/modules/lifecycle/db.js +167 -5564
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/coreB-carve-proof.sh +18 -9
- package/scripts/gds/fitness.js +34 -26
- package/scripts/gds/identity-strip.js +75 -0
- package/scripts/gds/provision.js +7 -3
- package/src/module-api.js +3 -1
- package/tests/auto_satisfy_criteria.mjs +3 -2
- package/tests/dependencies_migration.mjs +3 -2
- package/tests/fitness.mjs +43 -0
- package/tests/goal_abuse_matrix.mjs +3 -2
- package/tests/goal_advisory.mjs +3 -2
- package/tests/government_claim_eligibility.mjs +3 -1
- package/tests/government_rank_sync.mjs +9 -2
- package/tests/helpers.mjs +19 -0
- package/tests/migration_allocation.mjs +3 -1
- package/tests/publish_reconciler.mjs +3 -1
- package/tests/rank_gate.mjs +3 -1
- package/tests/reward_gate.mjs +3 -1
|
@@ -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
|
+
};
|