@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.
- package/.bongos-core.json +18 -18
- package/docs/copy-inventory.md +55 -54
- package/docs/copy-registry.json +81 -72
- package/docs/module-api-changelog.md +4 -0
- package/modules/government/catalog.js +126 -16
- package/modules/hall-ui/public/work.css +6 -0
- package/modules/hall-ui/public/work.html +18 -6
- package/modules/hall-ui/public/work.js +161 -66
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/goal_sort_staleness.mjs +12 -1
- package/tests/government_ownership_scope.mjs +192 -11
- package/tests/hall_board_world.mjs +103 -0
|
@@ -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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
567
|
-
// the
|
|
568
|
-
//
|
|
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
|
|
572
|
-
|
|
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 =
|
|
65
|
+
const q = backlogFilterQuery().replace(/^\?/, '&');
|
|
66
66
|
return getJSON(`${API}/tasks?status=backlog&limit=${BACKLOG_LIMIT}${q}`)
|
|
67
|
-
.then((r) => {
|
|
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
|
|
72
|
-
//
|
|
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
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
1059
|
-
|
|
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: () => {
|
|
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
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|