@bongos/core 1.19.375 → 1.19.376

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,83 @@
1
+ -- core_227_task_created_by.sql — a task remembers who created it (task 1003278,
2
+ -- goal 1000072, criterion legibility-sort-and-staleness).
3
+ --
4
+ -- THE GAP. "You can find your own work" was impossible on the task tier: tasks
5
+ -- carried source/source_ref (which PIPELINE filed it) and shipped_by (who landed
6
+ -- it), but nothing said which PERSON created it. A goal has had created_by since
7
+ -- migration 160; a task had nothing, so the owner could not ask the board "what
8
+ -- did I create" — one of the exact questions goal 1000072 exists to answer.
9
+ --
10
+ -- NULLABLE, NO BACKFILL, ON PURPOSE. History is honestly unattributable: the
11
+ -- ~1,357 existing BONGOS-V1 tasks were created before this column, and guessing
12
+ -- (from shipped_by, from a claim, from the source string) would write confident
13
+ -- lies into an attribution column. NULL = created before attribution existed, or
14
+ -- by a system path with no builder in the request — the same convention as
15
+ -- goals.created_by (migration 160: "NULL = system-attributed"). The UI's filter
16
+ -- gets a 'system / unattributed' bucket, exactly like the goals owner filter.
17
+ --
18
+ -- Additive + forward-safe: every existing reader either SELECTs an explicit
19
+ -- column list or `t.*` (which tolerates a new column); nothing NOT NULL, nothing
20
+ -- renamed (tests/migrations_forward_safe.mjs).
21
+ --
22
+ -- THE VIEWS MUST BE REBUILT WITH IT. A Postgres view created with `SELECT t.*`
23
+ -- pins its column list at CREATE VIEW time — core_190's own header documents six
24
+ -- columns that silently never reached the claim feed this way, and the Work
25
+ -- Board's Creator filter reads created_by off exactly that feed. So this
26
+ -- migration re-runs core_190's DROP+CREATE verbatim (no WHERE/CASE change), the
27
+ -- same way core_190 itself repaired the 044-era views.
28
+
29
+ BEGIN;
30
+
31
+ ALTER TABLE tasks
32
+ ADD COLUMN IF NOT EXISTS created_by bigint REFERENCES builders(id);
33
+
34
+ COMMENT ON COLUMN tasks.created_by IS
35
+ 'The builder who created this task (task 1003278). NULL = created before attribution existed, or by a system path with no builder in the request — never backfilled, because a guessed attribution is a confident lie. The goals.created_by convention (migration 160).';
36
+
37
+ CREATE INDEX IF NOT EXISTS idx_tasks_created_by ON tasks (created_by);
38
+
39
+ -- Rebuild the two t.* views so created_by is projected (bodies identical to
40
+ -- core_190 — the wildcard re-expands to every current column).
41
+
42
+ DROP VIEW IF EXISTS claimable_tasks;
43
+ CREATE VIEW claimable_tasks AS
44
+ SELECT t.*,
45
+ pms_calibrated_minutes(t.est_minutes, t.kind) AS est_minutes_calibrated
46
+ FROM tasks t
47
+ WHERE t.status = 'ready'
48
+ AND t.title NOT LIKE '\_\_smoke\_\_%' ESCAPE '\'
49
+ AND NOT EXISTS (
50
+ SELECT 1 FROM task_blockers tb WHERE tb.task_id = t.id
51
+ )
52
+ AND NOT EXISTS (
53
+ SELECT 1 FROM claims c
54
+ JOIN tasks t2 ON t2.id = c.task_id
55
+ WHERE c.released_at IS NULL
56
+ AND t2.touches && t.touches
57
+ );
58
+
59
+ DROP VIEW IF EXISTS tasks_session_fit;
60
+ CREATE VIEW tasks_session_fit AS
61
+ SELECT t.*,
62
+ CASE
63
+ WHEN t.automation_tag = 'overnight-safe' AND COALESCE(t.manual_degree, 5) <= 2
64
+ AND NOT EXISTS (SELECT 1 FROM task_blockers tb WHERE tb.task_id = t.id)
65
+ THEN ARRAY['autonomous','check-in','active']
66
+ WHEN t.automation_tag = 'live-only' OR COALESCE(t.manual_degree, 5) >= 4
67
+ THEN ARRAY['active']
68
+ ELSE ARRAY['check-in','active']
69
+ END AS fits_session_modes,
70
+ pms_calibrated_minutes(t.est_minutes, t.kind) AS est_minutes_calibrated,
71
+ CASE
72
+ WHEN t.credits_reward > 0
73
+ AND pms_calibrated_minutes(t.est_minutes, t.kind) IS NOT NULL
74
+ AND pms_calibrated_minutes(t.est_minutes, t.kind) > 0
75
+ THEN ROUND(t.credits_reward::numeric / pms_calibrated_minutes(t.est_minutes, t.kind), 3)
76
+ ELSE NULL
77
+ END AS credits_per_minute
78
+ FROM tasks t;
79
+
80
+ COMMIT;
81
+
82
+ INSERT INTO schema_migrations (version) VALUES ('core_227_task_created_by')
83
+ ON CONFLICT (version) DO NOTHING;
@@ -366,6 +366,8 @@ function createClient(opts = {}) {
366
366
  getGoalsInbox: (args) => request("GET", "/goals/inbox", { hasBody: false }, args),
367
367
  // POST /goals/reorder — rank: metic+archon — POST /goals/reorder
368
368
  postGoalsReorder: (args) => request("POST", "/goals/reorder", { hasBody: true }, args),
369
+ // GET /goals/rollups — rank: any-builder — GET /goals/rollups
370
+ getGoalsRollups: (args) => request("GET", "/goals/rollups", { hasBody: false }, args),
369
371
  // GET /goals/suggestions — rank: any-builder — GET /goals/suggestions
370
372
  getGoalsSuggestions: (args) => request("GET", "/goals/suggestions", { hasBody: false }, args),
371
373
  },
