@bongos/core 1.19.660 → 1.19.662

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.
@@ -201,7 +201,7 @@ const PERMISSIONS = [
201
201
  { key: 'criterion.unsatisfy', system: false, floor: 'metic', guards: 'POST /done-when/:id/unsatisfy (ADR 0157, was archon)' },
202
202
  { key: 'security.report.adjudicate', system: false, floor: 'metic', guards: 'GET /security/reports, POST /security/reports/:id/{confirm,dispute,fix,link-fix}, /security/docs CRUD (ADR 0157, was archon)' },
203
203
  { key: 'provisioning.fleet.manage', system: false, floor: 'metic', guards: 'GET /provisioning/fleet[/cost-ledger], POST .../force-teardown (ADR 0157, was archon)' },
204
- { key: 'box.fleet.manage', system: false, floor: 'metic', scope: 'any', resource: 'box', guards: 'GET /boxes[/cost-ledger], POST /boxes/:b/close, PATCH .../block (ADR 0157, was archon)' },
204
+ { key: 'box.fleet.manage', system: false, floor: 'metic', scope: 'any', resource: 'box', verb: 'manage', guards: 'GET /boxes[/cost-ledger], POST /boxes/:b/close, PATCH .../block (ADR 0157, was archon)' },
205
205
  { key: 'autonomy.precheck', system: false, floor: 'metic', guards: 'GET /autonomy/precheck — unsupervised-runner gate (ADR 0157, was archon)' },
206
206
  { key: 'discord.channels.manage', system: false, floor: 'metic', guards: 'GET /discord/channels/plan, POST .../reconcile (ADR 0157, was archon)' },
207
207
  // Metic by the discord.channels.manage analogue — configuring a project-wide
@@ -213,7 +213,7 @@ const PERMISSIONS = [
213
213
  { key: 'project.settings.manage', system: false, floor: 'metic', guards: "PATCH /project-settings/* — the project's own live knobs (the rot timer today) and the work-category set's shape (PATCH /work-categories/:id). Metic, matching goal.create's reasoning that extending the project's vocabulary is a planning act rather than an administrative one — task 1003280, goal 1000072" },
214
214
  { key: 'project.curate', system: false, floor: 'metic', guards: 'platform-identity: GET/POST /projects, /projects/invite, PATCH /projects/:id/featured, PATCH /projects/:id/status (the takedown lever — ADR 0252 §5.1), GET /scouting (ADR 0157, was archon)' },
215
215
  { key: 'override_request.decide', system: false, floor: 'metic', guards: 'GET /override-requests, POST .../:id/decide (ADR 0157, was archon)' },
216
- { key: 'claim.release.any', system: false, floor: 'metic', scope: 'any', resource: 'claim', guards: 'POST /claims/:id/release-on-behalf (ADR 0157, was archon)' },
216
+ { key: 'claim.release.any', system: false, floor: 'metic', scope: 'any', resource: 'claim', verb: 'release', guards: 'POST /claims/:id/release-on-behalf (ADR 0157, was archon)' },
217
217
  { key: 'idea.ratify', system: false, floor: 'metic', guards: 'POST /inbox/:id/ratify (ADR 0157, was archon)' },
218
218
  // ── the Board Room (ADR 0175, goal 1000069) ──────────────────────────────────
219
219
  // Declared BEFORE their routes exist (R07/R08 add them), and deliberately so:
@@ -236,7 +236,7 @@ const PERMISSIONS = [
236
236
  { key: 'goal.reorder', system: false, floor: 'metic', guards: 'POST /goals/reorder (ADR 0157, was archon)' },
237
237
  { key: 'builder.roster.read', system: false, floor: 'metic', guards: 'GET /builders/roster, /grades/by-builder, /builders/:id/active-claims (ADR 0157, was archon)' },
238
238
  { key: 'session.search', system: false, floor: 'metic', guards: 'GET /sessions/search (ADR 0157, was archon)' },
239
- { key: 'memory.read.any', system: false, floor: 'metic', scope: 'any', resource: 'memory', guards: 'GET /memory/builders/:id/file[s] — cross-builder memory read (ADR 0157, was archon)' },
239
+ { key: 'memory.read.any', system: false, floor: 'metic', scope: 'any', resource: 'memory', verb: 'read', guards: 'GET /memory/builders/:id/file[s] — cross-builder memory read (ADR 0157, was archon)' },
240
240
  { key: 'audit_log.read', system: false, floor: 'metic', guards: 'GET /audit-log (ADR 0157, was archon)' },
241
241
  // ADR 0157 §4. Split from `gate_approval.decide` (system, archon) so the Gate PAGE
242
242
  // can open to Metic while APPROVING a held PR stays Archon. Without the split, a
@@ -265,20 +265,32 @@ const PERMISSIONS = [
265
265
  // deliberately NOT lowered to xenos here (the board.vote.cast option): a floor
266
266
  // feeds RANK_SEED and the live reset route, so widening it is an authority change
267
267
  // that wants its own decision, not a note in a hardening batch.
268
- { key: 'goal.manage.own', system: false, floor: 'metic', scope: 'own', resource: 'goal', guards: 'PATCH /goals/:id/members/:b, POST /goals/:id/transfer — requireBuilder + in-handler owner|manager|Archon (ADR 0106); floor is not the whole gate, and sub-metic owners/managers hold it today' },
268
+ { key: 'goal.manage.own', system: false, floor: 'metic', scope: 'own', resource: 'goal', verb: 'manage', counterpart: [], guards: 'PATCH /goals/:id/members/:b, POST /goals/:id/transfer — requireBuilder + in-handler owner|manager|Archon (ADR 0106); floor is not the whole gate, and sub-metic owners/managers hold it today' },
269
269
  { key: 'task.claim.newcomer', system: false, floor: 'xenos', guards: 'claim a newcomer_friendly task (xenosClaimAllowed; xenos = one active claim)' },
270
270
  { key: 'task.ship', system: false, floor: 'xenos', guards: '/builder-ship own claim' },
271
- { key: 'task.act.own', system: false, floor: 'xenos', scope: 'own', resource: 'claim', guards: 'act on OWN active claim (release, cost, notes) — ownership-scoped' },
272
- { key: 'box.manage.own', system: false, floor: 'xenos', scope: 'own', resource: 'box', guards: 'own dev-box lifecycle ownership-scoped' },
273
- { key: 'memory.manage.own', system: false, floor: 'xenos', scope: 'own', resource: 'memory', guards: 'own builder memory — ownership-scoped' },
271
+ // counterpart is EMPTY, and that is the fix of task 1003195, not an omission.
272
+ // `act` is a BUNDLE release + cost + notes and the family's only cross-owner
273
+ // key, `claim.release.any`, carries just the release third. It is therefore not a
274
+ // counterpart: admitting on it would let a release-only grant post costs and notes
275
+ // against another builder's claim. A future `claim.manage.any` (verb `manage`)
276
+ // would dominate `act` and belongs here; `claim.release.any` never does.
277
+ { key: 'task.act.own', system: false, floor: 'xenos', scope: 'own', resource: 'claim', verb: 'act', counterpart: [], guards: 'act on OWN active claim (release, cost, notes) — ownership-scoped' },
278
+ // The one family whose counterpart is REAL: `box.fleet.manage` is the same `manage`
279
+ // verb over every owner's box, so it genuinely subsumes managing your own.
280
+ { key: 'box.manage.own', system: false, floor: 'xenos', scope: 'own', resource: 'box', verb: 'manage', counterpart: ['box.fleet.manage'], guards: 'own dev-box lifecycle — ownership-scoped' },
281
+ // Empty for the same reason as task.act.own: `memory.read.any` READS every
282
+ // builder's memory, which is not the authority to MANAGE one. This is the pair the
283
+ // task-1002671 verification probed (finding 12) — a read grant was admitting a
284
+ // write. A future `memory.manage.any` would belong here.
285
+ { key: 'memory.manage.own', system: false, floor: 'xenos', scope: 'own', resource: 'memory', verb: 'manage', counterpart: [], guards: 'own builder memory — ownership-scoped' },
274
286
  // No cross-owner counterpart, deliberately: nobody manages another builder's
275
287
  // credential, Archon included. A `credential.manage.any` would be a key no route
276
288
  // consumes — the catalog's first rule forbids minting one for symmetry.
277
- { key: 'credential.manage.own', system: false, floor: 'xenos', scope: 'own', resource: 'credential', guards: 'own API key / credential — ownership-scoped' },
289
+ { key: 'credential.manage.own', system: false, floor: 'xenos', scope: 'own', resource: 'credential', verb: 'manage', counterpart: [], guards: 'own API key / credential — ownership-scoped' },
278
290
  // Own-scoped WITHOUT a `.own` suffix — the `me.` namespace is the ownership
279
291
  // marker here. Exactly why scope is declared data: the suffix rule only runs the
280
292
  // safe direction ('.own' ⇒ scope 'own'), never the converse.
281
- { key: 'me.wandering.set', system: false, floor: 'xenos', scope: 'own', resource: 'builder_prefs', guards: 'PATCH /me/wandering (clamped by maxLevelForRank) — ownership-scoped' },
293
+ { key: 'me.wandering.set', system: false, floor: 'xenos', scope: 'own', resource: 'builder_prefs', verb: 'set', counterpart: [], guards: 'PATCH /me/wandering (clamped by maxLevelForRank) — ownership-scoped' },
282
294
  { key: 'cost.log', system: false, floor: 'xenos', guards: 'POST /cost' },
283
295
  { key: 'learning.create', system: false, floor: 'xenos', guards: 'POST /learnings' },
284
296
  { key: 'blocker.file', system: false, floor: 'xenos', guards: 'POST /blockers' },
@@ -296,6 +308,31 @@ const PERMISSIONS = [
296
308
  const PRINCIPAL_KEY_PREFIX = 'principal.';
297
309
  const OWN_KEY_SUFFIX = '.own';
298
310
  const OWNERSHIP_SCOPES = Object.freeze(['own', 'any']);
311
+
312
+ // ── the VERB axis of an ownership-scoped key (task 1003195) ────────────────────
313
+ // Scope answers "whose row"; VERB answers "how much of it". They are independent,
314
+ // and conflating them is exactly the bug this vocabulary closes: `crossOwnerKeysFor`
315
+ // used to hand back the resource family's whole `any` list VERB-BLIND, so holding
316
+ // only `memory.read.any` satisfied a `memory.manage.own` decision on someone else's
317
+ // row — a read grant admitting a write.
318
+ //
319
+ // The verb is DECLARED, never parsed out of the key string. A positional parse is
320
+ // wrong on the live population (`box.fleet.manage` is resource `box`, verb `manage`
321
+ // — the middle segment is not the verb), and the same reason `scope` is data:
322
+ // "the namespace can never outrun the data".
323
+ const OWNERSHIP_VERBS = Object.freeze(['read', 'set', 'release', 'act', 'manage']);
324
+ // Which verbs a verb SUBSUMES, beyond itself (every verb dominates itself). Kept
325
+ // deliberately small and explicit: `manage` is the full-control verb, so it covers
326
+ // every narrower slice; nothing else subsumes anything. A future pair that needs a
327
+ // wider relation adds one entry here and states why — it does not get inferred.
328
+ const VERB_DOMINANCE = Object.freeze({
329
+ manage: Object.freeze(['read', 'set', 'release', 'act']),
330
+ });
331
+ // Does `wide` carry at least everything `narrow` carries? Unknown verbs fail closed.
332
+ function verbDominates(wide, narrow) {
333
+ if (!OWNERSHIP_VERBS.includes(wide) || !OWNERSHIP_VERBS.includes(narrow)) return false;
334
+ return wide === narrow || (VERB_DOMINANCE[wide] || []).includes(narrow);
335
+ }
299
336
  const _byKey = new Map();
300
337
  const _byPrincipal = new Map();
301
338
  const _byResource = new Map();
@@ -376,6 +413,36 @@ for (let i = 0; i < PERMISSIONS.length; i++) {
376
413
  if (scope !== null && p.floor === null) {
377
414
  throw new Error(`government catalog: ${p.key} is ownership-scoped but has no rank floor — the ownership axis narrows a RANK permission, never an identity one`);
378
415
  }
416
+ // ── the VERB axis (task 1003195) ──────────────────────────────────────────
417
+ // Same partition posture as scope ⟺ resource: an ownership-scoped key declares
418
+ // HOW MUCH of the row it reaches, and an unscoped key has no owner axis to
419
+ // qualify. Both directions throw, so neither field can be added without the other.
420
+ const verb = p.verb ?? null;
421
+ if ((scope === null) !== (verb === null)) {
422
+ throw new Error(`government catalog: ${p.key} — scope and verb must come together (an ownership scope always names the verb it reaches the row with)`);
423
+ }
424
+ if (verb !== null && !OWNERSHIP_VERBS.includes(verb)) {
425
+ throw new Error(`government catalog: ${p.key} has unknown verb "${verb}" (expected one of ${OWNERSHIP_VERBS.join(', ')})`);
426
+ }
427
+ // ── the COUNTERPART declaration (task 1003195) ────────────────────────────
428
+ // REQUIRED on every own-scoped key and forbidden everywhere else. Required,
429
+ // because the bug being fixed was a silent DERIVATION: crossOwnerKeysFor fell
430
+ // back to the resource family's whole `any` list, so a new own key inherited
431
+ // every cross-owner key in its family — verbs unchecked — merely by existing.
432
+ // Making the escalation path an explicit per-key statement means a new own key
433
+ // fails LOUD at load until its author decides what may escalate past it, and
434
+ // `counterpart: []` is that decision written down, not its absence.
435
+ const hasCounterpart = p.counterpart !== undefined;
436
+ if (scope === 'own' && !hasCounterpart) {
437
+ throw new Error(`government catalog: ${p.key} is own-scoped and must declare counterpart: [] explicitly — the cross-owner escalation path is never derived from the resource family (task 1003195)`);
438
+ }
439
+ if (scope !== 'own' && hasCounterpart) {
440
+ throw new Error(`government catalog: ${p.key} declares a counterpart but is not own-scoped — only an ownership-scoped key has an escalation path`);
441
+ }
442
+ if (hasCounterpart && (!Array.isArray(p.counterpart) || p.counterpart.some((k) => typeof k !== 'string' || !k))) {
443
+ throw new Error(`government catalog: ${p.key} — counterpart must be an array of permission keys`);
444
+ }
445
+ const counterpart = hasCounterpart ? Object.freeze([...p.counterpart]) : null;
379
446
  // ONE object per permission, shared by every reader. The frozen copy goes back
380
447
  // into PERMISSIONS[i] as well as the lookup maps, because the alternative —
381
448
  // freezing a COPY and leaving the original mutable in the array — splits the
@@ -383,7 +450,7 @@ for (let i = 0; i < PERMISSIONS.length; i++) {
383
450
  // seals the array's SHAPE but not its entries, so a runtime mutation of
384
451
  // PERMISSIONS[i] would change the floor seedForRank() reads (and with it the
385
452
  // live POST /government/ranks/:rankKey/reset) while byKey() kept the old value.
386
- const frozen = Object.freeze({ ...p, principal, scope, resource, substrate });
453
+ const frozen = Object.freeze({ ...p, principal, scope, resource, verb, counterpart, substrate });
387
454
  PERMISSIONS[i] = frozen;
388
455
  _byKey.set(p.key, frozen);
389
456
  if (principal !== null) _byPrincipal.set(principal, frozen);
@@ -416,6 +483,32 @@ for (const [resource, fam] of _byResource) {
416
483
  }
417
484
  }
418
485
 
486
+ // ── the counterpart invariant: a declared escalation path must DOMINATE ────────
487
+ // The floor check above orders the two keys by PRICE; this one orders them by
488
+ // POWER, and only the pair together is the wall. A counterpart is an admit: the
489
+ // resolver returns true on it WITHOUT consulting the owner at all, so whatever it
490
+ // names must carry at least everything the own key carries. Naming a narrower verb
491
+ // converts the own key into that verb's cross-owner form for free — which is
492
+ // precisely how `memory.read.any` came to satisfy `memory.manage.own`.
493
+ for (const p of PERMISSIONS) {
494
+ if (p.scope !== 'own') continue;
495
+ for (const key of p.counterpart) {
496
+ const c = _byKey.get(key);
497
+ if (!c) {
498
+ throw new Error(`government catalog: ${p.key} names counterpart "${key}", which is not a permission in this catalog`);
499
+ }
500
+ if (c.scope !== 'any') {
501
+ throw new Error(`government catalog: ${p.key} names counterpart ${key}, which is not cross-owner (scope "${c.scope ?? 'none'}") — only an 'any' key reaches another owner's row`);
502
+ }
503
+ if (c.resource !== p.resource) {
504
+ throw new Error(`government catalog: ${p.key} (resource ${p.resource}) names counterpart ${key} from resource ${c.resource} — an escalation path never crosses resource families`);
505
+ }
506
+ if (!verbDominates(c.verb, p.verb)) {
507
+ throw new Error(`government catalog: ${p.key} names counterpart ${key}, but verb "${c.verb}" does not dominate "${p.verb}" — a cross-owner key may only satisfy an own key it carries at least as much authority as (task 1003195)`);
508
+ }
509
+ }
510
+ }
511
+
419
512
  // resource → { own: [keys], any: [keys] }. The pairing data a gate, a test, or the
420
513
  // Archon tab reads to relate "act on own X" to "act on any X"; DERIVED from the
421
514
  // rows above so it cannot drift from them.
@@ -547,6 +640,12 @@ function resourceOf(key) {
547
640
  const p = _byKey.get(key);
548
641
  return p ? p.resource : null;
549
642
  }
643
+ // How much of the row this key reaches. Null for an unscoped or unknown key — the
644
+ // verb only means anything alongside an ownership scope.
645
+ function verbOf(key) {
646
+ const p = _byKey.get(key);
647
+ return p ? p.verb : null;
648
+ }
550
649
  // Does holding this key require an ownership predicate against the concrete row?
551
650
  // This is the question a gate must ask BEFORE treating a held permission as an
552
651
  // admit — an ownership-scoped key is never sufficient on its own.
@@ -563,14 +662,21 @@ function ownershipScopedKeys() {
563
662
  function crossOwnerKeys() {
564
663
  return PERMISSIONS.filter((p) => p.scope === 'any').map((p) => p.key);
565
664
  }
566
- // The cross-owner keys over the SAME resource as `key` — what a route offers as
567
- // the escalation path ("your own claim, or claim.release.any"). Empty when the
568
- // resource has no cross-owner form by design.
665
+ // The DECLARED escalation path for `key` — what a route offers as the alternative
666
+ // to owning the row ("your own box, or box.fleet.manage"). Empty when nothing may
667
+ // escalate past it, which is the common case.
668
+ //
669
+ // Read straight off the key's own `counterpart`, NOT derived from its resource
670
+ // family (task 1003195). The family form was verb-blind: it handed back every `any`
671
+ // key over the resource, so `memory.read.any` — a READ grant — satisfied a
672
+ // `memory.manage.own` WRITE decision on another builder's row. Every entry returned
673
+ // here has been checked at load to be cross-owner, same-resource, and carrying a
674
+ // verb that dominates this key's, so the resolver can admit on one without
675
+ // consulting the owner. An unknown or non-own key has no path at all.
569
676
  const NO_KEYS = Object.freeze([]);
570
677
  function crossOwnerKeysFor(key) {
571
- const resource = resourceOf(key);
572
- const fam = resource === null ? null : RESOURCE_FAMILIES[resource];
573
- return fam ? fam.any : NO_KEYS;
678
+ const p = _byKey.get(key);
679
+ return p && p.counterpart ? p.counterpart : NO_KEYS;
574
680
  }
575
681
 
576
682
  // The compat seed, DERIVED from floors (cumulative supersets). A rank holds
@@ -615,4 +721,8 @@ module.exports = {
615
721
  ownershipScopedKeys,
616
722
  crossOwnerKeys,
617
723
  crossOwnerKeysFor,
724
+ OWNERSHIP_VERBS,
725
+ VERB_DOMINANCE,
726
+ verbDominates,
727
+ verbOf,
618
728
  };
@@ -197,6 +197,12 @@
197
197
  font-variant-numeric: tabular-nums; min-width: 3.2em;
198
198
  }
199
199
  .history__title { flex: 1 1 auto; color: var(--ink); min-width: 0; }
200
+ /* task 1003828 — the goal a backlog row belongs to, on its own line under the
201
+ title. The SUB-LINE voice (the .scroll__lede tone), not a tag: a tag would read
202
+ as one more equal chip beside version/discipline/rank, and where a task LIVES
203
+ is the parent fact those chips sit inside. Wraps rather than truncating —
204
+ a goal title a person cannot finish reading is the state this row is fixing. */
205
+ .history__goal { display: inline-block; margin-top: 3px; color: var(--ink-soft); font-size: 12.5px; }
200
206
  .history__when { color: var(--ink-soft); font-size: 12.5px; white-space: nowrap; font-variant-numeric: tabular-nums; }
201
207
 
202
208
  @media (max-width: 640px) {
@@ -52,6 +52,24 @@
52
52
  Deep links: /work#in-progress etc. -->
53
53
  <nav id="work-tabs" aria-label="Task sections"></nav>
54
54
 
55
+ <!-- task 2170: the filter bar is built by OTBKit.makeFilterBar —
56
+ Search / Sort / Version / Discipline primary (task 1003828 promoted
57
+ Discipline out of the overflow: a craft is the axis this platform
58
+ organises work by), the rest behind a "Filters" disclosure; state
59
+ rides the URL query. Control ids are unchanged (f-search, f-sort,
60
+ f-version, f-goal, f-kind, f-discipline, f-newcomer, f-fit) so
61
+ work.js reads stay stable.
62
+
63
+ task 1003828 moved it OUT of #claim-scroll and above the panels. It
64
+ lived inside the Ready section, which makeTabs hides — so on the
65
+ Backlog tab the filters were not merely inert, they were not on the
66
+ screen at all. It drives the two QUEUE tabs (Ready, Backlog) through
67
+ the one matchesBoardFilters predicate, and work.js hides it on In
68
+ Progress and Completed, which render different objects (live claims,
69
+ shipped history) that these lenses do not describe. A filter bar
70
+ above a list it cannot narrow is the bug this task is fixing. -->
71
+ <div id="work-filters"></div>
72
+
55
73
  <!-- READY — the claimable queue: the page's primary object, above the
56
74
  fold as the default tab. -->
57
75
  <!-- task 1003304: BARE, and that is the whole re-skin. In v3 a claimable
@@ -64,12 +82,6 @@
64
82
  <h2 id="claim-h" class="scroll__h">Ready — tasks to do<span class="count-pill" id="ready-count" hidden></span></h2>
65
83
  <p class="scroll__lede">Click <span class="kbd">Claim</span> to take a task right here — no terminal needed. Then click <span class="kbd">📋</span> to copy the task prompt and paste it into a fresh Claude Code session to begin.</p>
66
84
 
67
- <!-- task 2170: the filter bar is built by OTBKit.makeFilterBar —
68
- Search / Sort / Version primary, the rest behind a "Filters"
69
- disclosure; state rides the URL query. Control ids are unchanged
70
- (f-search, f-sort, f-version, f-goal, f-kind, f-discipline,
71
- f-newcomer, f-fit) so work.js reads stay stable. -->
72
- <div id="work-filters"></div>
73
85
  <p class="claim-count-line" id="claim-count" aria-live="polite"></p>
74
86
 
75
87
  <div class="task-list" id="task-list">
@@ -62,15 +62,23 @@
62
62
  // forever. Its own function because Creator/Age re-fetch it, same as the
63
63
  // claimable feed. Best-effort: on failure the section stays as it was.
64
64
  function loadBacklog() {
65
- const q = serverFilterQuery().replace(/^\?/, '&');
65
+ const q = backlogFilterQuery().replace(/^\?/, '&');
66
66
  return getJSON(`${API}/tasks?status=backlog&limit=${BACKLOG_LIMIT}${q}`)
67
- .then((r) => { taskState.backlog = r.tasks || []; renderBacklog(taskState.backlog); })
67
+ .then((r) => {
68
+ taskState.backlog = r.tasks || [];
69
+ renderBacklog(taskState.backlog);
70
+ // task 1003828 — the Goal filter draws on this feed too and races it.
71
+ // Re-offer the options now; populateGoalFilter keeps the selection.
72
+ populateGoalFilter();
73
+ })
68
74
  .catch(() => { /* backlog stays as it was */ });
69
75
  }
70
76
 
71
- // The last server-facing filter query we actually fetched with. Compared on
72
- // every filter change to decide re-fetch vs re-render.
77
+ // The last server-facing filter queries we actually fetched with one per
78
+ // feed, because the two routes answer DIFFERENT questions (see below).
79
+ // Compared on every filter change to decide re-fetch vs re-render.
73
80
  let lastServerFilterQuery = '';
81
+ let lastBacklogQuery = '';
74
82
 
75
83
  function serverFilterQuery() {
76
84
  const parts = [];
@@ -81,6 +89,24 @@
81
89
  return parts.length ? `?${parts.join('&')}` : '';
82
90
  }
83
91
 
92
+ // task 1003828 — the BACKLOG's server query, deliberately NOT shared with the
93
+ // claimable feed's. The routes differ (GET /tasks answers version/kind/
94
+ // discipline/goal_id; /tasks/claimable answers only version+discipline), and the
95
+ // backlog read is CAPPED at BACKLOG_LIMIT while the Ready feed is not — so a
96
+ // client-only narrow here would lie, hiding the artist tasks at row 501.
97
+ // '__none__' on Goal has no server predicate and is never sent.
98
+ function backlogFilterQuery() {
99
+ const f = boardFilterValues();
100
+ const parts = [];
101
+ if (f.creator) parts.push(`created_by=${encodeURIComponent(f.creator === '__none__' ? 'none' : f.creator)}`);
102
+ if (f.stale) parts.push(`stale_days=${encodeURIComponent(f.stale)}`);
103
+ if (f.version) parts.push(`version=${encodeURIComponent(f.version)}`);
104
+ if (f.kind) parts.push(`kind=${encodeURIComponent(f.kind)}`);
105
+ if (f.discipline) parts.push(`discipline=${encodeURIComponent(f.discipline)}`);
106
+ if (f.goal && f.goal !== '__none__') parts.push(`goal_id=${encodeURIComponent(f.goal)}`);
107
+ return parts.length ? `?${parts.join('&')}` : '';
108
+ }
109
+
84
110
  // task 1003278 — the ceiling on the Backlog read. Comfortably above today's
85
111
  // ~300 rows so nothing visibly changes now, and low enough that the page can
86
112
  // never be asked to hold the whole table as the project grows.
@@ -289,54 +315,76 @@
289
315
  return false;
290
316
  }
291
317
 
318
+ // task 1003828 — the board's filter lenses, read once from the DOM. ONE reader,
319
+ // so a control that exists can never be one a tab forgot to consult: before
320
+ // this, every dropdown was read inside renderClaimable and nowhere else, which
321
+ // left all six silently inert on the Backlog tab. ('__none__' on Creator
322
+ // isolates the unattributed — task 1003278.)
323
+ function boardFilterValues() {
324
+ const val = (id) => ($(id) ? $(id).value : '');
325
+ return {
326
+ search: searchQuery(),
327
+ version: val('#f-version'),
328
+ goal: val('#f-goal'),
329
+ kind: val('#f-kind'),
330
+ discipline: val('#f-discipline'),
331
+ newcomer: val('#f-newcomer'),
332
+ creator: val('#f-creator'),
333
+ stale: val('#f-stale'),
334
+ };
335
+ }
336
+
337
+ // Does one task survive the current lenses? Pure over (task, values) so both
338
+ // feeds narrow alike and a new filter cannot reach one tab alone.
339
+ function matchesBoardFilters(t, f) {
340
+ if (!matchesSearch(t, f.search)) return false;
341
+ if (f.version && t.version_id !== f.version) return false;
342
+ // task 1755: goal filter. '__none__' isolates tasks with no goal.
343
+ if (f.goal === '__none__' && t.goal_id != null) return false;
344
+ if (f.goal && f.goal !== '__none__' && String(t.goal_id) !== f.goal) return false;
345
+ if (f.kind && t.kind !== f.kind) return false;
346
+ if (f.discipline && (t.discipline || 'unclassified') !== f.discipline) return false;
347
+ if (f.newcomer === 'yes' && !t.newcomer_friendly) return false;
348
+ // Creator + Age were applied by the SERVER, so they are re-checked here only
349
+ // as a belt on a stale render — cheap, and it keeps the view honest if a poll
350
+ // lands between a control change and its re-fetch.
351
+ if (f.creator === '__none__' && t.created_by != null) return false;
352
+ if (f.creator && f.creator !== '__none__' && String(t.created_by) !== f.creator) return false;
353
+ if (f.stale && !isStale(taskActivityMs(t), f.stale)) return false;
354
+ return true;
355
+ }
356
+
357
+ // task 1756: run-mode — a task is "parallel-safe" when its touches[] don't
358
+ // overlap any OTHER task in the visible set. Set-RELATIVE, which is why it runs
359
+ // after matchesBoardFilters rather than inside it: it answers "of what I'm
360
+ // looking at, what can run together." O(n²) over touches — fine at board scale.
361
+ function applyRunMode(list, fitFilter) {
362
+ if (fitFilter !== 'parallel' && fitFilter !== 'conflict') return list;
363
+ return list.filter((t) => {
364
+ const conflicts = list.some((o) => o !== t && touchesConflict(t.touches, o.touches));
365
+ return fitFilter === 'parallel' ? !conflicts : conflicts;
366
+ });
367
+ }
368
+
369
+ // task 1003828 — the bar sits above the four panels now, so it must say which it
370
+ // speaks for. Ready and Backlog are the QUEUE tabs; In Progress (live claims)
371
+ // and Completed (shipped history) render other objects, so the bar hides there
372
+ // rather than offering a control that does nothing — this task's whole defect.
373
+ const FILTERED_TABS = new Set(['ready', 'backlog']);
374
+ function syncFilterBarVisibility(tabId) {
375
+ const bar = $('#work-filters');
376
+ if (bar) bar.hidden = !FILTERED_TABS.has(String(tabId));
377
+ }
378
+
292
379
  function renderClaimable() {
293
380
  const root = $('#task-list');
294
381
  if (!root) return;
295
- const vFilter = $('#f-version').value;
296
- const gFilter = $('#f-goal') ? $('#f-goal').value : '';
297
- const kFilter = $('#f-kind').value;
298
- const dFilter = $('#f-discipline').value;
299
- const nFilter = $('#f-newcomer') ? $('#f-newcomer').value : '';
300
- // task 1003278 — creator + staleness. '__none__' on Creator isolates the
301
- // unattributed (created before tasks.created_by existed, or by a system path)
302
- // — the goals owner filter's 'system / unattributed' convention.
303
- const cFilter = $('#f-creator') ? $('#f-creator').value : '';
304
- const staleFilter = $('#f-stale') ? $('#f-stale').value : '';
382
+ const filters = boardFilterValues();
305
383
  const sortBy = $('#f-sort').value;
306
- const sQuery = searchQuery();
307
-
308
- let list = taskState.all.filter((t) => {
309
- if (!matchesSearch(t, sQuery)) return false;
310
- if (vFilter && t.version_id !== vFilter) return false;
311
- // task 1755: goal filter. '__none__' isolates tasks with no goal.
312
- if (gFilter === '__none__' && t.goal_id != null) return false;
313
- if (gFilter && gFilter !== '__none__' && String(t.goal_id) !== gFilter) return false;
314
- if (kFilter && t.kind !== kFilter) return false;
315
- if (dFilter && (t.discipline || 'unclassified') !== dFilter) return false;
316
- if (nFilter === 'yes' && !t.newcomer_friendly) return false;
317
- // Creator + Age were applied by the SERVER (serverFilterQuery), so they are
318
- // re-checked here only as a belt on a stale render — never as the primary
319
- // filter. Re-applying them is cheap and keeps the view honest if a poll
320
- // lands between a control change and its re-fetch.
321
- if (cFilter === '__none__' && t.created_by != null) return false;
322
- if (cFilter && cFilter !== '__none__' && String(t.created_by) !== cFilter) return false;
323
- if (staleFilter && !isStale(taskActivityMs(t), staleFilter)) return false;
324
- return true;
325
- });
326
384
 
327
- // task 1756: run-mode filter a task is "parallel-safe" when its touches[]
328
- // don't overlap any OTHER task in the currently-visible set; "conflict" is the
329
- // complement (would collide, so run them sequentially). Computed over the
330
- // already-filtered list so it answers "of what I'm looking at, what can run
331
- // together." O(n²) over touches — fine at board scale.
332
- const fitFilter = $('#f-fit') ? $('#f-fit').value : '';
333
- if (fitFilter === 'parallel' || fitFilter === 'conflict') {
334
- const base = list;
335
- list = base.filter((t) => {
336
- const conflicts = base.some((o) => o !== t && touchesConflict(t.touches, o.touches));
337
- return fitFilter === 'parallel' ? !conflicts : conflicts;
338
- });
339
- }
385
+ let list = taskState.all.filter((t) => matchesBoardFilters(t, filters));
386
+
387
+ list = applyRunMode(list, $('#f-fit') ? $('#f-fit').value : '');
340
388
 
341
389
  if (sortBy === 'cpm') {
342
390
  list = list.slice().sort((a, b) => creditsPerMinute(b) - creditsPerMinute(a));
@@ -508,7 +556,10 @@
508
556
  if (!sel) return;
509
557
  const cur = sel.value;
510
558
  const titles = taskState.goalTitles || {};
511
- const ids = [...new Set(taskState.all.map((t) => t.goal_id).filter((x) => x != null).map(String))];
559
+ // task 1003828 options come from BOTH feeds: built from the claimable one
560
+ // alone, a goal whose only work sits at backlog was unofferable.
561
+ const rows = [...taskState.all, ...(taskState.backlog || [])];
562
+ const ids = [...new Set(rows.map((t) => t.goal_id).filter((x) => x != null).map(String))];
512
563
  ids.sort((a, b) => String(titles[a] || `Goal #${a}`).localeCompare(String(titles[b] || `Goal #${b}`)));
513
564
  sel.length = 1; // keep the leading "— all goals —" option
514
565
  for (const id of ids) {
@@ -517,7 +568,7 @@
517
568
  opt.textContent = titles[id] || `Goal #${id}`;
518
569
  sel.appendChild(opt);
519
570
  }
520
- if (taskState.all.some((t) => t.goal_id == null)) {
571
+ if (rows.some((t) => t.goal_id == null)) {
521
572
  const opt = document.createElement('option');
522
573
  opt.value = '__none__';
523
574
  opt.textContent = 'No goal';
@@ -1055,17 +1106,44 @@
1055
1106
  root.innerHTML = '<div class="profile__placeholder">Nothing in the backlog right now.</div>';
1056
1107
  return;
1057
1108
  }
1058
- const q = searchQuery();
1059
- const list = q ? backlogRows.filter((t) => matchesSearch(t, q)) : backlogRows;
1109
+ // task 1003828 — the SAME lenses the Ready tab uses. This read used to be
1110
+ // search-only, so six controls silently did nothing on the tab holding the
1111
+ // most rows. The server already narrowed this feed; re-applying the predicate
1112
+ // answers the two client-only lenses (search, Goal's '__none__') and keeps the
1113
+ // view honest between a control change and its re-fetch.
1114
+ const filters = boardFilterValues();
1115
+ let list = backlogRows.filter((t) => matchesBoardFilters(t, filters));
1116
+ list = applyRunMode(list, $('#f-fit') ? $('#f-fit').value : '');
1060
1117
  const countEl = $('#backlog-count');
1061
1118
  if (countEl) countEl.textContent = `${list.length} ${list.length === 1 ? 'task' : 'tasks'}`;
1119
+ if (!list.length) {
1120
+ root.innerHTML = '<div class="profile__placeholder">No backlog tasks match these filters. Try widening them.</div>';
1121
+ pagers.backlog.page = 1;
1122
+ return;
1123
+ }
1062
1124
  const shown = paginate(list, pagers.backlog);
1063
1125
  root.innerHTML = shown.map((t) => {
1064
1126
  const ver = t.version_id ? `<span class="task__tag">${escapeHtml(t.version_id)}</span>` : '';
1127
+ // task 1003828 — the row says WHOSE CRAFT and WHICH GOAL. A Ready card has
1128
+ // carried its discipline tag since 1755; a backlog row carried neither, so a
1129
+ // correctly-goaled task (an auto-filed artist review, say) read as
1130
+ // uncategorised in the one place a person goes looking for it. Same
1131
+ // vocabulary as the card, so the two tabs describe a task alike.
1132
+ const discipline = t.discipline || 'unclassified';
1133
+ const disciplineTag = discipline !== 'unclassified'
1134
+ ? `<span class="task__tag">${escapeHtml(discipline)}</span>`
1135
+ : '';
1136
+ // The goal's TITLE, from the map the Goal filter already loads, falling back
1137
+ // to its id before that resolves. A goal-less row says so rather than
1138
+ // rendering a gap — "no goal" is a real, findable state.
1139
+ const goalTitle = t.goal_id != null
1140
+ ? ((taskState.goalTitles || {})[String(t.goal_id)] || `Goal #${t.goal_id}`)
1141
+ : 'No goal';
1142
+ const goalTag = `<span class="history__goal" title="${escapeHtml(`Goal: ${goalTitle}`)}">${escapeHtml(goalTitle)}</span>`;
1065
1143
  return `
1066
1144
  <div class="history__row" data-task-id="${escapeHtml(String(t.id))}" title="Open task #${escapeHtml(String(t.id))}">
1067
1145
  <span class="history__id">#${escapeHtml(String(t.id))}</span>
1068
- <span class="history__title">${escapeHtml(t.title || '(untitled)')} ${ver} ${rankTagHtml(t.requires_rank)}</span>
1146
+ <span class="history__title">${escapeHtml(t.title || '(untitled)')} ${ver} ${disciplineTag} ${rankTagHtml(t.requires_rank)}<br>${goalTag}</span>
1069
1147
  <span class="history__when">${escapeHtml(t.kind || '')}</span>
1070
1148
  </div>`;
1071
1149
  }).join('');
@@ -1182,6 +1260,10 @@
1182
1260
  taskState.goalTitles[g.id] = g.title;
1183
1261
  taskState.goalCategories[g.id] = g.category || null;
1184
1262
  });
1263
+ // task 1003828 — loadBacklog() fires ABOVE this await, so its first paint
1264
+ // has no titles and every row would read "Goal #1000089" until something
1265
+ // else repainted it. Harmless if the feed has not landed yet.
1266
+ renderBacklog();
1185
1267
  } catch (_) { /* filter labels fall back to 'Goal #id' */ }
1186
1268
 
1187
1269
  // Claimable + active claims in one call. A 403 means the primer gate — show
@@ -1242,7 +1324,10 @@
1242
1324
  { id: 'backlog', label: 'Backlog', panel: 'backlog-scroll' },
1243
1325
  { id: 'completed', label: 'Completed', panel: 'history-scroll' },
1244
1326
  ],
1245
- onChange: () => { if (selectionTotal()) clearSelection(); },
1327
+ onChange: (id) => {
1328
+ if (selectionTotal()) clearSelection();
1329
+ syncFilterBarVisibility(id);
1330
+ },
1246
1331
  });
1247
1332
 
1248
1333
  // task 2170: filter diet — Search/Sort/Version primary, the rest behind
@@ -1266,6 +1351,16 @@
1266
1351
  ],
1267
1352
  },
1268
1353
  { id: 'f-version', label: 'Version', options: [{ value: '', label: '— any —' }] },
1354
+ // task 1003828 — Discipline is PRIMARY, not overflow. A craft is the axis
1355
+ // this platform organises people by (CLAUDE.md's role packs; a task's work
1356
+ // type IS its discipline), so "show me my craft's work" is a first-class
1357
+ // question, not one to find behind a disclosure. The mixed option form is
1358
+ // kit-supported — pinned by tests/goal_sort_staleness.mjs against this id.
1359
+ {
1360
+ id: 'f-discipline',
1361
+ label: 'Discipline',
1362
+ options: [{ value: '', label: '— any —' }, 'engineer', 'artist', 'ideator', 'ui', 'unclassified'],
1363
+ },
1269
1364
  ],
