@bongos/core 1.19.470 → 1.19.472

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 CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.470",
6
- "core_contract": "1.19.470",
7
- "source_commit": "dd9b08fd3b05ca54600d1cd9beaa771be0d8568d",
5
+ "core_version": "1.19.472",
6
+ "core_contract": "1.19.472",
7
+ "source_commit": "6b69181019333048f1fcb17d03f9d04b6b098fb5",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-02T14:48:18.482Z",
9
+ "built_at": "2026-09-02T15:04:57.327Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 410,
12
+ "docs_redacted": 411,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 1831,
14
+ "functional_verbatim": 1833,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2265,
20
- "tree_sha256": "669cadea9cb3ee85a8d995e4fd24179814f5b8475b753e228d71446493c85800",
19
+ "file_count": 2268,
20
+ "tree_sha256": "d824d0535e867ee19410330bc863104779eab940c7a8403f755f98805c928072",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -382,7 +382,7 @@
382
382
  {
383
383
  "path": "clients/bongos-client/index.d.ts",
384
384
  "mode": "0000644",
385
- "sha256": "2bce52b8070cda294371da71931bc7edde4fe431d99f06221d31c23d083e74d7"
385
+ "sha256": "6e1ae5c410c8796df7efcdcd9cbd0ccebe1075da8bc18d503c50196d0e7db683"
386
386
  },
387
387
  {
388
388
  "path": "clients/bongos-client/index.mjs",
@@ -1634,6 +1634,11 @@
1634
1634
  "mode": "0000644",
1635
1635
  "sha256": "f9f3a0b8ebe2d161e17e1c632bbbda71842c9476958294330420f28c6301f7db"
1636
1636
  },