@@ -84,11 +84,11 @@
84
84
  return false;
85
85
  }
86
86
 
87
- // ms helpers for the goal ordering (task 1002965): updated_at moves on every
88
- // task write, shipped_at folded in because a ship path can stamp it without
89
- // touching updated_at.
90
- const msOf = (iso) => { const t = iso ? Date.parse(iso) : NaN; return Number.isNaN(t) ? 0 : t; };
91
- const taskActivityMs = (t) => Math.max(msOf(t.updated_at), msOf(t.shipped_at));
87
+ // ms helpers for the goal ordering (task 1002965). The DEFINITIONS moved to
88
+ // task-lib.js in task 1003278 so the Tasks board and the Goals page read ONE
89
+ // meaning of "touched"; re-exported here so this lib's API is unchanged.
90
+ // goals.html loads task-lib.js before this file (the render-family order).
91
+ const { msOf, taskActivityMs } = window.OTBTaskLib;
92
92
 
93
93
  // task 1003224 — on a LIST, a goal's subtitle is a TOOLTIP, never a second
94
94
  // visible line. The board's job there is to let someone find a goal by name;
@@ -144,37 +144,60 @@
144
144
  // Array#sort is stable in every browser this hall supports — so goals sharing
145
145
  // a timestamp (or both missing one) keep their priority order as the tiebreak.
146
146
 
147
- // Fetch every task for the versions the goals span (one request each, with dep
148
- // summaries) and fold into per-goal rollups. Failure-tolerant: an older server
149
- // that doesn't honor include=deps just yields empty rollups and the list falls
150
- // back to plain cards. Tasks come back priority-ordered, so the first `ready`
151
- // one is the goal's best next pick.
147
+ // Per-goal rollups: readiness counters, the last-activity timestamp, and the
148
+ // craft set. Since task 1003278 the SERVER computes these (GET /goals/rollups),
149
+ // one small row per goal.
150
+ //
151
+ // WHY IT MOVED, in one number. This used to fetch every task in every version
152
+ // the board spans — `GET /tasks?version=…&include=deps` — and reduce them here.
153
+ // On the real corpus that single request measured **5,229,455 bytes for ~1,400
154
+ // tasks** (2026-08-25) to produce about forty counter objects. The criterion
155
+ // this page serves says it plainly: "~1,357 tasks on BONGOS-V1 alone. A view
156
+ // that does not survive that number is not a solution." It did not survive it;
157
+ // it just did not visibly fall over.
158
+ //
159
+ // The buckets, the activity clock and the next-ready pick are unchanged — the
160
+ // SQL mirrors goals-lib.js `readinessOf` exactly, and a test holds the two
161
+ // against each other, because a divergence would show a person different
162
+ // numbers depending on which surface they opened.
163
+ //
164
+ // Failure-tolerant, and deliberately NOT falling back to the old whole-version
165
+ // fetch: a "fallback" that quietly re-downloads 5 MB is the bug wearing a
166
+ // safety belt. An older server yields empty rollups and the cards render plain.
152
167
  async function loadRollups() {
153
168
  const versions = [...new Set(state.goals.map((g) => g.version_id).filter(Boolean))];
154
169
  const lists = await Promise.all(versions.map((v) =>
155
- getJSON(`${API}/tasks?version=${encodeURIComponent(v)}&include=deps`)
156
- .then((r) => r.tasks || []).catch(() => [])));
170
+ getJSON(`${API}/goals/rollups?version=${encodeURIComponent(v)}`)
171
+ .then((r) => r.rollups || []).catch(() => [])));
157
172
  const roll = new Map();
158
173
  const activity = new Map();