1270
1365
  overflow: [
1271
1366
  { id: 'f-goal', label: 'Goal', options: [{ value: '', label: '— all goals —' }] },
@@ -1276,11 +1371,6 @@
1276
1371
  'feature', 'bug', 'infra', 'refactor', 'spike', 'learning-capture',
1277
1372
  'cleanup', 'decision', 'blocker-resolution', 'unclassified'],
1278
1373
  },
1279
- {
1280
- id: 'f-discipline',
1281
- label: 'Discipline',
1282
- options: [{ value: '', label: '— any —' }, 'engineer', 'artist', 'ideator', 'ui', 'unclassified'],
1283
- },
1284
1374
  {
1285
1375
  id: 'f-newcomer',
1286
1376
  label: 'Newcomer',
@@ -1310,18 +1400,23 @@
1310
1400
  // changed id (hall-kit.js:269), and comparing the query is the direct
1311
1401
  // question anyway — a control that does not appear in it can never cost
1312
1402
  // a round-trip, which is what keeps the other filters instant.
1403
+ //
1404
+ // task 1003828 — decided SEPARATELY per feed now, because the backlog asks
1405
+ // the server more than claimable can answer. Each side re-fetches (ending
1406
+ // in its own render) or re-renders in place; the old early return left the
1407
+ // other tab's client-side lenses unapplied until the next poll.
1313
1408
  const q = serverFilterQuery();
1314
- if (q !== lastServerFilterQuery) {
1315
- lastServerFilterQuery = q;
1316
- refreshLive();
1317
- loadBacklog();
1318
- return;
1319
- }
1320
- renderClaimable();
1321
- renderBacklog();
1409
+ const bq = backlogFilterQuery();
1410
+ if (q !== lastServerFilterQuery) { lastServerFilterQuery = q; refreshLive(); } else { renderClaimable(); }
1411
+ if (bq !== lastBacklogQuery) { lastBacklogQuery = bq; loadBacklog(); } else { renderBacklog(); }
1322
1412
  },
1323
1413
  });
1324
1414
 
1415
+ // task 1003828 — makeTabs fires onChange only on a CHANGE and restores a
1416
+ // deep-linked tab before this point, so the opening state is set explicitly:
1417
+ // without it, a reload onto #completed shows a filter bar over ship history.
1418
+ syncFilterBarVisibility(workTabs && workTabs.active ? workTabs.active() : 'ready');
1419
+
1325
1420
  $('#task-list')?.addEventListener('click', (e) => {
1326
1421
  // task 1759: the multi-select checkbox. Toggle the Set + the card's
1327
1422
  // highlight class directly (cheaper than a full renderClaimable) and
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.660",
3
+ "version": "1.19.662",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.660",
9
+ "version": "1.19.662",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.660",
3
+ "version": "1.19.662",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",