1637
+ {
1638
+ "path": "docs/adr/0226-one-stale-timer-reporting-degrades-enforcement-refuses.md",
1639
+ "mode": "0000644",
1640
+ "sha256": "a3a854a3d04095c04abbe31239aa9931a482a6720d4a04077481e08d6deb6845"
1641
+ },
1637
1642
  {
1638
1643
  "path": "docs/adr/0227-a-task-network-map-is-refused-the-goal-graph-is-the-map.md",
1639
1644
  "mode": "0000644",
@@ -1642,7 +1647,7 @@
1642
1647
  {
1643
1648
  "path": "docs/adr/README.md",
1644
1649
  "mode": "0000644",
1645
- "sha256": "4c6f66d10d34f4c1c1e0ab6376868281b87e28a9f3b16ccfb75fafc004dcf8e2"
1650
+ "sha256": "93a49ca61bb1ea3e3bf9c3325bcae8b174278c8504fb1cc993c03d87bb6ba2f5"
1646
1651
  },
1647
1652
  {
1648
1653
  "path": "docs/api-reference.md",
@@ -1652,7 +1657,7 @@
1652
1657
  {
1653
1658
  "path": "docs/api/openapi.json",
1654
1659
  "mode": "0000644",
1655
- "sha256": "1e2b837a69669de3f76f189b3b868dbfc67c41cb7a5bbd773b465fa82a4e0cb2"
1660
+ "sha256": "fa675542b54c8bbd96530ecaac630deaf9873cc9091714eb9fde660e5af2ad4e"
1656
1661
  },
1657
1662
  {
1658
1663
  "path": "docs/architecture.md",
@@ -2132,7 +2137,7 @@
2132
2137
  {
2133
2138
  "path": "docs/module-api-changelog.md",
2134
2139
  "mode": "0000644",
2135
- "sha256": "8149596860cbbf60448d12ec3c229ee62cd7eb63524235a3464f68cb260b3c15"
2140
+ "sha256": "901d3e0044c9b6f0437df6424de404e6d367a0e2dceb7226eb3b1c4df7d906b8"
2136
2141
  },
2137
2142
  {
2138
2143
  "path": "docs/modules-contract.md",
@@ -4557,7 +4562,7 @@
4557
4562
  {
4558
4563
  "path": "modules/hall-ui/public/task-lib.js",
4559
4564
  "mode": "0000644",
4560
- "sha256": "dde9fe5cc1d80829c45aa6ef4f486697345a122cf0a7054991b7c7ebc390754f"
4565
+ "sha256": "3efb1199b1fc3c979b1c3aaa23d817241b48104dd1dfd40cbc20ed80b1a971cc"
4561
4566
  },
4562
4567
  {
4563
4568
  "path": "modules/hall-ui/public/task.css",
@@ -4782,7 +4787,7 @@
4782
4787
  {
4783
4788
  "path": "modules/lifecycle/db-ship.js",
4784
4789
  "mode": "0000644",
4785
- "sha256": "0a313d9c20cc0da5952e3a5acbca83cd1fd3df7ecccbbd109701d865b4dd2fe6"
4790
+ "sha256": "d300392b7bf802d00ae79c55ad3b0d9413dc9bb56e7e1105f49656b39abb874a"
4786
4791
  },
4787
4792
  {
4788
4793
  "path": "modules/lifecycle/db-tasks.js",
@@ -6227,7 +6232,7 @@
6227
6232
  {
6228
6233
  "path": "modules/sessions/pulse.js",
6229
6234
  "mode": "0000644",
6230
- "sha256": "0f9f0a412b4560dd20bc3e15d8160d991b276f5e3a6e2ba3b752a322bef78e4c"
6235
+ "sha256": "50e7009581961fc1c29cfead85f8d4278e948bfc2a43d5c6b3eaf4e7821ca1af"
6231
6236
  },
6232
6237
  {
6233
6238
  "path": "modules/sessions/routes/sessions.js",
@@ -6662,12 +6667,12 @@
6662
6667
  {
6663
6668
  "path": "package-lock.json",
6664
6669
  "mode": "0000644",
6665
- "sha256": "d31730b3e97acb3c865e52501b03d403acfb927f3107e3f605323e17945e0bf0"
6670
+ "sha256": "9950b1cf51f93769fdfe48d5001b54bb1274cc4359988e0a2ec98865fe551be0"
6666
6671
  },
6667
6672
  {
6668
6673
  "path": "package.json",
6669
6674
  "mode": "0000644",
6670
- "sha256": "533ce05335a427241e42414979e9c01c7c7df1f446959ffe2a70beae4f8089a7"
6675
+ "sha256": "2c9a8851c9bfeaa466c27bc933049734fcde9b77ce276bf2604882a0feff20b7"
6671
6676
  },
6672
6677
  {
6673
6678
  "path": "public-docs/index.html",
@@ -7097,7 +7102,7 @@
7097
7102
  {
7098
7103
  "path": "scripts/gds/fitness.js",
7099
7104
  "mode": "0000644",
7100
- "sha256": "ec823ee34e55318a1bee8008658fe44c24158e43a3736065312ae931d0bab796"
7105
+ "sha256": "a7322e6d2510bee2780ca76c68a1c46e7d771cd6675b5f4c223adcf8e001a6c5"
7101
7106
  },
7102
7107
  {
7103
7108
  "path": "scripts/gds/gate-review.js",
@@ -7447,7 +7452,7 @@
7447
7452
  {
7448
7453
  "path": "scripts/gds/pulse.js",
7449
7454
  "mode": "0000644",
7450
- "sha256": "0165d2b4c0f48e59f506e43ea0c8252fed81f2799aa14df0c4624097322dd636"
7455
+ "sha256": "21cf8741c5e046f61fbe94164d348d476f41bd45cac62c72609580032b1244ba"
7451
7456
  },
7452
7457
  {
7453
7458
  "path": "scripts/gds/push-main.js",
@@ -8217,7 +8222,7 @@
8217
8222
  {
8218
8223
  "path": "src/bongos/routes/builders.js",
8219
8224
  "mode": "0000644",
8220
- "sha256": "dc71324e4a1357f86b7e5a66c64ca5b38a2c79ef58c6d7a6f7337a23cfa1ee60"
8225
+ "sha256": "0d27bd6e4db13e9aae324efab98829480fed43abe615c5c56d8e707c6a3465d3"
8221
8226
  },
8222
8227
  {
8223
8228
  "path": "src/bongos/routes/github.js",
@@ -8322,7 +8327,7 @@
8322
8327
  {
8323
8328
  "path": "src/module-api.js",
8324
8329
  "mode": "0000644",
8325
- "sha256": "a5108ff620f06bbc05b7db97722319170c3fe6d5ce379524fe53b03ad0a137fb"
8330
+ "sha256": "35edfe1acbfd33316d1bbc681817dc06af19336873acd4abe239af41efbc22f2"
8326
8331
  },
8327
8332
  {
8328
8333
  "path": "src/module-loader/catalog.js",
@@ -8364,6 +8369,11 @@
8364
8369
  "mode": "0000644",
8365
8370
  "sha256": "4ba8c3b2e17c2faa38a872fe679e0dc7d032e79e05eb7277d76736201311dcdd"
8366
8371
  },
8372
+ {
8373
+ "path": "src/stale-timer.js",
8374
+ "mode": "0000644",
8375
+ "sha256": "2e46d071e5c61d302f34507215dd0a5f20a0887841ae0f7b033be865c2cb8af5"
8376
+ },
8367
8377
  {
8368
8378
  "path": "tests/abandon_task.mjs",
8369
8379
  "mode": "0000644",
@@ -10192,7 +10202,7 @@
10192
10202
  {
10193
10203
  "path": "tests/module_api.mjs",
10194
10204
  "mode": "0000644",
10195
- "sha256": "9b2394ce72e74a96460c6d9ef1cc2ee280dded85523fae2979d56a49fb514b85"
10205
+ "sha256": "a4980667646d3a3dda1a67906d0735d8acf462989de937e3d342461b275e74eb"
10196
10206
  },
10197
10207
  {
10198
10208
  "path": "tests/module_catalog.mjs",
@@ -10302,7 +10312,7 @@
10302
10312
  {
10303
10313
  "path": "tests/onboarding_observability.mjs",
10304
10314
  "mode": "0000644",
10305
- "sha256": "2423b4de9df54cfe2d127d8b3983b05f8e5ec7c15f7b325b6e81931d31a51061"
10315
+ "sha256": "973847bd475916e1418a8c70ff9c0f2a1186d55c31e0e8ba4f8b24fabb4c35dc"
10306
10316
  },
10307
10317
  {
10308
10318
  "path": "tests/onboarding_route_signals.mjs",
@@ -11079,6 +11089,11 @@
11079
11089
  "mode": "0000644",
11080
11090
  "sha256": "27bd2a87e001eec2bc58e13cfa58e53f88c73ebe7504b944c718182247d98ca7"
11081
11091
  },
11092
+ {
11093
+ "path": "tests/stale_timer.mjs",
11094
+ "mode": "0000644",
11095
+ "sha256": "770fb94ecdb37da2f7cf74b95d734b789cbbbd5414a97ae5275089302064a71d"
11096
+ },
11082
11097
  {
11083
11098
  "path": "tests/stale_worktrees.mjs",
11084
11099
  "mode": "0000644",
@@ -31,12 +31,12 @@ export interface GetBoxSshKeyResponse { keys: unknown }
31
31
  export interface GetBoxTerminalResponse { state: unknown; url: unknown; credential: unknown; hint: unknown; warming_up?: boolean; terminal_at?: unknown }
32
32
  export interface GetBoxesResponse { boxes: unknown }
33
33
  export interface GetBuildersDirectoryResponse { builders: unknown }
34
- export interface GetBuildersIdActiveClaimsResponse { builder_id: unknown; claims: unknown; stale_hours: unknown }
34
+ export interface GetBuildersIdActiveClaimsResponse { builder_id: unknown; claims: unknown; stale_hours: unknown; stale_hours_source: unknown }
35
35
  export interface GetBuildersIdActivityResponse { builder_id: unknown; section: unknown; role: unknown; rows: unknown; total: unknown }
36
36
  export interface GetBuildersIdOnboardingResponse { builder_id: unknown; onboarding: unknown }
37
37
  export interface GetBuildersIdProfileResponse { builder: unknown; shipped_count: unknown; recent_ships: unknown; achievements: unknown; cross_project?: unknown; handle?: unknown }
38
38
  export interface GetBuildersMePendingRankChangeResponse { change: unknown; greeting?: unknown }
39
- export interface GetBuildersRosterResponse { builders: unknown; stale_hours: unknown }
39
+ export interface GetBuildersRosterResponse { builders: unknown; stale_hours: unknown; stale_hours_source: unknown }
40
40
  export interface GetClosenessForMeResponse { closeness: unknown; count: unknown }
41
41
  export interface GetClosenessWeightsResponse { weights: unknown; window_days: unknown; note: unknown }
42
42
  export interface GetClosenessWithBuilderIdResponse { closeness: unknown; why: unknown }
@@ -0,0 +1,121 @@
1
+ # 0226 — One stale-claim timer; reporting degrades where enforcement refuses
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-08-28
5
+ - **Tasks:** [#1003476](https://cloudbongos.com/builders#/task/1003476)
6
+ - **Goal:** [#1000072](https://cloudbongos.com/builders#/goal/1000072) — Human legibility, criterion `legibility-sort-and-staleness`.
7
+ - **Builds on:** [#1003278](https://cloudbongos.com/builders#/task/1003278), which gave both work boards a shared *task* staleness vocabulary and left this one — the *claim* timer — named in a comment as the thing still to wire.
8
+ - **Split out of:** [#1003279](https://cloudbongos.com/builders#/task/1003279) on 2026-09-02. This work was authored under that task’s claim, but it answers a different question than that task’s DONE WHEN: 1003279 is the needs-attention **inbox item** a stale *goal or task* raises (with the verbs prune / kill / prioritise / water), and §4 below records that the *task* staleness buckets and a settings knob were deliberately **rejected** as out of scope here. It ships under its own task so the ledger says what the diff does; 1003279 stays open for its inbox half.
9
+
10
+ ## 1. The problem: one question, four answers, none of them the project's
11
+
12
+ "How long may a claim go silent before this project calls it abandoned" was
13
+ answered independently in four places:
14
+
15
+ | Surface | Number | Where it came from | What it does |
16
+ |---|---|---|---|
17
+ | `scripts/gds/pulse.js --release-stale` | **6h** | `GDS_STALE_CLAIM_HOURS`, else a literal `6` | **acts** — releases the claim, stamps the session log `[auto-released:stale]`, returns the task to `ready`, pings the builder |
18
+ | `GET /builders/roster` → `getBuildersRoster` | **24h** | a literal, twice (route and db helper) | **reports** — the `stale_claim` flag on the Archon roster |
19
+ | `GET /builders/:id/active-claims` → `<redacted>` | **24h** | a second, byte-identical literal | **reports** — which claim to target for release |
20
+ | `sweepStaleClaims` / `getStaleClaimsForBuilder` | **30 min** | a default parameter | whatever an argument-less caller wanted |
21
+
22
+ The 6-vs-24 pair is the one that hurt. The roster is the screen an Archon opens
23
+ to decide whether to intervene, and for **eighteen hours** after the sweeper had
24
+ already released a claim, tagged it and handed the task back to the queue, that
25
+ screen still showed it healthy. Neither file is wrong when read alone — the
26
+ roster's own comment even says it "mirrors the threshold the stale-claim-release
27
+ sweeper uses", which was true of the *definition* and false of the *number*. A
28
+ mirror that copies the shape and not the value is worse than no mirror: it reads
29
+ as a guarantee.
30
+
31
+ This is the shape goal 1000072 exists to close. A person steering an AI-run
32
+ project is steering by what the surfaces tell them, so a surface that reports a
33
+ different rule from the one the machine enforces is not a cosmetic defect.
34
+
35
+ ## 2. Decision
36
+
37
+ **One resolver owns the timer, and every surface that reports or enforces
38
+ staleness reads it.** `src/stale-timer.js` — a KERNEL file, in the same category
39
+ as `instance-config.js` and `branding.js`: host configuration a module reads
40
+ rather than hardcodes. Exposed to modules through the doorway
41
+ (`api.resolveStaleClaimHours`), required directly by core routes and by the CLI.
42
+
43
+ Precedence, widest scope last:
44
+
45
+ 1. **An explicit override** — `--threshold-hours`, `?stale_hours=N`. A *lens*, not
46
+ a reconfiguration: an Archon asking "what would 48h show me?" must not change
47
+ what the project does, and the response labels the answer `query` so the
48
+ distinction survives into the payload.
49
+ 2. **`<PREFIX>_STALE_CLAIM_HOURS`** — the project's configured timer, resolved
50
+ through `instance-config.resolveEnv`, whose legacy prefix chain still honours
51
+ the `GDS_STALE_CLAIM_HOURS` the live sweeper has always read. **Nothing in a
52
+ running deployment has to change for this to resolve to what it resolves to
53
+ today.** This is also the channel a platform-managed project already has: the
54
+ provisioning runner patches `web.env` in place and restarts ([ADR 0190](<redacted>.md)).
55
+ 3. **`6`** — the default. Deliberately the *sweeper's* historical number and not
56
+ the roster's 24: the sweeper is the surface that acts, and adopting 24 would
57
+ have quietly made every project three times more patient with a dead claim
58
+ while looking like a tidy-up.
59
+
60
+ The bound is **1..720 hours** — not taste: `GET /builders/roster` has published
61
+ `1..720` as its `stale_hours` contract since V3.R14, and one timer means one
62
+ bound. (Sub-hour values were previously accepted by that route despite the
63
+ published range; they are now rejected, consistent with what it always claimed.)
64
+
65
+ **Every surface that prints the timer prints where it came from.** The sweep
66
+ header and the auto-release note carry the source; both roster reads return
67
+ `stale_hours_source`. A number with no provenance is precisely what let 6 and 24
68
+ sit side by side in the ops log for a year.
69
+
70
+ ## 3. The part that is not obvious: the fail posture is asymmetric
71
+
72
+ A misconfigured timer must not silently become a different policy. But the two
73
+ kinds of caller cannot want the same thing from a bad value, so the resolver
74
+ **reports** rather than throws, and each caller states its own posture:
75
+
76
+ - **Reporting degrades.** A read-only surface takes the default and labels the
77
+ source `default (invalid <VAR>="…": …)`. A 500 here would take down the roster
78
+ an Archon opened *to find the problem*, and hide the typo behind an error page;
79
+ answering with the default and naming the bad value puts it on the screen of
80
+ the one person who can fix it.
81
+ - **Enforcement refuses.** The sweep declines to run at all. It releases other
82
+ people's claims and stamps their session logs, and no reload undoes that — so
83
+ "probably 6" is not an answer it is allowed to give. An explicit
84
+ `--threshold-hours` that is unusable is likewise a usage error, as before.
85
+
86
+ Stated as a rule: **a surface that only reads may guess and say so; a surface
87
+ that acts may not guess.**
88
+
89
+ ## 4. What was rejected
90
+
91
+ - **A new `config/stale.json` pair.** The env route already *is* the per-project
92
+ channel (the runner writes `web.env`), and a config file would have added a
93
+ fourth tier to a precedence whose whole point is that there is one place to
94
+ look.
95
+ - **Making the roster's 24 the shared default.** It is the number nothing acted
96
+ on. Unifying on it changes real behaviour under cover of a refactor.
97
+ - **Throwing from the resolver.** It would have forced the roster to either 500
98
+ or re-implement the fallback, which is a second reader — the original bug.
99
+ - **Folding in the boards' 14/30/60-day task buckets.** A different question:
100
+ those are *days*, about a *task* going untouched, and they are a browsing lens
101
+ with no enforcement behind them. Two questions may have two vocabularies; only
102
+ one of them may be a number typed twice.
103
+ - **A DB row + settings UI for the timer.** A reasonable next step, and out of
104
+ scope here: the defect was four numbers, not the absence of a knob. Adding a
105
+ row before the readers agreed would have produced a fifth.
106
+
107
+ ## 5. Consequences
108
+
109
+ - `GET /builders/roster` and `GET /builders/:id/active-claims` gain
110
+ `stale_hours_source`; their default `stale_hours` changes from `24` to the
111
+ project timer (`6` unless configured). Additive to the payload; the default is
112
+ a **deliberate** behaviour change — it is the fix.
113
+ - Both routes now share one `staleHoursFor(req, res)` helper. Two copies of a
114
+ literal is how the drift happened twice in one file.
115
+ - `sweepStaleClaims` / `getStaleClaimsForBuilder` no longer have a number of
116
+ their own; an argument-less call means the project's timer, resolved per call
117
+ so an env change lands on the next sweep and not the next restart.
118
+ - Pinned by `tests/stale_timer.mjs` (the resolver executed, the sameness of every
119
+ surface asserted, and both fail postures) plus the updated roster assertions in
120
+ `tests/onboarding_observability.mjs` — whose old `stale_hours === 24` line *was*
121
+ the eighteen-hour lie, written down as an expectation.
@@ -313,5 +313,6 @@ This keeps the decision history honest and traceable.
313
313
  | 0224 | [**Module-owned skills reach the core checkout as untracked, self-ignored copies with a manifest: the self-copy guard stays, the dev path is a flag, and the file map skips what the module documents** ([task 1003330](https://cloudbongos.com/builders#/task/1003330) · goal 1000083). `materializeClaude`'s self-copy guard is right (a core copying itself is a no-op at best), but Claude Code discovers only `.claude/skills/` at the project root, so a builder IN the core checkout could not invoke any of the nine ui-design skills written there. Decisions: **D1** `node scripts/gds/claude-materialize.js --module-skills-only [--dry-run] [--clean]` calls `materializeModuleSkillsIntoCore`: the same roster, gate and slug rule as step 1b, verbatim copies (no bongosify: the checkout runs `node scripts/gds/…`), a core-owned name kept, a disabled module's excluded; the guard untouched. **D2** untracked and self-ignored: a generated `.claude/skills/.module-skills.json` manifest and a `.gitignore` that ignores itself, the manifest and every materialised dir; each run removes what the last manifest named then lands the current set (byte-identical on repeat; a dropped declaration disappears); `--clean` stops after the removal. **D3** `gen-file-map.js` and `tests/file_map.mjs` skip materialised names by the manifest (a module skill is documented by its module's README, not by the notes sidecar). **D4** init from a checkout that carries the copies still lands them through step 1b, transformed, and never the dotfiles. **D5** pinned end to end in `tests/claude_materialize.mjs`. Rejected: dropping the guard; committing the copies; symlinks or junctions; notes for dirs not in the repository.](<redacted>.md) | materialiser / skills |
314
314
  | 0225 | [**Privacy suppresses cross-project surfaces only: a private builder still appears on their own project's roster** ([task 1003443](https://cloudbongos.com/builders#/task/1003443) · goal 1000075). The accepted privacy spec defined its two flags inconsistently for one case: `hide_stats` suppresses "everything derived from the CROSS-PROJECT rollup" (qualified, so an in-project leaderboard is out of scope) while `account_visibility: private` said "absent from search, LEADERBOARDS, sitemap" (unqualified, so it read as covering the in-project one too) — and PV2 demands "ONE shared predicate used by every public read: … leaderboards …" while `modules/economy/credits.js`'s `leaderboard()` is a bare `SELECT … FROM builders` with no join and no predicate. Read one way that is an unshipped PV2 violation in main; read the other it is correct, and nothing settled which. **Owner decision (2026-08-30): privacy suppresses CROSS-PROJECT surfaces only.** A `private` builder is absent from `/u/<handle>`, platform search, the sitemap, Explore and the recruiting reach, and still appears on the roster of a project they build on — a project's record of its own work is the project's business. Two consequences are the point of recording it: `credits.leaderboard()` is **correct as written** (adding a predicate there would be a regression, not a fix — exactly what a literal PV2 reading would have done), and PV2's "leaderboards" means the cross-project ones. The spec was amended rather than annotated: the D1 row and both `active · public` matrix rows now say *cross-project*, the two `active · private` rows drop "absent everywhere" (the file's most misreadable phrase) for "absent from every cross-project surface", PV2's ship-table row is qualified, and the boundary lands as the **third rule** under D2 beside "`hide_stats` beats everything". Rejected: making `private` hide a builder from projects they build on (it reads as more protective and breaks the ledger — a roster that omits contributors cannot show who built the thing, and credits would accrue to someone invisible on the surface explaining them); and leaving it as a dated spec note (it pins shipped code as deliberately predicate-free, which is an ADR's job).](<redacted>.md) | privacy / spec |
315
315
  | 0227 | [**A task network map is refused; the goal graph is the map, and search answers findability** ([task 1003282](https://cloudbongos.com/builders#/task/1003282) · goal 1000072, criterion `legibility-project-map`, which gates itself on this spike). Measured across a swept density range (p=2%–40% of tasks carrying a dependency): a node-link view of ~1,357 tasks is confetti, not structure, and densifying it draws forty goals badly while claiming to show a thousand tasks — a confident picture of nothing is the opposite of the legibility this goal serves. Decision: NO whole-corpus task map at any density; the readable surface is the ~40-node GOAL graph as the entry point with a per-goal DEPENDENCY FOREST as the drill-down, queried when a goal is opened rather than zoomed out of a global map. Every edge a map could draw already exists in the DB and none needs a new table — `task_dependencies` (020), `tasks.parent_task_id`, `tasks.goal_id`, `task_criteria`→`done_when_criteria`, `idea_inbox.promoted_to_task_id`, `idea_inbox.merged_into_idea_id` (`core_195`), `tasks.version_id` (membership, never an edge). **Findability (goal 1000027) answers the owner’s question more cheaply than a map**: the ask splits into “find what I was brainstorming”, which `/recall` (ADR 0060) plus the two lineage columns already answers end to end and which wants a text LINEAGE TRAIL — one thing and its ancestry — not a picture; and “see the shape of the portfolio”, the only half a graph earns, which is about ~40 goals and not about tasks. So the trail outranks the map and is sequenced first. Accessibility: the TABLE is the primary surface and the graph the enhancement (not an `aria-label` on an SVG blob, not a toggle hiding the accessible path), no sideways scroll on the page (the graph scrolls inside its own container), and category is text as well as colour since the ADR 0192 set is not colour-distinguishable at forty nodes. Ranked list: (1) measure the real density against the live DB — it GATES and may cancel the rest, (2) the lineage trail, (3) the goal graph, (4) the per-goal forest. Rejected: view A (every task); view D (densified); a prototype as the output (the criterion forbade it — `spike-task-map.js` is a measuring instrument under `docs/research/`, wired to no route); and building before measuring. Honest limit: the corpus is SYNTHETIC (the live DB was unreachable), so the verdict is robust across the sweep but the specific numbers are model output — which is why item 1 is a measurement against the real DB.](<redacted>.md) | GDS / legibility + hall |
316
+ | 0226 | [**One stale-claim timer; reporting degrades where enforcement refuses** ([task 1003476](https://cloudbongos.com/builders#/task/1003476), goal 1000072, criterion `legibility-sort-and-staleness`). "How long may a claim go silent" had FOUR answers and none of them was the project's: the sweeper that actually releases a claim read `GDS_STALE_CLAIM_HOURS` (else 6h); `GET /builders/roster` and `GET /builders/:id/active-claims` each hardcoded 24h in byte-identical blocks; and the two `session_pulses` helpers defaulted to 30 minutes. The 6-vs-24 pair is the harm: the roster is the screen an Archon opens to decide whether to intervene, and for **eighteen hours** after the sweeper had released a claim, stamped its session log `[auto-released:stale]` and returned the task to `ready`, that screen still showed it healthy — while the roster's own comment claimed to "mirror" the sweeper, true of the definition and false of the number. Decision: ONE resolver, `src/stale-timer.js` (a KERNEL file, the `instance-config.js`/`branding.js` category — host config a module reads, never hardcodes), reached by modules through the doorway and by core routes and the CLI directly. Precedence: explicit override (`--threshold-hours`, `?stale_hours=` — a LENS, labelled `query`, never a reconfiguration) → `<PREFIX>_STALE_CLAIM_HOURS` via `instance-config.resolveEnv`, whose legacy prefix chain still honours the live `GDS_STALE_CLAIM_HOURS` so **nothing in a running deployment has to change** (and which is the channel ADR 0190's runner already patches into `web.env`) → **6**, the SWEEP's historical number and deliberately not the roster's 24, because the sweep is the surface that acts and unifying on 24 would have made every project three times more patient with a dead claim under cover of a refactor. Bound 1..720h — the range `/builders/roster` has published since V3.R14; one timer, one bound. Every surface that prints the timer prints its PROVENANCE (`stale_hours_source`, the sweep header, the auto-release note): a number without it is exactly what let 6 and 24 coexist unnoticed. **The fail posture is asymmetric and that is the decision**: the resolver reports an unusable value rather than throwing, then a surface that only READS degrades to the default and names the bad var (500-ing the roster an Archon opened to find the problem would hide the problem), while the SWEEP refuses to run at all (it releases other people's claims and stamps their logs; no reload undoes that) — a surface that only reads may guess and say so, a surface that acts may not guess. Both routes now share one `staleHoursFor(req, res)`; two copies of a literal is how the drift happened twice inside one file. Rejected: a new `config/stale.json` tier (the env route already IS the per-project channel); making 24 the shared default; throwing from the resolver (forces a second reader — the original bug); folding in the boards' 14/30/60-DAY task buckets (a different question — days, about a task, a browsing lens with no enforcement behind it); a DB row + settings UI (a reasonable next step, but the defect was four numbers, not a missing knob — a row added before the readers agreed would have been a fifth).](<redacted>.md) | GDS / claims + ops |
316
317
 
317
318
  > ⚠️ **Numbering collisions are now machine-enforced, not narrated.** Twenty numbers are shared by two ADRs each — assigned in parallel sessions before anything checked. The files keep their filenames (renumbering would break every existing citation, and a number once assigned is never reused), and this table disambiguates each pair as `NNNN-a` / `NNNN-b`. The authoritative list is `LEGACY_DUPLICATE_ADRS` in [`scripts/gds/adr-namespace.js`](../../scripts/gds/adr-namespace.js), frozen by exact filename: a **new** collision — or a third file joining a legacy number, or a rename of either half — hard-fails the `unit` CI gate, as does an index row that points at no file or an ADR with no row. The list may only shrink. This paragraph used to narrate the collisions one by one and had fallen eight behind reality; see [ADR 0195](<redacted>.md) for why the check exists and why the pairs are grandfathered rather than renumbered.
@@ -2591,7 +2591,7 @@
2591
2591
  "builders"
2592
2592
  ],
2593
2593
  "summary": "GET /builders/roster",
2594
- "description": "GET /builders/roster — V3.R14 (task 238) onboarding observability. Archon-only roster of every builder with their identity + onboarding signals + active-claim posture. Powers the management panel in the /builders hall so the Archon can promote / demote / release-stale-claim without dropping into psql. Optional query param: ?stale_hours=N (default 24). Defines what counts as \"stale\" for the per-row stale_claim flag — mirrors the threshold the stale-claim-release sweeper uses but exposed as a roster knob so the Archon can dial it in if needed. rank: archon — surfaces every builder's onboarding state. Gate: requirePermission('builder.roster.read') — BV1.R104; the same atom /grades/by-builder and /builders/:id/active-claims already gate on (ADR 0151).\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `builder.roster.read` (all required).",
2594
+ "description": "GET /builders/roster — V3.R14 (task 238) onboarding observability. Archon-only roster of every builder with their identity + onboarding signals + active-claim posture. Powers the management panel in the /builders hall so the Archon can promote / demote / release-stale-claim without dropping into psql. Optional query param: ?stale_hours=N. Defines what counts as \"stale\" for the per-row stale_claim flag. The DEFAULT is the project's configured stale-claim timer (task 1003476, goal 1000072) — literally the number the sweeper releases on, not a lookalike: this route defaulted to a hardcoded 24 while the sweeper acted at 6, so the Archon's screen called a claim healthy for eighteen hours after the machine had already taken it away. The param stays as a LENS (\"what would 48h show me?\"), and the response reports both the number and where it came from so the two can never quietly diverge again. rank: archon — surfaces every builder's onboarding state. Gate: requirePermission('builder.roster.read') — BV1.R104; the same atom /grades/by-builder and /builders/:id/active-claims already gate on (ADR 0151).\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `builder.roster.read` (all required).",
2595
2595
  "x-rank": "metic+archon",
2596
2596
  "x-source": "src/bongos/routes/builders.js",
2597
2597
  "x-permissions": [
@@ -15830,12 +15830,14 @@
15830
15830
  "properties": {
15831
15831
  "builder_id": {},
15832
15832
  "claims": {},
15833
- "stale_hours": {}
15833
+ "stale_hours": {},
15834
+ "stale_hours_source": {}
15834
15835
  },
15835
15836
  "required": [
15836
15837
  "builder_id",
15837
15838
  "claims",
15838
- "stale_hours"
15839
+ "stale_hours",
15840
+ "stale_hours_source"
15839
15841
  ]
15840
15842
  },
15841
15843
  "GetBuildersIdActivityResponse": {
@@ -15897,11 +15899,13 @@
15897
15899
  "type": "object",
15898
15900
  "properties": {
15899
15901
  "builders": {},
15900
- "stale_hours": {}
15902
+ "stale_hours": {},
15903
+ "stale_hours_source": {}
15901
15904
  },
15902
15905
  "required": [
15903
15906
  "builders",
15904
- "stale_hours"
15907
+ "stale_hours",
15908
+ "stale_hours_source"
15905
15909
  ]
15906
15910
  },
15907
15911
  "GetClosenessForMeResponse": {
@@ -1377,5 +1377,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1377
1377
  landed since 1.19.468 with no explicit bump. run 33643388774. (task 1002620)
1378
1378
  1.19.470 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1379
1379
  landed since 1.19.469 with no explicit bump. run 33644371734. (task 1002620)
1380
+ 1.19.471 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1381
+ landed since 1.19.470 with no explicit bump. run 33645344292. (task 1002620)
1382
+ 1.19.472 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1383
+ landed since 1.19.471 with no explicit bump. run 33646171957. (task 1002620)
1380
1384
  ---------------------------------------------------------------------------
1381
1385
  ```
@@ -117,9 +117,13 @@
117
117
  const msOf = (iso) => { const t = iso ? Date.parse(iso) : NaN; return Number.isNaN(t) ? 0 : t; };
118
118
  const taskActivityMs = (t) => Math.max(msOf(t.updated_at), msOf(t.shipped_at));
119
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.
120
+ // The staleness buckets both boards offer. A CLIENT-side vocabulary over TASKS,
121
+ // deliberately NOT the project's configured stale-claim timer — that one lives in
122
+ // src/stale-timer.js, counts hours rather than days, is about a CLAIM going silent,
123
+ // and is what the sweeper enforces and the Archon roster reports (task 1003476).
124
+ // These are lenses for a person browsing a board; that is the machine's rule. Two
125
+ // different questions, so two vocabularies — but only one of them may be a number
126
+ // typed twice, which is the bug 1003476 closed.
123
127
  const STALE_CHOICES = [
124
128
  { value: '', label: '— any age —' },
125
129
  { value: '14', label: 'untouched 14+ days' },
@@ -562,21 +562,29 @@ async function getClaimPublishedSha(taskId) {
562
562
  // time_to_first_ship_minutes (NULL until they've shipped)
563
563
  // - claims: active_claim_count, stale_claim
564
564
  //
565
- // "stale_claim" mirrors the stale-claim-release sweeper definition
566
- // (.claude/scheduled-tasks/stale-claim-release/SKILL.md): a still-open
567
- // claim where either the last session_pulses row is older than the
568
- // stale threshold, or there's no pulse row at all AND claimed_at is
569
- // older than the threshold. Default threshold is 24h to match this
570
- // task's spec — the sweeper itself uses GDS_STALE_CLAIM_HOURS (default
571
- // 6h) and is in charge of *acting*. The roster only *surfaces*.
565
+ // "stale_claim" mirrors the stale-claim-release sweeper's definition: a
566
+ // still-open claim where either the last session_pulses row is older than
567
+ // the stale threshold, or there's no pulse row at all AND claimed_at is
568
+ // older than the threshold.
569
+ //
570
+ // THE THRESHOLD IS THE PROJECT'S, NOT THIS ROUTE'S (task 1003476, goal
571
+ // 1000072). It used to default to a hardcoded 24h "to match this task's
572
+ // spec" while the sweeper acted on GDS_STALE_CLAIM_HOURS (6h) — so for
573
+ // eighteen hours after a claim had been auto-released, tagged
574
+ // [auto-released:stale] and handed back to the queue, this roster still
575
+ // showed it healthy. The roster surfaces and the sweeper acts, but they
576
+ // must answer the same question the same way, so both now read
577
+ // src/stale-timer.js. An explicit `staleHours` still overrides, because
578
+ // "what would 48h show me?" is a lens a person points at the roster and
579
+ // not a reconfiguration of the project.
572
580
  //
573
581
  // Returns: array of builder roster rows, ordered by:
574
582
  // 1. inactive builders sink to the bottom (status='inactive' last)
575
583
  // 2. still-onboarding (first_ship_at IS NULL) above shipped builders
576
584
  // 3. then by created_at DESC inside each group (newest first)
577
585
 
578
- async function getBuildersRoster({ staleHours = 24 } = {}) {
579
- const hours = Number(staleHours);
586
+ async function getBuildersRoster({ staleHours } = {}) {
587
+ const hours = Number(staleHours ?? api.resolveStaleClaimHours().hours);
580
588
  if (!Number.isFinite(hours) || hours <= 0) {
581
589
  throw new Error('getBuildersRoster: staleHours must be a positive number');
582
590
  }
@@ -629,9 +637,12 @@ async function getBuildersRoster({ staleHours = 24 } = {}) {
629
637
  // signals, so the Archon can pick the right one to release. Used by
630
638
  // the management panel's per-row "release stale claim" action.
631
639
 
632
- async function getActiveClaimsForBuilderWithStale(builderId, { staleHours = 24 } = {}) {
640
+ // The threshold defaults to the PROJECT's stale-claim timer, not to a number of its
641
+ // own (task 1003476) — a claim the roster called stale must still be stale one click
642
+ // later, in the view an Archon opens to release it.
643
+ async function getActiveClaimsForBuilderWithStale(builderId, { staleHours } = {}) {
633
644
  const builder = Number(builderId);
634
- const hours = Number(staleHours);
645
+ const hours = Number(staleHours ?? api.resolveStaleClaimHours().hours);
635
646
  if (!Number.isFinite(builder)) {
636
647
  throw new Error('getActiveClaimsForBuilderWithStale: builderId must be numeric');
637
648
  }
@@ -39,7 +39,17 @@
39
39
  // getStaleClaimsForBuilder().
40
40
  // -------------------------------------------------------------------------
41
41
 
42
- const { pool } = require('../../src/module-api');
42
+ const api = require('../../src/module-api');
43
+
44
+ const { pool } = api;
45
+
46
+ // The project's stale-claim timer, in minutes (task 1003476, goal 1000072). These
47
+ // helpers used to default to a hardcoded 30 minutes, which was neither the sweeper's
48
+ // 6h nor the roster's 24h — a third answer to one question, reachable by any caller
49
+ // that omitted the argument. There is one answer now, and it is the project's.
50
+ // Resolved per call, never at require time: an env change must reach a long-running
51
+ // process on its next sweep, not on its next restart.
52
+ const projectThresholdMin = () => api.staleClaimMinutes(api.resolveStaleClaimHours().hours);
43
53
 
44
54
  // Record a heartbeat for a claim. Idempotent UPSERT — the PK is
45
55
  // claim_id so repeated pulses just bump last_pulse_at.
@@ -77,8 +87,8 @@ async function recordPulse(claimId, builderId) {
77
87
  // This function is READ-ONLY by design (see open question #1 above);
78
88
  // the write-side marker is deferred to a follow-up task that owns
79
89
  // the migration.
80
- async function sweepStaleClaims({ thresholdMin = 30 } = {}) {
81
- const threshold = Number(thresholdMin);
90
+ async function sweepStaleClaims({ thresholdMin } = {}) {
91
+ const threshold = Number(thresholdMin ?? projectThresholdMin());
82
92
  if (!Number.isFinite(threshold) || threshold <= 0) {
83
93
  throw new Error('sweepStaleClaims: thresholdMin must be a positive number');
84
94
  }
@@ -110,9 +120,9 @@ async function sweepStaleClaims({ thresholdMin = 30 } = {}) {
110
120
  // Today, "stale" is computed the same way as sweepStaleClaims() —
111
121
  // once a marker column exists (follow-up a), this can pivot to read
112
122
  // claims.pulse_lost_at IS NOT NULL instead.
113
- async function getStaleClaimsForBuilder(builderId, { thresholdMin = 30 } = {}) {
123
+ async function getStaleClaimsForBuilder(builderId, { thresholdMin } = {}) {
114
124
  const builder = Number(builderId);
115
- const threshold = Number(thresholdMin);
125
+ const threshold = Number(thresholdMin ?? projectThresholdMin());
116
126
  if (!Number.isFinite(builder)) {
117
127
  throw new Error('getStaleClaimsForBuilder: builderId must be numeric');
118
128
  }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.470",
3
+ "version": "1.19.472",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.470",
9
+ "version": "1.19.472",
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.470",
3
+ "version": "1.19.472",
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",
@@ -509,6 +509,12 @@ const KERNEL_FILES = [
509
509
  'src/instance-config.js',
510
510
  'src/branding.js',
511
511
  'src/build-info.js',
512
+ // task 1003476 (goal 1000072): the project's stale-claim timer — host configuration
513
+ // a module reads rather than hardcodes, exactly the category instance-config.js and
514
+ // branding.js are in. The doorway (a KERNEL_FILE) re-exports it, so leaving it off
515
+ // this roster would read as a kernel->non-kernel leak; it imports only
516
+ // instance-config + node built-ins, so joining costs nothing.
517
+ 'src/stale-timer.js',
512
518
  // BV1.R86: the git-remote/slug + publish-target identity the doorway exposes
513
519
  // (api.repoInfo) for the carved lifecycle github-push/conflict-resolve. It is
514
520
  // KERNEL (already in the module-scope-map kernel key) and imports only the