159
- for (const t of lists.flat()) {
160
- if (t.goal_id == null) continue;
161
- const key = String(t.goal_id);
162
- let r = roll.get(key);
163
- if (!r) { r = { done: 0, active: 0, ready: 0, blocked: 0, backlog: 0, cut: 0, total: 0, nextReady: null }; roll.set(key, r); }
164
- const bucket = readinessOf(t);
165
- r[bucket] += 1;
166
- if (bucket !== 'cut') r.total += 1;
167
- if (bucket === 'ready' && !r.nextReady) r.nextReady = t;
168
- // task 1002965 — ANY task activity counts (owner call), not just ships: a
169
- // goal being worked right now should read as active before anything lands.
170
- // updated_at moves on every task write (claim, status flip, edit), so it
171
- // dominates in practice; shipped_at is folded in because a task can be
172
- // shipped by a path that stamps it without touching updated_at.
173
- const ts = taskActivityMs(t);
174
- if (ts && ts > (activity.get(key) || 0)) activity.set(key, ts);
174
+ const disciplines = new Map(); // task 1003278 — goalId -> Set(craft)
175
+ for (const row of lists.flat()) {
176
+ const key = String(row.goal_id);
177
+ roll.set(key, {
178
+ done: row.done || 0,
179
+ active: row.active || 0,
180
+ ready: row.ready || 0,
181
+ blocked: row.blocked || 0,
182
+ backlog: row.backlog || 0,
183
+ cut: row.cut || 0,
184
+ total: row.total || 0,
185
+ // The row shape the renderers already expect: `nextReady` is a task-like
186
+ // object with an id and a title, and nothing else was ever read off it.
187
+ nextReady: row.next_ready_id ? { id: row.next_ready_id, title: row.next_ready_title } : null,
188
+ });
189
+ const ts = msOf(row.last_activity_at);
190
+ if (ts) activity.set(key, ts);
191
+ // The goal's CRAFT set. "Role" here is the craft axis ADR 0174 reserves the
192
+ // word for (engineer | artist | ideator | ui), never a goal membership —
193
+ // that is `membership_kind` (ADR 0177). A goal has no discipline column and
194
+ // must not get one: its craft set is what its tasks carry, which is why the
195
+ // server aggregates it rather than the goal row storing it.
196
+ disciplines.set(key, new Set(row.disciplines || []));
175
197
  }
176
198
  state.rollups = roll;
177
199
  state.activity = activity;
200
+ state.disciplines = disciplines;
178
201
  }
179
202
 
180
203
  /* ---------------- list view ---------------- */
@@ -893,6 +916,14 @@
893
916
  // task 1003276 — filled from GET /work-categories after boot
894
917
  // (populateCategoryFilter), the populateOwnerFilter pattern.
895
918
  { id: 'f-goal-category', label: 'Category', options: [{ value: '', label: '— any —' }] },
919
+ // task 1003278 — the craft axis (ADR 0174 reserves "role" for the crafts;
920
+ // a goal membership is a membership_kind, ADR 0177). Values are the
921
+ // disciplines a goal's tasks carry, derived in loadRollups. Staleness
922
+ // buckets are the shared STALE_CHOICES from task-lib.js so this page and
923
+ // the Tasks board offer the same lenses.
924
+ { id: 'f-goal-role', label: 'Craft', options: [ // ADR 0174: "role" means a craft
925
+ { value: '', label: '— any —' }, 'engineer', 'artist', 'ideator', 'ui'] },
926
+ { id: 'f-goal-stale', label: 'Age', options: window.OTBTaskLib.STALE_CHOICES },
896
927
  { id: 'f-goal-order',
897
928
  label: 'Sort',
898
929
  value: GOAL_SORTS[savedGoalSort] ? savedGoalSort : undefined,
@@ -60,6 +60,9 @@
60
60
  newest: { label: 'newest first', cmp: (a, b) => goalCreatedMs(b) - goalCreatedMs(a), stamp: (g) => ({ label: 'created', ms: goalCreatedMs(g) }) },
61
61
  oldest: { label: 'oldest first', cmp: (a, b) => goalCreatedMs(a) - goalCreatedMs(b), stamp: (g) => ({ label: 'created', ms: goalCreatedMs(g) }) },
62
62
  active: { label: 'recently active', cmp: (a, b) => goalActivityMs(b) - goalActivityMs(a), stamp: (g) => ({ label: 'last work', ms: goalActivityMs(g) }) },
63
+ // task 1003278 — the rot view: least-recently-touched first, on the SAME
64
+ // clock as 'recently active' so the two can never disagree.
65
+ stale: { label: 'stalest first', cmp: (a, b) => goalActivityMs(a) - goalActivityMs(b), stamp: (g) => ({ label: 'last work', ms: goalActivityMs(g) }) },
63
66
  };
64
67
  const currentGoalSort = () => GOAL_SORTS[$('#f-goal-order')?.value] || GOAL_SORTS.priority;
65
68
  function sortGoals(rows) {
@@ -93,6 +96,16 @@
93
96
  || (owner === 'none' ? g.created_by == null : String(g.created_by) === owner);
94
97
  const matchesCategory = (g) => !cat
95
98
  || (cat === 'none' ? !g.category : (g.category?.slug === cat));
99
+ // task 1003278 — role + staleness. A goal's role is the discipline set its
100
+ // tasks carry (loadRollups derives it from the per-version task fetch); its
101
+ // clock is the same activity timestamp the 'recently active' sort reads, so
102
+ // sorting and filtering can never disagree about what "touched" means.
103
+ const role = $('#f-goal-role')?.value || '';
104
+ const staleDays = $('#f-goal-stale')?.value || '';
105
+ const { isStale } = window.OTBTaskLib;
106
+ const matchesRole = (g) => !role
107
+ || !!(state.disciplines && state.disciplines.get(String(g.id))?.has(role));
108
+ const matchesStale = (g) => !staleDays || isStale(goalActivityMs(g), staleDays);
96
109
  const myId = selfId();
97
110
  const iAmMember = (g) => {
98
111
  const d = state.details.get(String(g.id));
@@ -104,6 +117,8 @@
104
117
  matchesSearch(g) &&
105
118
  matchesOwner(g) &&
106
119
  matchesCategory(g) &&
120
+ matchesRole(g) &&
121
+ matchesStale(g) &&
107
122
  (!mineOnly || iAmMember(g)));
108
123
  }
109
124
  function goalStampHtml(g) {
@@ -108,6 +108,34 @@
108
108
  // case leaked a machine code into the Goals page UI. Adding a server-side reason
109
109
  // without adding it here degrades quietly rather than loudly, so keep this in
110
110
  // step with the claim route's refusal codes.
111
+ // ---- staleness (task 1003278, criterion legibility-sort-and-staleness) ----
112
+ // ONE definition of "touched", shared by both tiers: updated_at moves on every
113
+ // task write (claim, status flip, edit); shipped_at is folded in because a ship
114
+ // path can stamp it without touching updated_at (the task-1002965 owner call,
115
+ // previously private to goals-lib.js — moved HERE so the Tasks board and the
116
+ // Goals page cannot disagree about what "untouched" means).
117
+ const msOf = (iso) => { const t = iso ? Date.parse(iso) : NaN; return Number.isNaN(t) ? 0 : t; };
118
+ const taskActivityMs = (t) => Math.max(msOf(t.updated_at), msOf(t.shipped_at));
119
+
120
+ // The staleness buckets both boards offer. A CLIENT-side vocabulary, not the
121
+ // project's configured stale timer (task 1003279 wires that into the sweep);
122
+ // these are lenses for a person browsing, and the sweep is the enforcement.
123
+ const STALE_CHOICES = [
124
+ { value: '', label: '— any age —' },
125
+ { value: '14', label: 'untouched 14+ days' },
126
+ { value: '30', label: 'untouched 30+ days' },
127
+ { value: '60', label: 'untouched 60+ days' },
128
+ ];
129
+ // Does this activity timestamp fall past the chosen bucket? '' = no filter.
130
+ // 0 (no readable timestamp at all) is treated as STALE, not fresh — a row
131
+ // nobody can date is exactly what a rot-hunt must not hide.
132
+ function isStale(activityMs, days, nowMs) {
133
+ const d = Number(days);
134
+ if (!d) return true;
135
+ const now = Number.isFinite(nowMs) ? nowMs : Date.now();
136
+ return now - activityMs >= d * 24 * 60 * 60 * 1000;
137
+ }
138
+
111
139
  function friendlyBatchReason(item) {
112
140
  switch (item && item.reason) {
113
141
  case 'TASK_NOT_FOUND': return 'no longer exists';
@@ -128,6 +156,7 @@
128
156
  }
129
157
 
130
158
  const api = {
159
+ msOf, taskActivityMs, STALE_CHOICES, isStale,
131
160
  priorityRank, touchesConflict, rankTagHtml, deHashRefs,
132
161
  creditsPerMinute, buildPromptForTask, friendlyBatchReason,
133
162
  };
@@ -46,6 +46,45 @@
46
46
  } = window.OTBTaskLib; // deHashRefs is not called here — it is used inside buildPromptForTask
47
47
  // Shared component kit (hall-kit.js, task 2166): tabs, pager, filter bar.
48
48
  const { makePager, paginate, wirePager, pagerBarHtml, makeTabs, makeFilterBar } = window.OTBKit;
49
+ // task 1003278 — the shared staleness vocabulary + activity clock (task-lib.js,
50
+ // one definition for both tiers) and the creator axis.
51
+ const { taskActivityMs, STALE_CHOICES, isStale } = window.OTBTaskLib;
52
+ // task 1003278 — Creator and Age are SERVER-side axes: the board asks the feed
53
+ // a narrower question rather than downloading it and reducing it here. That is
54
+ // the criterion's explicit branch ("if it does, the new axes filter and page
55
+ // SERVER-side"), and it is why changing either control re-fetches instead of
56
+ // just re-rendering. Every other control stays client-side over what came
57
+ // back, exactly as before — those narrow an already-loaded page, this narrows
58
+ // what gets loaded.
59
+ // The Backlog group, BOUNDED and server-filtered. This read measured 690,625
60
+ // bytes for ~298 rows on 2026-08-25 and had no ceiling at all — it is
61
+ // `SELECT * FROM tasks WHERE status='backlog'`, so it grew with the project
62
+ // forever. Its own function because Creator/Age re-fetch it, same as the
63
+ // claimable feed. Best-effort: on failure the section stays as it was.
64
+ function loadBacklog() {
65
+ const q = serverFilterQuery().replace(/^\?/, '&');
66
+ return getJSON(`${API}/tasks?status=backlog&limit=${BACKLOG_LIMIT}${q}`)
67
+ .then((r) => { taskState.backlog = r.tasks || []; renderBacklog(taskState.backlog); })
68
+ .catch(() => { /* backlog stays as it was */ });
69
+ }
70
+
71
+ // The last server-facing filter query we actually fetched with. Compared on
72
+ // every filter change to decide re-fetch vs re-render.
73
+ let lastServerFilterQuery = '';
74
+
75
+ function serverFilterQuery() {
76
+ const parts = [];
77
+ const creator = $('#f-creator') ? $('#f-creator').value : '';
78
+ const stale = $('#f-stale') ? $('#f-stale').value : '';
79
+ if (creator) parts.push(`created_by=${encodeURIComponent(creator === '__none__' ? 'none' : creator)}`);
80
+ if (stale) parts.push(`stale_days=${encodeURIComponent(stale)}`);
81
+ return parts.length ? `?${parts.join('&')}` : '';
82
+ }
83
+
84
+ // task 1003278 — the ceiling on the Backlog read. Comfortably above today's
85
+ // ~300 rows so nothing visibly changes now, and low enough that the page can
86
+ // never be asked to hold the whole table as the project grows.
87
+ const BACKLOG_LIMIT = 500;
49
88
  const fmtDate = (iso) => window.OTB.fmtDate(iso);
50
89
  const toast = (msg) => window.OTB.toast(msg, { duration: 2200, clear: true });
51
90
  const curLabel = (caps) => { const l = (window.__BRANDING__ && window.__BRANDING__.currency && window.__BRANDING__.currency.label) || 'credits'; return caps ? l.charAt(0).toUpperCase() + l.slice(1) : l; };
@@ -255,6 +294,11 @@
255
294
  const kFilter = $('#f-kind').value;
256
295
  const dFilter = $('#f-discipline').value;
257
296
  const nFilter = $('#f-newcomer') ? $('#f-newcomer').value : '';
297
+ // task 1003278 — creator + staleness. '__none__' on Creator isolates the
298
+ // unattributed (created before tasks.created_by existed, or by a system path)
299
+ // — the goals owner filter's 'system / unattributed' convention.
300
+ const cFilter = $('#f-creator') ? $('#f-creator').value : '';
301
+ const staleFilter = $('#f-stale') ? $('#f-stale').value : '';
258
302
  const sortBy = $('#f-sort').value;
259
303
  const sQuery = searchQuery();
260
304
 
@@ -267,6 +311,13 @@
267
311
  if (kFilter && t.kind !== kFilter) return false;
268
312
  if (dFilter && (t.discipline || 'unclassified') !== dFilter) return false;
269
313
  if (nFilter === 'yes' && !t.newcomer_friendly) return false;
314
+ // Creator + Age were applied by the SERVER (serverFilterQuery), so they are
315
+ // re-checked here only as a belt on a stale render — never as the primary
316
+ // filter. Re-applying them is cheap and keeps the view honest if a poll
317
+ // lands between a control change and its re-fetch.
318
+ if (cFilter === '__none__' && t.created_by != null) return false;
319
+ if (cFilter && cFilter !== '__none__' && String(t.created_by) !== cFilter) return false;
320
+ if (staleFilter && !isStale(taskActivityMs(t), staleFilter)) return false;
270
321
  return true;
271
322
  });
272
323
 
@@ -295,6 +346,10 @@
295
346
  } else if (sortBy === 'id') {
296
347
  // task 1830: sort by task number, ascending (lowest/oldest first).
297
348
  list = list.slice().sort((a, b) => Number(a.id) - Number(b.id));
349
+ } else if (sortBy === 'stale') {
350
+ // task 1003278: what is rotting — least-recently-touched first, on the
351
+ // shared activity clock (updated_at ∨ shipped_at, task-lib.js).
352
+ list = list.slice().sort((a, b) => taskActivityMs(a) - taskActivityMs(b));
298
353
  }
299
354
 
300
355
  const count = $('#claim-count');
@@ -405,6 +460,44 @@
405
460
  }
406
461
  }
407
462
 
463
+ // task 1003278: the Creator filter — options are the builders who created the
464
+ // tasks on the board (claimable + backlog), labelled via the directory the
465
+ // goals page also reads (GET /builders/directory, best-effort — 'builder #id'
466
+ // until it lands). '__none__' isolates the unattributed: every task created
467
+ // before tasks.created_by existed (core_227 deliberately backfills nothing —
468
+ // a guessed attribution is a confident lie), plus system paths like the
469
+ // newcomer restock.
470
+ async function populateCreatorFilter() {
471
+ const sel = $('#f-creator');
472
+ if (!sel) return;
473
+ const rows = [...taskState.all, ...(taskState.backlog || [])];
474
+ const ids = [...new Set(rows.filter((t) => t.created_by != null).map((t) => String(t.created_by)))];
475
+ let byId = new Map();
476
+ if (ids.length) {
477
+ try {
478
+ const d = await getJSON(`${API}/builders/directory`);
479
+ byId = new Map((d.builders || []).map((b) => [String(b.id), b]));
480
+ } catch (_) { /* labels fall back to builder #id */ }
481
+ }
482
+ const label = (id) => {
483
+ const b = byId.get(id);
484
+ return b ? (b.display_name || '@' + b.github_login) : `builder #${id}`;
485
+ };
486
+ sel.length = 1; // keep the leading "— any —" option
487
+ for (const id of ids.sort((a, b) => label(a).localeCompare(label(b)))) {
488
+ const opt = document.createElement('option');
489
+ opt.value = id;
490
+ opt.textContent = label(id);
491
+ sel.appendChild(opt);
492
+ }
493
+ if (rows.some((t) => t.created_by == null)) {
494
+ const opt = document.createElement('option');
495
+ opt.value = '__none__';
496
+ opt.textContent = 'system / unattributed';
497
+ sel.appendChild(opt);
498
+ }
499
+ }
500
+
408
501
  // task 1755: the Goal filter — options are the goals present on the claimable
409
502
  // feed, labeled by title (falls back to 'Goal #id' before titles load).
410
503
  // '__none__' isolates ungoaled tasks. Populated once after the feed loads.
@@ -983,7 +1076,7 @@
983
1076
  // the same way loadAll does so a transient failure doesn't blank the board.
984
1077
  async function refreshLive() {
985
1078
  try {
986
- const claimResp = await getJSON(`${API}/tasks/claimable`);
1079
+ const claimResp = await getJSON(`${API}/tasks/claimable${serverFilterQuery()}`);
987
1080
  renderInFlight(claimResp.active_claims || []);
988
1081
  taskState.all = claimResp.claimable || [];
989
1082
  renderClaimable();
@@ -1072,9 +1165,7 @@
1072
1165
 
1073
1166
  // task 1754: the Backlog group — not-yet-ready tasks. Best-effort + independent
1074
1167
  // of the claimable feed; leaves the section hidden on failure.
1075
- getJSON(`${API}/tasks?status=backlog`)
1076
- .then((r) => { taskState.backlog = r.tasks || []; renderBacklog(taskState.backlog); })
1077
- .catch(() => { /* backlog stays hidden */ });
1168
+ loadBacklog();
1078
1169
 
1079
1170
  // task 1755: goal id → title map for the Goal filter (best-effort).
1080
1171
  taskState.goalTitles = {};
@@ -1096,7 +1187,7 @@
1096
1187
  // empty-but-honest (the gate also blocks active_claims, so say so).
1097
1188
  try {
1098
1189
  const [claimResp, versionsResp] = await Promise.all([
1099
- getJSON(`${API}/tasks/claimable`),
1190
+ getJSON(`${API}/tasks/claimable${serverFilterQuery()}`),
1100
1191
  getJSON(`${API}/versions`).catch(() => ({ versions: [] })),
1101
1192
  ]);
1102
1193
  taskState.versions = versionsResp.versions || [];
@@ -1106,6 +1197,7 @@
1106
1197
  taskState.all = claimResp.claimable || [];
1107
1198
  renderClaimable();
1108
1199
  populateGoalFilter(); // task 1755
1200
+ populateCreatorFilter().catch(() => {}); // task 1003278 — fire-and-forget, the owner-filter posture
1109
1201
  // task 2170: a shared URL's goal filter can only apply once the options
1110
1202
  // exist — restore it now and re-render if it changed anything.
1111
1203
  if (restoreUrlFilter('f-goal')) renderClaimable();
@@ -1168,6 +1260,7 @@
1168
1260
  { value: 'minutes', label: 'minutes (low → high)' },
1169
1261
  { value: 'priority', label: 'priority (P0 first)' },
1170
1262
  { value: 'id', label: 'task number (low → high)' },
1263
+ { value: 'stale', label: 'stalest first (untouched longest)' },
1171
1264
  ],
1172
1265
  },
1173
1266
  { id: 'f-version', label: 'Version', options: [{ value: '', label: '— any —' }] },
@@ -1191,6 +1284,11 @@
1191
1284
  label: 'Newcomer',
1192
1285
  options: [{ value: '', label: '— any —' }, { value: 'yes', label: 'newcomer-friendly only' }],
1193
1286
  },
1287
+ // task 1003278 — Creator options fill after the feeds land
1288
+ // (populateCreatorFilter, the f-goal pattern); staleness buckets are the
1289
+ // shared STALE_CHOICES so both tiers offer the same lenses.
1290
+ { id: 'f-creator', label: 'Creator', options: [{ value: '', label: '— any —' }] },
1291
+ { id: 'f-stale', label: 'Age', options: STALE_CHOICES },
1194
1292
  {
1195
1293
  id: 'f-fit',
1196
1294
  label: 'Run mode',
@@ -1203,6 +1301,20 @@
1203
1301
  onChange: () => {
1204
1302
  pagers.ready.page = 1;
1205
1303
  pagers.backlog.page = 1;
1304
+ // task 1003278 — Creator and Age are answered by the SERVER, so a change
1305
+ // to either re-fetches a narrower feed instead of re-reducing the wide
1306
+ // one. The test is whether the SERVER-FACING QUESTION changed, not which
1307
+ // control fired: makeFilterBar's onChange is handed every value, not the
1308
+ // changed id (hall-kit.js:269), and comparing the query is the direct
1309
+ // question anyway — a control that does not appear in it can never cost
1310
+ // a round-trip, which is what keeps the other filters instant.
1311
+ const q = serverFilterQuery();
1312
+ if (q !== lastServerFilterQuery) {
1313
+ lastServerFilterQuery = q;
1314
+ refreshLive();
1315
+ loadBacklog();
1316
+ return;
1317
+ }
1206
1318
  renderClaimable();
1207
1319
  renderBacklog();
1208
1320
  },
@@ -429,6 +429,10 @@ async function routeIdeaToGoal({
429
429
  // (db.findTaskBySourceRef): one idea can only ever mint one routed task.
430
430
  source: 'idea-route',
431
431
  sourceRef: `idea-${idea.id}`,
432
+ // task 1003278 — the filer created this task, via the route. capturedBy is
433
+ // already the authenticated filer's id (never body-supplied), so routing an
434
+ // idea attributes the task to the person whose idea it was.
435
+ createdBy: capturedBy ?? null,
432
436
  }, { client });
433
437
 
434
438
  // d. The lineage row — origin='route' (ideas_003 widened the CHECK for
@@ -72,6 +72,11 @@ async function createTask({
72
72
  // (requiresRank stays null for the goal vector → derived purely from touches +
73
73
  // sensitivity), so goal_id never influences the requires_rank clamp.
74
74
  goalId = null,
75
+ // task 1003278: WHO created it — the builder id from the authenticated request.
76
+ // Nullable, appended at the end (the goals bind-order convention): a system
77
+ // path with no builder in the request (a seed, a migration backfill) passes
78
+ // nothing and the row honestly reads unattributed, never guessed.
79
+ createdBy = null,
75
80
  }, 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
81
  // task 1498 / ADR 0085: every task carries a deliberate rank floor. Derive it
77
82
  // from touches + sensitivity, then let an explicit requiresRank override only
@@ -112,12 +117,12 @@ async function createTask({
112
117
  est_minutes, est_cost_usd, manual_degree, priority,
113
118
  automation_tag, credits_reward, source, source_ref, value_summary,
114
119
  kind, discipline, parallel_safe, newcomer_friendly, needs_migration,
115
- security_sensitive, requires_rank, module_key, goal_id, promoted_at)
120
+ security_sensitive, requires_rank, module_key, goal_id, created_by, promoted_at)
116
121
  VALUES ($1, $2, $3, $4, $5,
117
122
  $6, $7, $8, $9,
118
123
  $10, $11, $12, $13, $14,
119
124
  $15, $16, $17, $18, $19,
120
- $20, $21, $22, $23,
125
+ $20, $21, $22, $23, $24,
121
126
  CASE WHEN $4 IN ('ready','active') THEN now() ELSE NULL END)
122
127
  RETURNING *`,
123
128
  [
@@ -144,6 +149,7 @@ async function createTask({
144
149
  requiresRankEffective,
145
150
  moduleKey ?? null,
146
151
  goalIdEffective ?? null,
152
+ createdBy ?? null,
147
153
  ]
148
154
  );
149
155
  // task 1677 / ADR 0096: audit each creation-time auto-assignment so the trail is
@@ -473,7 +479,7 @@ async function getTask(id) {
473
479
  }
474
480
 
475
481
 
476
- async function listTasks({ versionId, status, kind, discipline, goalId } = {}) {
482
+ async function listTasks({ versionId, status, kind, discipline, goalId, createdBy, staleDays, limit, offset } = {}) {
477
483
  const params = [];
478
484
  const where = [];
479
485
  if (versionId) {
@@ -496,13 +502,44 @@ async function listTasks({ versionId, status, kind, discipline, goalId } = {}) {
496
502
  params.push(goalId);
497
503
  where.push(`goal_id = $${params.length}`);
498
504
  }
505
+ // task 1003278 — the creator + staleness axes, server-side. They exist HERE and
506
+ // not only in the browser because the board was reducing the whole table to
507
+ // answer them: GET /tasks?status=backlog measured 690,625 bytes on 2026-08-25,
508
+ // and the Goals page's whole-version read 5.2 MB. `'none'` selects the
509
+ // unattributed (created before tasks.created_by existed, or by a system path) —
510
+ // the goals owner-filter convention, and a real question ("what has no owner").
511
+ if (createdBy === 'none') {
512
+ where.push('created_by IS NULL');
513
+ } else if (createdBy) {
514
+ params.push(createdBy);
515
+ where.push(`created_by = $${params.length}`);
516
+ }
517
+ // Untouched for N days, on the SAME clock the clients use: max(updated_at,
518
+ // shipped_at) — a ship path can stamp shipped_at without touching updated_at.
519
+ // A row with neither is stale by construction (GREATEST of two epochs), which
520
+ // matches isStale()'s treatment of an undated row: a row nobody can date is
521
+ // what a rot-hunt must not hide.
522
+ if (staleDays) {
523
+ params.push(staleDays);
524
+ where.push(
525
+ `GREATEST(COALESCE(updated_at, 'epoch'::timestamptz), COALESCE(shipped_at, 'epoch'::timestamptz))
526
+ <= now() - ($${params.length}::int * INTERVAL '1 day')`
527
+ );
528
+ }
499
529
  const sql =
500
530
  `SELECT t.*, ${CREDITS_AWARDED_SUBQUERY} AS credits_awarded,
501
531
  pms_calibrated_minutes(t.est_minutes, t.kind) AS est_minutes_calibrated
502
532
  FROM tasks t` +
503
533
  (where.length ? ` WHERE ${where.join(' AND ')}` : '') +
504
534
  ` ORDER BY priority NULLS LAST, created_at`;
505
- const { rows } = await pool.query(sql, params);
535
+ // BOUND, not interpolated. Both call sites validate to a finite non-negative
536
+ // integer today, so string interpolation would not be exploitable — but a
537
+ // query builder that is only safe because of who happens to call it is one
538
+ // careless caller away from not being safe at all (raised in review).
539
+ let tail = '';
540
+ if (limit) { params.push(Number(limit)); tail += ` LIMIT $${params.length}`; }
541
+ if (offset) { params.push(Number(offset)); tail += ` OFFSET $${params.length}`; }
542
+ const { rows } = await pool.query(sql + tail, params);
506
543
  return rows;
507
544
  }
508
545
 
@@ -571,7 +608,7 @@ const SESSION_FIT_SQL = `
571
608
  END AS fits_session_modes
572
609
  FROM tasks t`;
573
610
 
574
- async function listClaimableTasks({ versionId = null, builderId = null, discipline = null, fit = null } = {}) {
611
+ async function listClaimableTasks({ versionId = null, builderId = null, discipline = null, fit = null, createdBy = null, staleDays = null } = {}) {
575
612
  const params = [];
576
613
  const where = [];
577
614
  let goalJoin = '';
@@ -584,6 +621,25 @@ async function listClaimableTasks({ versionId = null, builderId = null, discipli
584
621
  params.push(discipline);
585
622
  where.push(`v.discipline = $${params.length}`);
586
623
  }
624
+ // task 1003278 — the SAME creator + staleness axes GET /tasks offers, so the
625
+ // Work Board asks the server a narrow question instead of pulling the feed and
626
+ // reducing it in the browser. 'none' selects the unattributed; the staleness
627
+ // clock is max(updated_at, shipped_at), identical to listTasks, the rollup SQL
628
+ // and task-lib.js — four expressions of one rule, pinned against each other by
629
+ // tests/goal_sort_staleness.mjs.
630
+ if (createdBy === 'none') {
631
+ where.push('v.created_by IS NULL');
632
+ } else if (createdBy) {
633
+ params.push(createdBy);
634
+ where.push(`v.created_by = $${params.length}`);
635
+ }
636
+ if (staleDays) {
637
+ params.push(staleDays);
638
+ where.push(
639
+ `GREATEST(COALESCE(v.updated_at, 'epoch'::timestamptz), COALESCE(v.shipped_at, 'epoch'::timestamptz))
640
+ <= now() - ($${params.length}::int * INTERVAL '1 day')`
641
+ );
642
+ }
587
643
  if (builderId) {
588
644
  params.push(builderId);
589
645
  where.push(`NOT EXISTS (