@bongos/core 1.19.651 → 1.19.653

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.651",
6
- "core_contract": "1.19.651",
7
- "source_commit": "c13a88d638588dedd2454579a62cb76e4415656a",
5
+ "core_version": "1.19.653",
6
+ "core_contract": "1.19.653",
7
+ "source_commit": "eff58cdbf33eb107723b56ef2f119c578d628d5e",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-10T20:32:32.558Z",
9
+ "built_at": "2026-09-10T23:48:13.514Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 471,
12
+ "docs_redacted": 472,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2109,
14
+ "functional_verbatim": 2110,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2604,
20
- "tree_sha256": "3e626f458ea19a08ab773ec27d6efe07ab6901a906091aa433ac27aa7789173e",
19
+ "file_count": 2606,
20
+ "tree_sha256": "f2c83acf6434a7c56482adc7efcd782b2577ba4f4d6092d74e2ea143b5c6f90c",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/backlog-review/SKILL.md",
@@ -382,7 +382,7 @@
382
382
  {
383
383
  "path": "clients/bongos-client/index.d.ts",
384
384
  "mode": "0000644",
385
- "sha256": "5ef1befd1a1b30e8ef9e8b8ca08f768361d2422f6ddba2160a5395d46a84d443"
385
+ "sha256": "a82dc5f2fdcf44a1abff4bd8ba0aec70cc49004ad5feae72c362e8b45c5f04e5"
386
386
  },
387
387
  {
388
388
  "path": "clients/bongos-client/index.mjs",
@@ -1889,20 +1889,25 @@
1889
1889
  "mode": "0000644",
1890
1890
  "sha256": "2830a09a2d0520e7992c8e7d1545304092857267a9060f5127bd2ffcff237ebc"
1891
1891
  },
1892
+ {
1893
+ "path": "docs/adr/0277-a-box-is-in-use-only-while-a-human-is-attached.md",
1894
+ "mode": "0000644",
1895
+ "sha256": "e5e99f19152b5a98c21f70fc0e54a09e9ae59beeb81fea8d26df2de4ef196b90"
1896
+ },
1892
1897
  {
1893
1898
  "path": "docs/adr/README.md",
1894
1899
  "mode": "0000644",
1895
- "sha256": "0c6981591a439c22775b596f69e602ba7801f8d8549d4244ca8fefc5dc48ac3b"
1900
+ "sha256": "5ee80f0739cfbe9543f746ed13efbfeeaa56c350fb4e9570c7c122723b86a34a"
1896
1901
  },
1897
1902
  {
1898
1903
  "path": "docs/api-reference.md",
1899
1904
  "mode": "0000644",
1900
- "sha256": "6c5f26c9c00387ecc84ed80726a4d62eca0f017f1d75c304d758961749fe616f"
1905
+ "sha256": "fe08952e837f4e79243823d66876f2413ca1d743c22bc2a287c18275616692d4"
1901
1906
  },
1902
1907
  {
1903
1908
  "path": "docs/api/openapi.json",
1904
1909
  "mode": "0000644",
1905
- "sha256": "d63a4ab6e92a24d339a632360fb92246512e09eabbc5bff632881bda6d4abf50"
1910
+ "sha256": "db8be14055cc32b31376585ad954e1e91a8b1cbe77401bad3d5215c1d3b419aa"
1906
1911
  },
1907
1912
  {
1908
1913
  "path": "docs/architecture.md",
@@ -2777,7 +2782,7 @@
2777
2782
  {
2778
2783
  "path": "docs/module-api-changelog.md",
2779
2784
  "mode": "0000644",
2780
- "sha256": "b19f014bb424a4974164c08f892cb0103af216bebd411615b1d997f01d6054a7"
2785
+ "sha256": "11da08cbbf3f33e0a95df12baddcfc0bddba2b4bb4cf7c77f2a818c7d1dbfba8"
2781
2786
  },
2782
2787
  {
2783
2788
  "path": "docs/modules-contract.md",
@@ -3824,6 +3829,11 @@
3824
3829
  "mode": "0000644",
3825
3830
  "sha256": "2cda282b43ea71dd82ddf4e5ece90f11ad4013cbee0820f0a33391f202feff9c"
3826
3831
  },
3832
+ {
3833
+ "path": "migrations/core_238_box_unattended_cap.sql",
3834
+ "mode": "0000644",
3835
+ "sha256": "03df9104a0309011df1b6e96627c62cc6612f34b654134306d13fd5c89b8f038"
3836
+ },
3827
3837
  {
3828
3838
  "path": "modules/agents/lib/validate.js",
3829
3839
  "mode": "0000644",
@@ -4247,7 +4257,7 @@
4247
4257
  {
4248
4258
  "path": "modules/dev-box/boxes.js",
4249
4259
  "mode": "0000644",
4250
- "sha256": "667993d81db36e1ddd1307013044f3d40b6baca3c6f5271b77332d10fa290063"
4260
+ "sha256": "cd1fe37f04e6a32964fd165045ac6613ce2e1236e1ff388da38d428a769beb4b"
4251
4261
  },
4252
4262
  {
4253
4263
  "path": "modules/dev-box/db.js",
@@ -4262,7 +4272,7 @@
4262
4272
  {
4263
4273
  "path": "modules/dev-box/routes/box.js",
4264
4274
  "mode": "0000644",
4265
- "sha256": "b61996de3fa3dbdc9b8fb639fd53aba5c1c2567350dc602b39ebbbd3a93164ac"
4275
+ "sha256": "c5cef340b2a5c85202d07709f1b96983eb03ce5a15168684922e2de1ae025d71"
4266
4276
  },
4267
4277
  {
4268
4278
  "path": "modules/discord/CLAUDE.md",
@@ -7732,12 +7742,12 @@
7732
7742
  {
7733
7743
  "path": "package-lock.json",
7734
7744
  "mode": "0000644",
7735
- "sha256": "e70fe3cda6fcf43f7416f03c0232be6be8ff84fca2aaac504dac9268b33d8f69"
7745
+ "sha256": "42dddf576e9fb7f8d33e39dff4244f6105bd4bf78b150cf700d819f763ab6b8d"
7736
7746
  },
7737
7747
  {
7738
7748
  "path": "package.json",
7739
7749
  "mode": "0000644",
7740
- "sha256": "17a361e96b81d29aaddc6b2f73d2d3cdf007914df8e14d6f3fcd0ea7c12378c8"
7750
+ "sha256": "12d491b531085f6ed22cbda1b8f7485401c186fd10b1cb4170e8af6bed174d95"
7741
7751
  },
7742
7752
  {
7743
7753
  "path": "public-docs/index.html",
@@ -7922,7 +7932,7 @@
7922
7932
  {
7923
7933
  "path": "scripts/gds/box-infra.js",
7924
7934
  "mode": "0000644",
7925
- "sha256": "bfbe7348ddc9895d4eed1a23efe9246b44850e1bfe6d6015d63a0b2a4433373d"
7935
+ "sha256": "811545b6769947c5399f4be52782e90c1a6c2e628e72d7751266d2c7dafb62c3"
7926
7936
  },
7927
7937
  {
7928
7938
  "path": "scripts/gds/box-sync.js",
@@ -7932,7 +7942,7 @@
7932
7942
  {
7933
7943
  "path": "scripts/gds/box.js",
7934
7944
  "mode": "0000644",
7935
- "sha256": "afcebfb8e8e8f7041ee9cbfce1e9b8a46a299feb5e6e89ce4e00273135b268db"
7945
+ "sha256": "e7d82101c8212d1c46da944bb52f2d18a7fc87293f66f18e6856844f8766666b"
7936
7946
  },
7937
7947
  {
7938
7948
  "path": "scripts/gds/bug-triage.js",
@@ -8177,7 +8187,7 @@
8177
8187
  {
8178
8188
  "path": "scripts/gds/fitness-checks-vocabulary.js",
8179
8189
  "mode": "0000644",
8180
- "sha256": "5ea954ec0bf26e5192a369d0f94dcb3b8bf2e263c9e927c66a01791962a46995"
8190
+ "sha256": "f64905f88718e5617ee97584dada757367497eed350bfb41814d257c9d2ec680"
8181
8191
  },
8182
8192
  {
8183
8193
  "path": "scripts/gds/fitness-checks-write-validation.js",
@@ -8197,7 +8207,7 @@
8197
8207
  {
8198
8208
  "path": "scripts/gds/fitness.js",
8199
8209
  "mode": "0000644",
8200
- "sha256": "3593c992b397333ee85f11dc2ea6325c3c96a2d1e5b813204da32c34c493b236"
8210
+ "sha256": "b30c5c2b3bf6465a63a7124327ad40f94987088d1d87fcc110fec9281539a0a1"
8201
8211
  },
8202
8212
  {
8203
8213
  "path": "scripts/gds/gate-review.js",
@@ -9492,7 +9502,7 @@
9492
9502
  {
9493
9503
  "path": "src/module-api.js",
9494
9504
  "mode": "0000644",
9495
- "sha256": "de52ed5ea35350aff9ade88ecc06393252ef5294ea7b76aca8222f956367472a"
9505
+ "sha256": "03201e0e94d7ba4d755c8d0efae051208913faa2a7f654b231d3cb9ded49bd9b"
9496
9506
  },
9497
9507
  {
9498
9508
  "path": "src/module-loader/catalog.js",
@@ -9962,7 +9972,7 @@
9962
9972
  {
9963
9973
  "path": "tests/boxes.mjs",
9964
9974
  "mode": "0000644",
9965
- "sha256": "5a8d2ca5709f2c4ae5089b53883a5a892beeb6920bacbddbd352b106c4ec0e34"
9975
+ "sha256": "30a553024c9fe50076efd447c7f50ddca8e6d20bb91392f20ec30ab3b414aab6"
9966
9976
  },
9967
9977
  {
9968
9978
  "path": "tests/branding.mjs",
@@ -10947,7 +10957,7 @@
10947
10957
  {
10948
10958
  "path": "tests/government_vocabulary.mjs",
10949
10959
  "mode": "0000644",
10950
- "sha256": "71bd23242e53b1acd15fddedcb093790a847471c8c55485614d379d29e3065f3"
10960
+ "sha256": "444f0638dccebc42e5a371820ed565c665d4373c198e1581389ab222bf267c5e"
10951
10961
  },
10952
10962
  {
10953
10963
  "path": "tests/grade_aggregates_unavailable.mjs",
@@ -213,7 +213,7 @@ export interface PostBlockersRequest { title: string; body_md?: string; source?:
213
213
  export interface PostBlockersResponse { skipped?: boolean; reason?: unknown; blocker?: unknown }
214
214
  export interface PostBoxCloseResponse { ok: boolean; queued: unknown; action: unknown; message: unknown }
215
215
  export interface PostBoxEnsureResponse { state: unknown; action: unknown; queued: boolean; intent?: unknown }
216
- export interface PostBoxHeartbeatRequest { claude_active?: boolean }
216
+ export interface PostBoxHeartbeatRequest { claude_active?: boolean; attached?: boolean }
217
217
  export interface PostBoxHeartbeatResponse { ok: boolean; state: unknown }
218
218
  export interface PostBoxHostKeysRequest { host_keys: string }
219
219
  export interface PostBoxHostKeysResponse { ok: boolean; state: unknown; count: unknown }
@@ -0,0 +1,53 @@
1
+ # ADR 0277 — A box is "in use" only while a human is attached, and the claim expires
2
+
3
+ - **Status:** accepted
4
+ - **Date:** 2026-09-10
5
+ - **Task:** [1003507](https://cloudbongos.com/builders#/task/1003507) (goal 1000095 — *Working area 6*, criterion `wa6-role-experience`)
6
+ - **Supersedes the "never touch a box with a claude process" rule** in [ADR 0031](<redacted>.md) §5(a); the park/preserve guarantee of tasks 1002726/1002727 is unchanged and is what makes this safe.
7
+
8
+ ## Context
9
+
10
+ `sweep-idle` parks an `active` box whose heartbeat has gone quiet. Which boxes it may touch was decided by `selectIdleBoxes()`, and the rule was: skip any box with `claude_active = true`, at any age, plus skip anything whose `last_activity_at` is recent.
11
+
12
+ Both halves were satisfiable without a human being anywhere near the box.
13
+
14
+ 1. **`claude_active` is a latch, not a level.** Only a heartbeat ping writes it, and `infra/box-heartbeat.sh` **exits without pinging** when it sees no activity. So silence — the very signal the sweep exists to act on — could never clear the flag. Reproduced against the shipped selector: a row with `claude_active = true` was skipped at `idleMinutes` of 10, 120, 1440 and **5,256,000** (ten years). Not "reaped late": never.
15
+ 2. **`load > 0.2` kept the activity clock fresh.** A running devcontainer plus an idle `claude` clears that floor on its own, so `last_activity_at` was bumped every 5 minutes indefinitely.
16
+
17
+ Either leg alone kept a box alive, which is why the incident looked inexplicable: `example-owner`'s box had been up since 2026-08-12 with a heartbeat every 5 minutes, while tmux session `otb` had been **unattached since 2026-08-15 18:01 UTC** and the `claude` inside it had burned 16 minutes of CPU across 25 hours of wall clock. An abandoned interactive `claude` sitting at its prompt is byte-for-byte indistinguishable from a working one. Cost: **$20.21** on one box, almost all of it unattended.
18
+
19
+ **A third defect made the whole sweep inert**, found while testing this fix and confirmed on clean `origin/main`: `loadDeps()` in `scripts/gds/box.js` opened with `if (_deps) return _deps;` and **`_deps` was never declared**. The assignment further down would have created a global in sloppy mode, but the read comes first, and reading an undeclared identifier is a `ReferenceError`. So `loadDeps()` threw on its first call, every time, and every command that resolves a DigitalOcean client died with it — including `sweep-idle --apply`, which builds that client *before* the park loop. **The idle sweep could not park any box at all.** No test caught it because the only `apply: true` test in the suite relied on the `claude_active` veto emptying the idle set before `makeDo()` was reached: the bug was hidden behind the other bug.
20
+
21
+ ## Decision
22
+
23
+ **A box may not stay active longer than `BOX_UNATTENDED_MAX_HOURS` (default 12) without evidence that a human was actually attached to it.** One number bounds both proxies.
24
+
25
+ The heartbeat already computed the human signal and threw it away by folding it into one boolean. It now reports **`attached`** separately — a login session (`who`), an established inbound SSH connection, an open browser terminal (ttyd), or an **attached tmux client** (`#{session_attached}`, the signal that distinguishes this incident's box from a working one). The server stamps `last_attached_at` from it. `claude_active` keeps its meaning and its veto, but the veto now expires: `claude_active_since` records the `false→true` edge (`COALESCE`d across a run of true beats, so it measures the session and not the last 5 minutes), and past the cap the flag stops counting.
26
+
27
+ `pgrep -x claude` and the load floor stay as reasons the box **pings**; they are no longer accepted as evidence anyone is **there**. That distinction is the whole decision — a running process is not a person.
28
+
29
+ Both new clocks are cleared with `claude_active` at park, wake and deprovision, or a woken box would inherit an expired latch and be parked instantly — the fix reintroducing the bug from the far side.
30
+
31
+ ### `NULL` means "no data", never "unattended"
32
+
33
+ A box still running the pre-1003507 heartbeat never sends `attached`, so its `last_attached_at` stays `NULL` forever. Treating that as unattended would park **every un-upgraded box mid-work**, and box source sync is not reliably prompt (idea 1000745 records a spell where a box sat 257 commits behind for three days). So `NULL` is read as unknown and the box keeps the old behaviour, and migration `core_238` deliberately **does not backfill** the column — a backfilled `now()` would start a clock nothing could ever advance and park the box one cap later.
34
+
35
+ `claude_active_since` takes the opposite default and **is** backfilled, because an unknown latch age *is* the forever-latch being fixed; after the migration a `true` flag with no stamp is unreachable, so refusing it costs nothing and closes the hole.
36
+
37
+ ### Why 12 hours
38
+
39
+ Longer than any interactive session and long enough for a real overnight autonomous run. The asymmetry justifies the generosity: since task 1002726 the idle sweep **parks** — snapshot kept, filesystem preserved — so a false positive costs one wake, while no cap at all cost $20.21 on a single box. Tunable per instance via the env var.
40
+
41
+ ## Consequences
42
+
43
+ - `sweep-idle` can park a box that claims `claude_active`. Its log names the clock that selected it (`unattended 21h (cap 12h) — abandoned session`) through a shared `idleReason()`, so the log cannot drift from the selection.
44
+ - **`sweep-idle --apply` works for the first time.** Fixing `_deps` was not optional scope: the selection fix is inert without it, since the sweep crashes before parking anything. A regression test now reaches dep resolution through the same `apply: true` path that hid it.
45
+ - A box nobody ever attaches to (a pure autonomous run on an upgraded heartbeat, `last_attached_at` still `NULL`) is bounded by the `claude_active_since` cap rather than by attachment. That is the intended fallback and the reason the claude clock kept its own bound rather than deferring entirely to attachment.
46
+
47
+ ## Rejected
48
+
49
+ - **Tracking attachment *instead of* bounding `claude_active`** (the task's option (b), and the more honest-looking fix): it does not close the reported hole. A box that stops pinging keeps its last `claude_active = true` forever, because only a ping can clear it — so the forever-latch survives untouched, and the fix would have shipped against a stale-heartbeat fixture looking green.
50
+ - **Bounding `claude_active` alone** (options (a)/(c)): this is what was built first, and the verification probe caught it — the incident's heartbeat was *fresh*, so lifting the veto changed nothing and the box was still never idle. Recorded because it is the plausible-looking half-fix, and the task's own framing offered the three options as alternatives when the reported cost needs two of them.
51
+ - **Deleting the `load > 0.2` floor** so an abandoned box simply stops pinging: it is what makes an autonomous run count as use, and removing it would park legitimate unattended runs after `idleMinutes`.
52
+ - **Measuring the cap from `active_since`**: that is uptime. A box worked on continuously for days has an old `active_since` and would be parked mid-work.
53
+ - **Raising `BOX_IDLE_MINUTES`**: the reproduction shows no threshold reaches these boxes. The veto was unbounded, so the threshold was never the variable.
@@ -368,3 +368,4 @@ This keeps the decision history honest and traceable.
368
368
  | 0274 | [**One kernel, three role packs: the always-loaded instructions stop being an engineer's** ([task 1002990](https://cloudbongos.com/builders#/task/1002990) · goal 1000095 — *Working area 6, Governor / Builder / Artist / Ideator experience*, criterion `wa6-kernel-and-packs`). The root `CLAUDE.md` loads in full every session for every craft, and it was an engineer's document: 250 lines / 32,422 chars of claim-before-working, worktree-per-claim, `DEPS_NOT_SHIPPED`, `touches[]` and `/merge-mode`. An ideator and an artist each paid the full token cost of a pipeline they were not going to run and read it as instructions addressed to them — and it CONTRADICTED the same goal's `wa6-drift-stops-the-session`, which says ideation *"needs no claim"*, while the kernel's hard rule said every change is backed by one. The craft-specific half lived in three SKILLS (`/dev`, `/ideate`, `/paint`), the wrong carrier twice over: a skill's description is resident in every session's listing whatever craft is running (27,022 chars against a ~8,000 budget, three of them over-length), and a skill must be INVOKED — a session that never types the command works from the kernel alone, which is the engineer's document again. **Decision: a role-neutral kernel plus one pack per craft under `docs/packs/`, bound by a registry CI checks.** The kernel keeps what is true for every craft (the lifecycle, the money, the trust boundary, the identity split, the charter, navigation) and drops to 216 lines / 22,572 chars, clearing the character target it had been over; the per-line test gains a second half — *is it true for every craft?* The Engineer pack inherits today's methodology; the Ideator pack absorbs `/ideate` and writes down the two grades the criterion names (a quick idea stays ONE LINE, then Claude ASKS whether to develop it into a Full Idea — five questions drawn out of the conversation, in the ideator's own words, scored on completeness); the Artist pack absorbs `/paint`, generalised off the pixel-art pipeline per [ADR 0272](<redacted>.md) — show-first, as little interpretation as possible, and ARRIVE PRECALCULATED, because a session that opens with intake questions is a form. The three skills are deleted. `scripts/gds/discipline-modes.json` becomes the ROLE REGISTRY: a core craft names a `pack` (a file to read), a module-contributed discipline may still name a `skill` (`ui` → `/design`, unchanged as ADR 0272 requires), and `claim.js` prints whichever with the right verb. **`scripts/gds/role-pack-guard.js` HARD-FAILS CI** on a pack path that resolves to nothing, an entry with neither pointer, fewer than three packs, or a pack over budget (16,000 hard / 13,000 target) — hard precisely BECAUSE `claim.js` fails open: a broken path degrades to silence, the role is hollow, and no error says so, so CI is the only gate ("CI budgets each pack"). `docs/packs/` over `.claude/packs/` because `docs/` is already owned by the `methodology` scope key — a new top-level path resolves to NO key, and the scope map's own comment records the cost (a missing key is an empty wall, and goal-scope-check then denies every member-authored task); it is registered explicitly in the default-deny publish allowlist, in lockstep with the registry, or a released core would print an unresolvable pack path on every claim. `ship-card.js` moved with the claim (it would have shipped an "Open the work" button firing a deleted command — the 1003487 divergence in reverse) and `tests/idea_ideate_full.mjs` moved with its subject, its thirteen Full-Idea assertions passing against the pack unchanged. Governor is deliberately DEFERRED — three packs, not four, until the owner decides; the guard floors at three. Rejected: keeping the skills beside the packs (two homes drift, and the listing cost stays); packs AS skills (the exact failure being fixed); duplicating the safety rules into all three packs (the charter forbids it — three copies drift and the kernel becomes the one nobody trusts); leaving the guard advisory like the doc-comment check (that one gates a judgement, this one gates a resolvable path). **Not done:** the kernel is 216 lines against a ~200 target — still a warning, as it was at 250; the line count is the context-budget cluster's work (tasks 1003620/1003687/1003688), which the goal's audit sequenced behind this one.](<redacted>.md) | methodology / roles / context budget |
369
369
  | 0275 | [**A role's written responsibility has one source, and the pack's copy is generated** ([task 1003732](https://cloudbongos.com/builders#/task/1003732) · goal 1000095 — *Working area 6*, criterion `wa6-written-responsibilities`; statements fixed by the area owner 2026-09-08). The criterion is one sentence — *"one source, shown on the profile, injected into the pack, referenced by grading"* — and before this task the three statements existed only in the criterion record, nowhere in the tree. The interesting half is why ONE source and not three good copies: if each consumer holds its own, they drift, and the drift has a specific unfair shape — the project shows a builder one standard on their profile, teaches their session a second, and grades the shipped work against a third, so someone is judged by a rule they were never shown. **Decision: a frozen map at `src/role-responsibilities.js` (verbatim owner text, imports nothing), read by three consumers, with the one copy that CAN drift generated and CI-gated.** The hall reads it through `clientModules()` — already injected into every served page as `window.__MODULES__` and already carrying the discipline roster the statements are keyed by, so the profile renders with no new route, no fetch and no copy. The grader reads `responsibilityFor()` through the module doorway and states the standard in the prompt as the project's, not its own. The PACKS get a generated block: markdown cannot require a JS module, so their copy is the only one that can go stale — and it is the worst to lose, being what a session is actually instructed by. `gen-role-responsibilities.js` writes it from the ROLE REGISTRY (so a renamed pack, or Governor when the owner decides, needs no edit), and `role-pack-guard.js` fails CI on drift. Three consequences worth the record. (1) `fitness.js` needed an EXEMPTION for the owner's own prose: the ideator's statement opens "Make good ideas" and `ideas` is a module key, so the no-module-keys scan flagged an English word — paraphrasing to satisfy a scanner is precisely what the task forbids, so the reason is written down and the file's import DIRECTION is still guarded. (2) Registering the freshness check inside `checkGeneratedArtifactsFresh` pushed `fitness.js` one line past its 1,500-line ratchet; rather than raise the baseline (which the check forbids in its own message) the assertion MOVED into `role-pack-guard.js`, which already owns the packs — one check now answers "are the packs correct". (3) The generator matches each pack's own line ending, because `.md` checks out CRLF on Windows and LF in CI, and a generator that always wrote `\n` would make the gate red on one checkout and green on the other for a file nobody touched. A craft with no statement (`ui`, module-contributed per [ADR 0272](<redacted>.md); Governor, deferred per [ADR 0274](<redacted>.md)) renders NOTHING everywhere — no empty quote, no grader line — because a grader told a role has no written standard would supply its own. The profile's avatar was repinned to the top row (the head grid is `align-items:center`, fine for three short lines, wrong once the block made the column tall); verified in both themes in the hall preview. `tests/role_responsibilities.mjs` asserts the criterion's actual content by sweeping `src/`, `scripts/`, `modules/`, `tests/` and `docs/packs/` for any second copy. Rejected: three hand-written paragraphs (the drift would be invisible); a dedicated route for the hall (a fetch and a failure mode for static text already on a rail); storing the statements in the DB (not live state — a row puts the project's written standard outside code review); letting the grader describe a craft from its name (the invented standard this removes); raising `oversized_file_count` to let `fitness.js` grow.](<redacted>.md) | roles / grading / generated artifacts |
370
370
  | 0276 | [**The skill-listing budget cannot hold 64 skills and every trigger, so the number is ratcheted and the choice goes to the owner** ([task 1003620](https://cloudbongos.com/builders#/task/1003620) · goal 1000095 — *Working area 6*, criterion `wa6-role-experience`). Every SKILL.md description is resident in EVERY session for every craft, and `skill-lint` budgets the whole listing at 8,000 chars. It has been trimmed twice ([#1003548], [#1003587]) and grown back both times, because the 400-char per-description aim was a WARNING THAT ACCUMULATES. This task rewrote 49 of the 50 `.claude/skills/` descriptions — 17 warnings to 0, listing 25,624 → 19,316 chars, about 1,570 tokens returned to every session — with every trigger phrase preserved and CHECKED MECHANICALLY (a checker diffed the quoted phrases against HEAD and caught five real losses, all restored; four dropped strings were UI labels, not triggers). **8,000 was not reached, and the arithmetic says it cannot be:** across 64 skills the names (880) plus the mandatory quoted triggers (~5,370) are an irreducible ~6,250, leaving ~27 chars per skill to say what the skill DOES. Reaching the budget means trigger-only descriptions — trading truncation for the loss of the semantic signal routing leans on when the user’s words do not literally match a trigger. **Decision: ratchet `skill_listing_chars` at 19,316** in `fitness-ratchets.js` (fails open if the linter cannot load), so it cannot grow while the real question is open; the baseline is deliberately ABOVE skill-lint’s budget and is not a claim the budget is met. **Open and owner-gated:** delist skills (64 is a menu), scale the budget with the roster, or accept trigger-only text — recommendation is delist then rescale, filed as a blocker. `fitness.js`’s over-budget warning STAYS, as the visible trace of that question. Rejected: raising `LISTING_BUDGET_CHARS` to silence the warning (deletes the signal, not the debt); re-cutting the 14 module ui-design descriptions a week after [#1003587] wrote them, for ~2,000 chars toward a target still 9,000 away.](<redacted>.md) | skills / context budget / routing |
371
+ | 0277 | [**A box is "in use" only while a human is attached, and the claim expires** ([task 1003507](https://cloudbongos.com/builders#/task/1003507) · goal 1000095 — *Working area 6*, criterion `wa6-role-experience`). `sweep-idle` skipped any box with `claude_active = true` at ANY age, and `claude_active` is a LATCH, not a level: only a heartbeat ping writes it, and `infra/box-heartbeat.sh` exits WITHOUT pinging when it sees nothing — so silence, the very signal the sweep exists to act on, could never clear it. Reproduced against the shipped selector: skipped at `idleMinutes` of 10, 120, 1440 and **5,256,000** (ten years). Not reaped late; never. The second leg is that `load > 0.2` kept `last_activity_at` bumping every 5 minutes anyway (a devcontainer plus an idle `claude` clears that floor on its own), so EITHER leg alone kept a box alive — which is why bounding only the veto looks like a fix and is not: the incident box's heartbeat was FRESH. Measured: `example-owner`'s box up since 2026-08-12, tmux `otb` UNATTACHED since 2026-08-15 18:01 UTC, `claude` burning 16 min of CPU across 25h of wall clock, **$20.21** of mostly-unattended compute. A THIRD defect, found while testing this and confirmed on clean `origin/main`, made the sweep inert regardless: `loadDeps()` read `_deps` before anything declared it, so it threw `ReferenceError` on first call and every command resolving a DigitalOcean client went with it — including `sweep-idle --apply`, which builds that client before the park loop. **The idle sweep could not park any box at all**, hidden because the suite's only `apply: true` test relied on the veto emptying the idle set before `makeDo()` was reached: one bug shielded by the other. **Decision: a box may not stay active longer than `BOX_UNATTENDED_MAX_HOURS` (12) without evidence a HUMAN was attached.** The heartbeat already computed that signal and folded it into one boolean; it now reports `attached` separately (login session, inbound SSH, open ttyd, or an ATTACHED tmux client via `#{session_attached}` — the signal that separates this box from a working one) and the server stamps `last_attached_at`. `claude_active` keeps its veto but it EXPIRES, bounded by a `claude_active_since` edge stamp. `pgrep -x claude` and the load floor remain reasons the box PINGS, never evidence anyone is THERE — a running process is not a person, and that distinction is the whole decision. The two `NULL` defaults deliberately DISAGREE: `last_attached_at` NULL means "no data" and keeps a pre-1003507 box on the old behaviour (core_238 pointedly does NOT backfill it — a backfilled `now()` starts a clock nothing can advance and parks every un-upgraded box one cap later, and box source sync is not prompt: idea 1000745 records 257 commits behind for three days), while `claude_active_since` NULL is REFUSED because an unknown latch age is the forever-latch itself, and is backfilled so the state is unreachable after deploy. 12h because parking is reversible since task 1002726 (snapshot kept), so a false positive costs one wake against $20.21 for no cap. Both clocks clear at park/wake/deprovision, or a woken box inherits an expired latch and is parked instantly — the fix reintroducing the bug from the far side. Rejected: tracking attachment INSTEAD of bounding the latch (the silent box keeps `true` forever, so the reported hole survives); bounding the latch alone (built first, and the verification probe caught it — the fresh heartbeat meant lifting the veto changed nothing); deleting the load floor (it is what makes an autonomous run count); measuring from `active_since` (that is uptime — parks a box worked on for days); raising `BOX_IDLE_MINUTES` (no threshold reaches an unbounded veto).](<redacted>.md) | dev box / cost / idle sweep |
@@ -1775,7 +1775,7 @@
1775
1775
  "box"
1776
1776
  ],
1777
1777
  "summary": "POST /box/heartbeat",
1778
- "description": "POST /box/heartbeat — the box reports it is alive, resetting the idle clock. rank: any authenticated builder (own resource write). Optional body: { claude_active: bool } when provided, records whether a `claude` process is running. The idle sweep skips boxes with claude_active=true, so an active Claude session keeps the box alive indefinitely. Heartbeats without a body behave as before (last_activity_at bumped, claude_active unchanged). task 919: allowBoxScope — box-heartbeat.sh (cron */5) runs with the box-scoped session.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
1778
+ "description": "POST /box/heartbeat — the box reports it is alive, resetting the idle clock. rank: any authenticated builder (own resource write). Optional body: { claude_active: bool, attached: bool }. `claude_active` records whether a `claude` process is running; the idle sweep honours it as a veto, but since task 1003507 only for BOX_UNATTENDED_MAX_HOURS (a running process is not a person, and an unbounded veto pinned boxes active forever). `attached` is the human signal — a login session, an inbound SSH connection, an open browser terminal, or an attached tmux client — and stamps last_attached_at, the clock that cap is measured against. A heartbeat that omits `attached` (any box still running the pre-1003507 script) leaves the column NULL, which the sweep reads as \"no data\" and not as \"unattended\". Heartbeats without a body behave as before (last_activity_at bumped only). task 919: allowBoxScope — box-heartbeat.sh (cron */5) runs with the box-scoped session.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
1779
1779
  "x-rank": "any-builder",
1780
1780
  "x-source": "modules/dev-box/routes/box.js",
1781
1781
  "requestBody": {
@@ -19399,6 +19399,9 @@
19399
19399
  "properties": {
19400
19400
  "claude_active": {
19401
19401
  "type": "boolean"
19402
+ },
19403
+ "attached": {
19404
+ "type": "boolean"
19402
19405
  }
19403
19406
  },
19404
19407
  "additionalProperties": true
@@ -91,7 +91,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
91
91
  | GET | `/api/bongos/box/connect.ps1` | `public` | — | GET /box/connect.ps1 — the Windows (PowerShell) bootstrap script. |
92
92
  | GET | `/api/bongos/box/connect.sh` | `public` | — | GET /box/connect.sh — the macOS/Linux bootstrap script. |
93
93
  | POST | `/api/bongos/box/ensure` | `any-builder` | — | POST /box/ensure — "make my box ready". |
94
- | POST | `/api/bongos/box/heartbeat` | `any-builder` | `claude_active` | POST /box/heartbeat — the box reports it is alive, resetting the idle clock. |
94
+ | POST | `/api/bongos/box/heartbeat` | `any-builder` | `claude_active`, `attached` | POST /box/heartbeat — the box reports it is alive, resetting the idle clock. |
95
95
  | POST | `/api/bongos/box/host-keys` | `any-builder` | `host_keys` | POST /box/host-keys — the box publishes its own PUBLIC SSH host keys (task 1187 / idea 235). |
96
96
  | GET | `/api/bongos/box/managed-settings` | `any-builder` | — | GET /box/managed-settings — the caller's Claude Desktop managed-settings.json. |
97
97
  | GET | `/api/bongos/box/me` | `any-builder` | — | GET /box/me — the caller's own box. |
@@ -1751,5 +1751,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1751
1751
  landed since 1.19.649 with no explicit bump. run 34523753119. (task 1002620)
1752
1752
  1.19.651 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1753
1753
  landed since 1.19.650 with no explicit bump. run 34526936330. (task 1002620)
1754
+ 1.19.652 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1755
+ landed since 1.19.651 with no explicit bump. run 34529655648. (task 1002620)
1756
+ 1.19.653 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1757
+ landed since 1.19.652 with no explicit bump. run 34543713846. (task 1002620)
1754
1758
  ---------------------------------------------------------------------------
1755
1759
  ```
@@ -0,0 +1,57 @@
1
+ -- migration core_238: bound how long a dev box may claim to be in use
2
+ -- (task 1003507).
3
+ --
4
+ -- WHY: a box could assert "in use" forever from two proxies, neither of which
5
+ -- implies a human is present.
6
+ --
7
+ -- 1. claude_active is a LATCH, not a level. Only a heartbeat ping writes it,
8
+ -- and infra/box-heartbeat.sh exits WITHOUT pinging when it sees no
9
+ -- activity - so silence, the very signal the idle sweep exists to act on,
10
+ -- could never clear it. selectIdleBoxes() skipped claude_active=true rows
11
+ -- with no time bound, so such a box was unreapable at EVERY threshold.
12
+ -- 2. `load > 0.2` kept last_activity_at bumping, so the activity clock never
13
+ -- went stale either. A running devcontainer plus an idle `claude` clears
14
+ -- that floor on its own.
15
+ --
16
+ -- Together they made an abandoned interactive `claude` at its prompt in an
17
+ -- UNATTACHED tmux session indistinguishable from a working one. Measured: one
18
+ -- box unattended since 2026-08-15 burned 20.21 USD of compute the sweep could
19
+ -- not reach.
20
+ --
21
+ -- Two columns, because the fix needs two clocks:
22
+ -- claude_active_since - when claude_active last went false->true, so the veto
23
+ -- can be bounded by how long it has been claimed.
24
+ -- last_attached_at - when a HUMAN was last actually attached (a login
25
+ -- session, an inbound SSH connection, an open browser
26
+ -- terminal, or an attached tmux client). The heartbeat
27
+ -- already computed this and folded it into one boolean;
28
+ -- it now reports it separately.
29
+ --
30
+ -- Idempotent: ADD COLUMN IF NOT EXISTS, and the backfill is guarded on IS NULL
31
+ -- so a fresh restore + replay never moves a stamp later beats have written.
32
+
33
+ BEGIN;
34
+
35
+ ALTER TABLE builder_boxes
36
+ ADD COLUMN IF NOT EXISTS claude_active_since timestamptz;
37
+
38
+ ALTER TABLE builder_boxes
39
+ ADD COLUMN IF NOT EXISTS last_attached_at timestamptz;
40
+
41
+ -- Backfill claude_active_since ONLY. A box that is claude_active right now has
42
+ -- no recorded latch age, and refusing an unknown age would park boxes that are
43
+ -- genuinely in use the moment this deploys. Stamp now() so every live box gets
44
+ -- one full grace window; the next heartbeat keeps it honest from there.
45
+ UPDATE builder_boxes
46
+ SET claude_active_since = now()
47
+ WHERE claude_active = true
48
+ AND claude_active_since IS NULL;
49
+
50
+ -- last_attached_at is deliberately NOT backfilled. A box still running a
51
+ -- heartbeat from before this change never reports attachment, so its column
52
+ -- stays NULL forever - and selectIdleBoxes reads NULL as "no attachment data"
53
+ -- rather than "unattended", which is what keeps such a box on the old behaviour
54
+ -- instead of parking it mid-work. Backfilling now() would instead start a clock
55
+ -- that nothing can ever advance, and park every un-upgraded box after the cap.
56
+
57
+ COMMIT;
@@ -164,22 +164,100 @@ function effectiveActivityMs(row) {
164
164
  return toMs(row.last_activity_at) ?? toMs(row.active_since) ?? toMs(row.provisioned_at);
165
165
  }
166
166
 
167
- // Idle = active boxes whose effective activity is older than idleMinutes AND
168
- // that don't have an active Claude process. A box with claude_active=true is
169
- // kept alive regardless of last_activity_at the session ends when Claude stops.
170
- // Pure: caller passes the candidate rows and nowMs; returns the subset to deprovision.
171
- function selectIdleBoxes(rows, { idleMinutes, nowMs } = {}) {
167
+ // ---- the unattended cap (task 1003507) ------------------------------------
168
+ //
169
+ // A box used to be able to assert "in use" forever from two proxies, neither of
170
+ // which implies a human is present:
171
+ //
172
+ // 1. `pgrep -x claude` -> claude_active=true, which selectIdleBoxes skipped
173
+ // with NO time bound. claude_active is a LATCH, not a level: only a
174
+ // heartbeat ping writes it, and box-heartbeat.sh exits WITHOUT pinging when
175
+ // it sees nothing — so silence, the very signal the sweep exists to act on,
176
+ // could never clear it. Unreapable at EVERY threshold, forever.
177
+ // 2. `load > 0.2` -> ACTIVE=1, which keeps bumping last_activity_at. A running
178
+ // devcontainer plus an idle `claude` clears that floor on its own, so the
179
+ // activity clock never went stale either.
180
+ //
181
+ // Together they made an abandoned interactive `claude` at its prompt in an
182
+ // UNATTACHED tmux session indistinguishable from a working one, and one such box
183
+ // burned $20.21 of unattended compute the sweep could not reach. Bounding only
184
+ // (1) does not stop the bleeding, because (2) keeps the box out of the idle set
185
+ // on its own — so both proxies are bounded by the same policy:
186
+ //
187
+ // A box may not stay active for longer than `unattendedMaxHours` without
188
+ // evidence that a human was actually attached to it.
189
+ //
190
+ // "Attached" is what the heartbeat already knew and used to throw away by
191
+ // folding it into one boolean: a login session (`who`), an established inbound
192
+ // SSH connection, an open browser terminal (ttyd), or an ATTACHED tmux client.
193
+ // It now reports that separately and the server stamps `last_attached_at`.
194
+ const HOUR_MS = 3_600_000;
195
+
196
+ // Has a human been attached recently enough for this box's claims to stand?
197
+ // - no cap configured -> yes (the pre-1003507 unbounded behaviour;
198
+ // the policy lives in the caller's CONFIG)
199
+ // - last_attached_at unset -> yes. A box whose heartbeat predates this change
200
+ // never reports attachment, so its column stays
201
+ // NULL forever — treating unknown as unattended
202
+ // would park every such box mid-work. NULL is
203
+ // deliberately NOT backfilled for that reason.
204
+ // - within the cap -> yes
205
+ // - older than the cap -> no; the box is unattended
206
+ function attendedRecently(row, { nowMs, unattendedMaxHours } = {}) {
207
+ if (!Number.isFinite(unattendedMaxHours) || unattendedMaxHours <= 0) return true;
208
+ const at = toMs(row.last_attached_at);
209
+ if (at == null) return true;
210
+ return at > nowMs - unattendedMaxHours * HOUR_MS;
211
+ }
212
+
213
+ // Does claude_active still earn this box a pass from the idle sweep? Bounded by
214
+ // how long it has been claimed, read off `claude_active_since` (migration
215
+ // core_238, stamped on the false->true edge so it measures the whole session
216
+ // rather than the last beat). An unknown age is refused: core_238 backfills every
217
+ // box that is claude_active at deploy, so a NULL stamp under a true flag is the
218
+ // forever-latch this fixes, not a live session.
219
+ function claudeVetoHolds(row, { nowMs, unattendedMaxHours } = {}) {
220
+ if (!row.claude_active) return false;
221
+ if (!Number.isFinite(unattendedMaxHours) || unattendedMaxHours <= 0) return true;
222
+ const since = toMs(row.claude_active_since);
223
+ if (since == null) return false;
224
+ return since > nowMs - unattendedMaxHours * HOUR_MS;
225
+ }
226
+
227
+ // Idle = active boxes that are either (a) quiet past idleMinutes with no live
228
+ // claude_active veto, or (b) still chattering but unattended past the cap. (b) is
229
+ // the abandoned-session case: the heartbeat is fresh, so (a) can never fire.
230
+ // Pure: caller passes the candidate rows and nowMs; returns the subset to park.
231
+ function selectIdleBoxes(rows, { idleMinutes, nowMs, unattendedMaxHours } = {}) {
172
232
  if (!Number.isFinite(idleMinutes) || idleMinutes <= 0) return [];
173
233
  if (!Number.isFinite(nowMs)) return [];
174
234
  const cutoff = nowMs - idleMinutes * 60_000;
175
235
  return (rows || []).filter((r) => {
176
236
  if (r.state !== 'active') return false;
177
- if (r.claude_active) return false;
237
+ if (!attendedRecently(r, { nowMs, unattendedMaxHours })) return true;
238
+ if (claudeVetoHolds(r, { nowMs, unattendedMaxHours })) return false;
178
239
  const a = effectiveActivityMs(r);
179
240
  return a != null && a <= cutoff;
180
241
  });
181
242
  }
182
243
 
244
+ // Why the sweep picked a box, for the operator log. Mirrors selectIdleBoxes'
245
+ // branches exactly — if these two ever disagree the log is lying, so they are
246
+ // read together.
247
+ function idleReason(row, { nowMs, idleMinutes, unattendedMaxHours } = {}) {
248
+ if (!attendedRecently(row, { nowMs, unattendedMaxHours })) {
249
+ const h = Math.round((nowMs - toMs(row.last_attached_at)) / HOUR_MS);
250
+ return `unattended ${h}h (cap ${unattendedMaxHours}h) — abandoned session`;
251
+ }
252
+ const mins = Math.round((nowMs - (effectiveActivityMs(row) || nowMs)) / 60_000);
253
+ if (row.claude_active) {
254
+ const since = toMs(row.claude_active_since);
255
+ const held = since == null ? 'no claude_active_since recorded' : `claude_active ${Math.round((nowMs - since) / HOUR_MS)}h`;
256
+ return `idle ${mins}m, ${held} past the ${unattendedMaxHours}h cap`;
257
+ }
258
+ return `idle ${mins}m`;
259
+ }
260
+
183
261
  // Dormant = parked boxes that have been parked longer than dormantDays. These
184
262
  // get fully de-provisioned (snapshot deleted too) to reclaim the last cents.
185
263
  // Since task 1002726 this is the ONLY automated path that destroys a snapshot —
@@ -469,7 +547,7 @@ async function recordEvent(db, { boxId, builderId, event, detail, costUsd, actor
469
547
  // active box, so logging one per beat would grow the ledger unbounded
470
548
  // (~100K rows/builder/year). last_activity_at IS the heartbeat record; box_events
471
549
  // is reserved for the rare lifecycle transitions (provision/park/wake/deprovision/cost).
472
- async function bumpActivity(db, builderId, { claudeActive } = {}) {
550
+ async function bumpActivity(db, builderId, { claudeActive, attached } = {}) {
473
551
  const hasClaude = typeof claudeActive === 'boolean';
474
552
  const claudeCol = hasClaude ? ', claude_active = $2' : '';
475
553
  // The FIRST time a box reports a live `claude` process, stamp
@@ -481,10 +559,29 @@ async function bumpActivity(db, builderId, { claudeActive } = {}) {
481
559
  const connectedCol = (hasClaude && claudeActive === true)
482
560
  ? ', first_connected_at = COALESCE(first_connected_at, now())'
483
561
  : '';
562
+ // task 1003507 — claude_active_since is the EDGE, not the level: stamped when
563
+ // the flag goes false->true (COALESCE keeps the original edge across a run of
564
+ // true beats, so the latch measures the whole session, not the last 5 minutes)
565
+ // and cleared on a false beat so the next session starts a fresh clock. Without
566
+ // this the flag has no age, and selectIdleBoxes cannot bound its own veto.
567
+ const sinceCol = hasClaude
568
+ ? (claudeActive === true
569
+ ? ', claude_active_since = COALESCE(claude_active_since, now())'
570
+ : ', claude_active_since = NULL')
571
+ : '';
572
+ // task 1003507 — `attached` is the HUMAN signal (a login session, an inbound
573
+ // SSH connection, an open browser terminal, or an attached tmux client), kept
574
+ // separate from claude_active because a running process is not a person. Only
575
+ // a true beat writes it: last_attached_at is a high-water mark, so a beat that
576
+ // reports nobody attached leaves the last real attachment where it was and the
577
+ // unattended clock keeps running. A box whose heartbeat predates this change
578
+ // never sends the field, leaving the column NULL, which selectIdleBoxes reads
579
+ // as "no data" rather than "unattended".
580
+ const attachedCol = attached === true ? ', last_attached_at = now()' : '';
484
581
  const params = hasClaude ? [builderId, claudeActive] : [builderId];
485
582
  const { rows } = await db.query(
486
583
  `UPDATE builder_boxes
487
- SET last_activity_at = now(), updated_at = now()${claudeCol}${connectedCol}
584
+ SET last_activity_at = now(), updated_at = now()${claudeCol}${connectedCol}${sinceCol}${attachedCol}
488
585
  WHERE builder_id = $1 AND state = 'active'
489
586
  RETURNING state`,
490
587
  params
@@ -516,6 +613,10 @@ const SETTABLE_BOX_COLUMNS = new Set([
516
613
  'scope', 'provisioned_rank', 'region', 'size_slug', 'droplet_id', 'droplet_name',
517
614
  'snapshot_id', 'ip', 'hostname', 'last_activity_at', 'provisioned_at',
518
615
  'active_since', 'parked_at', 'destroyed_at', 'error_note', 'claude_active',
616
+ // task 1003507 — park/wake/deprovision clear the claude_active latch and its
617
+ // clock together. A woken box that kept a stale claude_active_since would be
618
+ // instantly reapable, which is the fix reintroducing the bug from the far side.
619
+ 'claude_active_since', 'last_attached_at',
519
620
  // host_keys/host_keys_at are normally written by setBoxHostKeys(), but
520
621
  // deprovisionOne() clears them on teardown so a rebuilt box never serves its
521
622
  // predecessor's SSH identity (idea 310) — hence they are settable here too.
@@ -889,7 +990,10 @@ module.exports = {
889
990
  storageCostUsd,
890
991
  summarizeBoxCost,
891
992
  // pure — selection
993
+ attendedRecently,
994
+ claudeVetoHolds,
892
995
  effectiveActivityMs,
996
+ idleReason,
893
997
  selectIdleBoxes,
894
998
  selectDormantBoxes,
895
999
  selectDormantWarnBoxes,
@@ -633,21 +633,29 @@ module.exports = function buildBoxRouter() {
633
633
 
634
634
  // POST /box/heartbeat — the box reports it is alive, resetting the idle clock.
635
635
  // rank: any authenticated builder (own resource write). Optional body:
636
- // { claude_active: bool } — when provided, records whether a `claude` process
637
- // is running. The idle sweep skips boxes with claude_active=true, so an active
638
- // Claude session keeps the box alive indefinitely. Heartbeats without a body
639
- // behave as before (last_activity_at bumped, claude_active unchanged).
636
+ // { claude_active: bool, attached: bool }.
637
+ //
638
+ // `claude_active` records whether a `claude` process is running; the idle sweep
639
+ // honours it as a veto, but since task 1003507 only for BOX_UNATTENDED_MAX_HOURS
640
+ // (a running process is not a person, and an unbounded veto pinned boxes active
641
+ // forever). `attached` is the human signal — a login session, an inbound SSH
642
+ // connection, an open browser terminal, or an attached tmux client — and stamps
643
+ // last_attached_at, the clock that cap is measured against. A heartbeat that
644
+ // omits `attached` (any box still running the pre-1003507 script) leaves the
645
+ // column NULL, which the sweep reads as "no data" and not as "unattended".
646
+ // Heartbeats without a body behave as before (last_activity_at bumped only).
640
647
  // #919: allowBoxScope — box-heartbeat.sh (cron */5) runs with the box-scoped session.
641
648
  router.post('/box/heartbeat', auth.allowBoxScope, auth.requireBuilder, async (req, res) => {
642
649
  // strict:false — heartbeat is a machine ping from SHIPPED box clients that
643
650
  // can't update in lockstep with the server (the ADR-0117 un-updatable-consumer
644
651
  // concern). Validate the known field's type, but tolerate a future/extra field
645
652
  // rather than reject a resilience-critical ping (the idle sweep keys off it).
646
- if (validateOrRespond(req, res, { claude_active: { type: 'boolean' } }, { strict: false })) return;
653
+ if (validateOrRespond(req, res, { claude_active: { type: 'boolean' }, attached: { type: 'boolean' } }, { strict: false })) return;
647
654
  try {
648
655
  const body = req.body || {};
649
656
  const claudeActive = typeof body.claude_active === 'boolean' ? body.claude_active : undefined;
650
- const state = await boxes.bumpActivity(pool, req.builder.id, { claudeActive });
657
+ const attached = typeof body.attached === 'boolean' ? body.attached : undefined;
658
+ const state = await boxes.bumpActivity(pool, req.builder.id, { claudeActive, attached });
651
659
  res.json({ ok: true, state: state || 'none' });
652
660
  } catch (err) {
653
661
  log.error('[gds] POST /box/heartbeat', err);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.651",
3
+ "version": "1.19.653",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.651",
9
+ "version": "1.19.653",
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.651",
3
+ "version": "1.19.653",
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",
@@ -62,6 +62,14 @@ const CONFIG = {
62
62
  size: process.env.BOX_SIZE || 's-2vcpu-4gb',
63
63
  baseImage: process.env.BOX_BASE_IMAGE || 'ubuntu-24-04-x64',
64
64
  idleMinutes: numEnv('BOX_IDLE_MINUTES', 10),
65
+ // task 1003507 — the longest a box may stay active without evidence that a
66
+ // HUMAN was attached to it. One number bounds both proxies that used to keep a
67
+ // box alive on their own (a running `claude` process; load above the floor), so
68
+ // an abandoned session can no longer pin a droplet forever. 12h is deliberately
69
+ // generous — longer than any interactive session, long enough for a real
70
+ // overnight autonomous run — because being wrong costs one wake, while having
71
+ // no cap at all cost $20.21 on a single box.
72
+ unattendedMaxHours: numEnv('BOX_UNATTENDED_MAX_HOURS', 12),
65
73
  dormantDays: numEnv('BOX_DORMANT_DAYS', 14),
66
74
  // task 1002727 — how many days BEFORE the reclaim a builder gets warned.
67
75
  dormantWarnLeadDays: numEnv('BOX_DORMANT_WARN_LEAD_DAYS', 3),
@@ -28,7 +28,8 @@
28
28
  //
29
29
  // Config (env): DO_API_TOKEN (live only), BOX_PROVISION_MIN_RANK (default xenos),
30
30
  // BOX_REGION (nyc3), BOX_SIZE (s-2vcpu-4gb), BOX_BASE_IMAGE (ubuntu-24-04-x64),
31
- // BOX_IDLE_MINUTES (10), BOX_DORMANT_DAYS (14), BOX_SSH_KEY_IDS (comma list),
31
+ // BOX_IDLE_MINUTES (10), BOX_UNATTENDED_MAX_HOURS (12), BOX_DORMANT_DAYS (14),
32
+ // BOX_SSH_KEY_IDS (comma list),
32
33
  // BOX_DNS_HOOK (optional command, gets BOX_HOSTNAME/BOX_IP in env), BOX_TAG.
33
34
 
34
35
  const fs = require('node:fs');
@@ -109,6 +110,21 @@ async function preflightCookieStripScope() {
109
110
  return scope;
110
111
  }
111
112
 
113
+ // Lazily-built handles to the heavy dependencies (DB pool, DO/CF clients), so a
114
+ // dry-run or a --help never opens a pool or requires a network client.
115
+ //
116
+ // task 1003507 — this was UNDECLARED. `_deps = {...}` further down is an
117
+ // assignment, which in sloppy mode would have created a global, but
118
+ // `if (_deps)` READS it first, and reading an undeclared identifier is a
119
+ // ReferenceError. So loadDeps() threw on its first call, every time, and every
120
+ // command that reaches makeDo()/makeCf() went with it — including
121
+ // `sweep-idle --apply`, which resolves its DO client before the park loop.
122
+ // The idle sweep therefore could not park ANY box, which is a second and
123
+ // entirely separate reason the abandoned box in this task was never reaped.
124
+ // No test caught it because the only apply:true test relied on the
125
+ // claude_active veto emptying the idle set before makeDo() was reached.
126
+ let _deps = null;
127
+
112
128
  function loadDeps() {
113
129
  if (_deps) return _deps;
114
130
  // eslint-disable-next-line global-require
@@ -559,6 +575,10 @@ async function parkOne(pool, boxes, doApi, box, { actor }) {
559
575
  await boxes.setBoxState(pool, box.builder_id, 'parked', {
560
576
  snapshot_id: snapshotId, parked_at: new Date(now),
561
577
  droplet_id: null, ip: null, active_since: null,
578
+ // task 1003507 — the droplet is gone, so there is no `claude` process to
579
+ // report. Clearing the flag AND its clock here means a wake starts a fresh
580
+ // session rather than inheriting an expired latch and being reaped at once.
581
+ claude_active: false, claude_active_since: null,
562
582
  });
563
583
  await boxes.recordEvent(pool, {
564
584
  boxId: box.id, builderId: box.builder_id, event: 'park',
@@ -610,6 +630,10 @@ async function cmdWake(pool, boxes, ref, { apply }) {
610
630
  const now = new Date();
611
631
  await boxes.setBoxState(pool, builder.id, 'active', {
612
632
  droplet_id: String(droplet.id), ip, active_since: now, last_activity_at: now, parked_at: null,
633
+ // task 1003507 — a woken box has reported nothing yet. Start the latch and
634
+ // its clock clean so the first real heartbeat sets both, rather than the
635
+ // box re-entering the sweep carrying a stale claim from its last session.
636
+ claude_active: false, claude_active_since: null,
613
637
  });
614
638
  await boxes.recordEvent(pool, { boxId: box.id, builderId: builder.id, event: 'wake', detail: `droplet ${droplet.id} ${ip || ''}`, actor: actorTag() });
615
639
  // #602: the box just ended a PARKED interval (snapshot retained) — accrue its
@@ -724,7 +748,7 @@ async function deprovisionOne(pool, boxes, doApi, box, { actor, login, sleepFn }
724
748
  if (CONFIG.dnsHook && dnsHost) runDnsHookBlackhole(dnsHost);
725
749
  await boxes.setBoxState(pool, box.builder_id, 'destroyed', {
726
750
  destroyed_at: new Date(now), droplet_id: null, ip: null, snapshot_id: null,
727
- active_since: null, parked_at: null, claude_active: false,
751
+ active_since: null, parked_at: null, claude_active: false, claude_active_since: null,
728
752
  // idea 310: a rebuilt box mints fresh SSH host keys, but the GDS row kept the
729
753
  // OLD keys until the new box re-reported (~10 min). In that window GET /box/me
730
754
  // served a stale identity and the desktop app pinned the WRONG key — the
@@ -811,18 +835,20 @@ async function cmdDeprovision(pool, boxes, ref, { apply }) {
811
835
 
812
836
  async function cmdSweepIdle(pool, boxes, { apply }) {
813
837
  const rows = await boxes.listBoxes(pool, { state: 'active' });
814
- const idle = boxes.selectIdleBoxes(rows, { idleMinutes: CONFIG.idleMinutes, nowMs: Date.now() });
815
- console.log(`idle sweep (threshold=${CONFIG.idleMinutes}m${apply ? '' : ', dry-run'}): ${idle.length} of ${rows.length} active box(es) idle`);
838
+ const sweepNow = Date.now();
839
+ const sel = { idleMinutes: CONFIG.idleMinutes, nowMs: sweepNow, unattendedMaxHours: CONFIG.unattendedMaxHours };
840
+ const idle = boxes.selectIdleBoxes(rows, sel);
841
+ console.log(`idle sweep (threshold=${CONFIG.idleMinutes}m, unattended cap=${CONFIG.unattendedMaxHours}h${apply ? '' : ', dry-run'}): ${idle.length} of ${rows.length} active box(es) idle`);
816
842
  if (idle.length === 0) { console.log(' nothing idle — exit clean'); return { parked: 0 }; }
817
843
  const doApi = apply ? makeDo(apply) : null;
818
844
  let parked = 0, errors = 0;
819
845
  for (const box of idle) {
820
- const mins = Math.round((Date.now() - (boxes.effectiveActivityMs(box) || Date.now())) / 60000);
821
- if (!apply) { console.log(` would park @${box.github_login}'s box (idle ${mins}m, droplet ${box.droplet_id})`); continue; }
846
+ const why = boxes.idleReason(box, sel);
847
+ if (!apply) { console.log(` would park @${box.github_login}'s box (${why}, droplet ${box.droplet_id})`); continue; }
822
848
  try {
823
849
  await parkOne(pool, boxes, doApi, box, { actor: 'sweep:idle' });
824
850
  parked++;
825
- console.log(` ✓ parked @${box.github_login} (idle ${mins}m — snapshot kept, compute stopped)`);
851
+ console.log(` ✓ parked @${box.github_login} (${why} — snapshot kept, compute stopped)`);
826
852
  } catch (e) {
827
853
  // A park that fails must NOT fall through to a destroy — leaving the box
828
854
  // at 'error' with its droplet intact is strictly safer than reclaiming it,
@@ -1058,7 +1084,7 @@ async function cmdReconcileDrift(pool, boxes, { apply }) {
1058
1084
  await teardownEdgeForLogin(g.login, { apply });
1059
1085
  await boxes.setBoxState(pool, g.builderId, 'destroyed', {
1060
1086
  destroyed_at: new Date(nowMs), droplet_id: null, ip: null, snapshot_id: null,
1061
- active_since: null, parked_at: null, claude_active: false,
1087
+ active_since: null, parked_at: null, claude_active: false, claude_active_since: null,
1062
1088
  host_keys: null, host_keys_at: null,
1063
1089
  });
1064
1090
  if (row) {
@@ -1198,7 +1224,7 @@ Commands:
1198
1224
  deprovision <builder> destroy droplet AND snapshot (full reclaim)
1199
1225
  run-intents drain the auto-provision/wake-on-connect queue (#701):
1200
1226
  execute each pending box_intents row (provision/wake)
1201
- sweep-idle park active boxes idle > ${CONFIG.idleMinutes}m
1227
+ sweep-idle park active boxes idle > ${CONFIG.idleMinutes}m, or unattended > ${CONFIG.unattendedMaxHours}h
1202
1228
  sweep-dormant warn at ${Math.max(CONFIG.dormantDays - CONFIG.dormantWarnLeadDays, 0)}d, then deprovision boxes parked > ${CONFIG.dormantDays}d
1203
1229
  (never reclaims a box that was not warned first)
1204
1230
  reconcile source-access clawback: deprovision below-floor/inactive
@@ -48,11 +48,36 @@ const VOCAB_ALLOW = [
48
48
  },
49
49
  ];
50
50
 
51
- // A line CITING a historical artifact by its real name: an ADR filename, the
52
- // docs/governance/ evidence folder, or one of the already-applied
53
- // governance_NNN migrations. These names are permanent (see the header), so a
54
- // reference to one is a citation, not drift.
55
- const VOCAB_HISTORICAL_REF = /docs\/adr\/|docs\/governance\/|docs\/session-logs\/|governance_0\d\d/;
51
+ // A line CITING a durable artifact by its real name: an ADR filename, the
52
+ // docs/governance/ evidence folder, one of the already-applied migrations, or
53
+ // the cross-goal pass-event contract. These names are permanent (see the
54
+ // header), so a reference to one is a citation, not drift.
55
+ //
56
+ // The migration family is `govern(ance|ment)_NNN` on purpose. government_001
57
+ // RENAMED the governance_NNN family to government_NNN, so half the applied
58
+ // migrations on disk carry each spelling and BOTH are permanent — and the
59
+ // rename migration's own filename (`government_001_rename_from_governance.sql`)
60
+ // says the banned word while matching neither half of the old regex. That is
61
+ // not drift; it is the one filename that has to name what it renamed.
62
+ //
63
+ // docs/specs/board-room-pass-event-contract.md is here for the same reason a
64
+ // migration filename is: it is the agreed cross-goal name of the economy-side
65
+ // award (awardIdeaGovernancePassCredit) and the credit_log reason family
66
+ // idea.credit.governance_pass:<id>. Those are durable ledger rows and another
67
+ // goal's method signature — a line pointing at the contract is citing a name
68
+ // this surface does not own and cannot rename.
69
+ const VOCAB_HISTORICAL_REF = /docs\/adr\/|docs\/governance\/|docs\/session-logs\/|govern(?:ance|ment)_0\d\d|board-room-pass-event-contract/;
70
+
71
+ // Naming the RULE stays legal — a line that cites ADR 0174 or the task that
72
+ // built the wall is meta-commentary about the ban, and meta-commentary has to
73
+ // be able to say the banned word. Same principle as check 19b's
74
+ // GOAL_VOCAB_CITATION, and deliberately preferred over a per-file VOCAB_ALLOW
75
+ // entry: the exemption belongs to the CITATION, not to whichever file happens
76
+ // to hold it today. (Adding '.md' to VOCAB_EXTS brought the module's own
77
+ // nested CLAUDE.md into scope — the file that TEACHES this rule, which states
78
+ // it in the same breath. Three allowlist entries would have taken the cap of 5
79
+ // to its limit for lines whose only sin is quoting the rule correctly.)
80
+ const VOCAB_CITATION = /ADR 0174|task 1003008/;
56
81
 
57
82
  const VOCAB_SURFACE = [
58
83
  'modules/government',
@@ -67,7 +92,13 @@ const VOCAB_SURFACE = [
67
92
  'modules/hall-ui/public/board-room.html',
68
93
  'modules/hall-ui/public/board-room.css',
69
94
  ];
70
- const VOCAB_EXTS = new Set(['.js', '.json', '.html', '.css']);
95
+ // '.md' is load-bearing, not tidiness (task 1003120). A nested CLAUDE.md is
96
+ // INSTRUCTION: it loads on demand for every agent working in the module, and
97
+ // the agent that reads the wrong vocabulary there goes on to write it into the
98
+ // code. Leaving markdown unscanned is how modules/government/CLAUDE.md came to
99
+ // assert the INVERSE of ADR 0174 and survive a criterion review (task 1003118).
100
+ // The doc surface is the higher-leverage one to guard, and it was the unguarded one.
101
+ const VOCAB_EXTS = new Set(['.js', '.json', '.html', '.css', '.md']);
71
102
  const VOCAB_BANNED = [
72
103
  { re: /\brole/i, label: 'role', hint: '"role" is reserved for the crafts (Artist / Ideator / Builder). On this surface the noun is RANK.' },
73
104
  { re: /governance/i, label: 'governance', hint: 'the pre-ADR-0174 name of this surface. It is the GOVERNMENT now.' },
@@ -202,6 +233,9 @@ function checkVocabularyWall() {
202
233
  // too, or the code could not point at the migration that created its own
203
234
  // tables. Same principle, applied to the line rather than the file.
204
235
  if (VOCAB_HISTORICAL_REF.test(scanLine)) continue;
236
+ // A line that CITES the rule is meta-commentary about the ban, not an
237
+ // instance of it. Costs no allowlist entry and travels with the sentence.
238
+ if (VOCAB_CITATION.test(scanLine)) continue;
205
239
  const excused = VOCAB_ALLOW.some((a) => a.file === rel && scanLine.toLowerCase().includes(a.match));
206
240
  if (excused) continue;
207
241
  violations.push(`${rel}:${i + 1}: "${b.label}" — ${b.hint} → ${line.trim().slice(0, 90)}`);
@@ -214,8 +248,52 @@ function checkVocabularyWall() {
214
248
  hardFail: violations.length > 0,
215
249
  violations,
216
250
  warnings: [],
217
- note: `${scanned} government-surface file(s) scanned for "role"/"governance"; `
218
- + `${VOCAB_ALLOW.length} documented allowlist entr(y|ies). Historical migrations + docs/adr are excluded by construction, not suppressed.`,
251
+ note: `${scanned} government-surface file(s) scanned for "role"/"governance" (${[...VOCAB_EXTS].sort().join(' ')}); `
252
+ + `${VOCAB_ALLOW.length} documented allowlist entr(y|ies), plus the citation rule. `
253
+ + 'Historical migrations + docs/adr are excluded by construction, not suppressed.',
254
+ };
255
+ }
256
+
257
+ // ── Check 19c — a rename sweep that ate its own arrow (task 1003120) ─────────
258
+ //
259
+ // THE HOLE CHECK 19 CANNOT SEE. Checks 19 and 19b grep for a BANNED WORD, so
260
+ // they are structurally blind to the damage that actually happened in task
261
+ // 1003118: a find-and-replace ran over a doc that was DESCRIBING the rename, and
262
+ // swept BOTH sides of every rename pair. `governance` → `government` became
263
+ // `government` → `government`. Not one banned word survives that edit, so the
264
+ // wall passed — while the nested CLAUDE.md now taught the next agent that the
265
+ // old name and the new name were the same word, and asserted the inverse of
266
+ // ADR 0174 through a criterion review.
267
+ //
268
+ // An arrow between two identical names states nothing. It is only ever the
269
+ // fossil of a sweep, so flagging the SHAPE catches the defect on the day it
270
+ // lands, with no vocabulary to enumerate. Measured on the whole tree: the one
271
+ // hit outside this surface is ADR 0194's `open` → `open`, a real mapping
272
+ // sentence — which is why the check is scoped to the government surface (the
273
+ // files the rename actually swept) rather than run repo-wide.
274
+ const RENAME_IDENTITY_RE = /`([\w./-]+)` *(?:→|->) *`\1`/;
275
+
276
+ function checkGovernmentRenameIdentity() {
277
+ const violations = [];
278
+ let scanned = 0;
279
+ for (const { abs, rel } of vocabFilesToScan()) {
280
+ scanned += 1;
281
+ fs.readFileSync(abs, 'utf8').split('\n').forEach((line, i) => {
282
+ const m = RENAME_IDENTITY_RE.exec(line);
283
+ if (!m) return;
284
+ violations.push(`${rel}:${i + 1}: \`${m[1]}\` → \`${m[1]}\` — a rename arrow between two IDENTICAL names states nothing. `
285
+ + 'A sweep ate one side of the pair (task 1003118); restore the name it renamed FROM.'
286
+ + ` → ${line.trim().slice(0, 90)}`);
287
+ });
288
+ }
289
+ return {
290
+ name: 'a rename arrow names two different things (task 1003120)',
291
+ ok: violations.length === 0,
292
+ hardFail: violations.length > 0,
293
+ violations,
294
+ warnings: [],
295
+ note: `${scanned} government-surface file(s) scanned for \`X\` → \`X\` pairs; no allowlist — `
296
+ + 'an arrow between identical names has no legitimate form to excuse.',
219
297
  };
220
298
  }
221
299
 
@@ -225,12 +303,15 @@ module.exports = {
225
303
  GOAL_VOCAB_LINE_SCOPED,
226
304
  GOAL_VOCAB_SURFACE,
227
305
  LIFECYCLE_DB_FILES,
306
+ RENAME_IDENTITY_RE,
228
307
  VOCAB_ALLOW,
229
308
  VOCAB_BANNED,
309
+ VOCAB_CITATION,
230
310
  VOCAB_EXTS,
231
311
  VOCAB_HISTORICAL_REF,
232
312
  VOCAB_SURFACE,
233
313
  checkGoalMembershipVocabulary,
314
+ checkGovernmentRenameIdentity,
234
315
  checkVocabularyWall,
235
316
  vocabFilesToScan,
236
317
  };
@@ -36,7 +36,7 @@ const { resolveCoreRoot, resolveInstanceRoot } = require('../../src/instance-con
36
36
  const { CORE_ROOT, INSTANCE_ROOT, MODULE_ROOTS, REPO_ROOT, rel, walkJs, walkJsAllRoots } = require('./fitness-lib.js');
37
37
  const { IDENTITY_SCAN_ALLOWED, IDENTITY_SCAN_EXCLUDE, IDENTITY_SCAN_EXTRA_FILES, IDENTITY_SCAN_EXTS, IDENTITY_SCAN_ROOTS, IDENTITY_SCAN_SKIP_DIRS, PROMPT_IDENTITY_LITERALS, checkDesignContract, checkKernelPromptsNoHardcodedIdentity, checkNeutralConfigsNoHostIdentity, collectIdentityFiles, scanPromptIdentity, stripCommentsForIdentity } = require('./fitness-checks-identity.js');
38
38
  const { WRITE_VALIDATION_BASELINE, WRITE_VALIDATION_EXEMPT, checkWriteRoutesValidated, scanUnvalidatedWriteRoutes } = require('./fitness-checks-write-validation.js');
39
- const { GOAL_VOCAB_CITATION, GOAL_VOCAB_LINE_SCOPED, GOAL_VOCAB_SURFACE, LIFECYCLE_DB_FILES, VOCAB_ALLOW, VOCAB_BANNED, VOCAB_EXTS, VOCAB_HISTORICAL_REF, VOCAB_SURFACE, checkGoalMembershipVocabulary, checkVocabularyWall, vocabFilesToScan } = require('./fitness-checks-vocabulary.js');
39
+ const { GOAL_VOCAB_CITATION, GOAL_VOCAB_LINE_SCOPED, GOAL_VOCAB_SURFACE, LIFECYCLE_DB_FILES, RENAME_IDENTITY_RE, VOCAB_ALLOW, VOCAB_BANNED, VOCAB_CITATION, VOCAB_EXTS, VOCAB_HISTORICAL_REF, VOCAB_SURFACE, checkGoalMembershipVocabulary, checkGovernmentRenameIdentity, checkVocabularyWall, vocabFilesToScan } = require('./fitness-checks-vocabulary.js');
40
40
  // ============================================================================
41
41
  // Check 1 — core ↔ host/game import boundary (ADR 0062 §1 cut-line)
42
42
  // ============================================================================
@@ -1374,6 +1374,7 @@ const CHECKS = [
1374
1374
  checkErrorEnvelope, // Check 17 — BV1 R03 / task 1990: no route hand-rolls a flat error (ADR 0116)
1375
1375
  checkVocabularyWall, // Check 19 — ADR 0174 / goal 1000068 R07: "role" is a craft, never a rank
1376
1376
  checkGoalMembershipVocabulary, // Check 19b — ADR 0177 / task 1003013: the FOURTH "role" (goal membership)
1377
+ checkGovernmentRenameIdentity, // Check 19c — task 1003120: `X` → `X`, the rename arrow a sweep ate both sides of (the shape checks 19/19b are blind to)
1377
1378
  checkWriteRoutesValidated, // Check 18 — BV1 R12 / task 1999: every body-reading write route validates (ADR 0118)
1378
1379
  require('./fitness-ratchets.js').checkQualityRatchets, // Check 20 — task 1003129: quality budgets only tighten (mechanics + knip CI step live in fitness-ratchets.js)
1379
1380
  require('./doc-cli-guard.js').checkDocNamesRealCli, // Check 22 — task 1003165 / G01: a doc that names a CLI invocation must match the real CLI
@@ -1434,8 +1435,8 @@ function main() {
1434
1435
  if (require.main === module) main();
1435
1436
 
1436
1437
  module.exports = {
1437
- checkVocabularyWall, VOCAB_ALLOW,
1438
- checkGoalMembershipVocabulary, GOAL_VOCAB_SURFACE,
1438
+ checkVocabularyWall, VOCAB_ALLOW, VOCAB_CITATION, VOCAB_EXTS, vocabFilesToScan,
1439
+ checkGoalMembershipVocabulary, GOAL_VOCAB_SURFACE, checkGovernmentRenameIdentity, RENAME_IDENTITY_RE, // 19c — task 1003120
1439
1440
  runAll,
1440
1441
  resolvesToHostGame,
1441
1442
  checkCoreHostBoundary,
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.651'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.653'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
package/tests/boxes.mjs CHANGED
@@ -170,7 +170,7 @@ t('selectIdleBoxes: guards bad inputs', () => {
170
170
  assert.deepEqual(boxes.selectIdleBoxes([], { idleMinutes: 30, nowMs: NaN }), []);
171
171
  });
172
172
 
173
- t('selectIdleBoxes: skips boxes where claude_active=true regardless of age', () => {
173
+ t('selectIdleBoxes: with NO cap configured, claude_active=true skips regardless of age (pre-1003507 path)', () => {
174
174
  const rows = [
175
175
  // Old + claude running → kept alive.
176
176
  { state: 'active', github_login: 'clauding', last_activity_at: new Date(NOW - 60 * 60000).toISOString(), claude_active: true },
@@ -183,6 +183,114 @@ t('selectIdleBoxes: skips boxes where claude_active=true regardless of age', ()
183
183
  assert.deepEqual(idle.map((r) => r.github_login).sort(), ['idle', 'legacy']);
184
184
  });
185
185
 
186
+ // --- task 1003507: the unattended cap ---------------------------------------
187
+ //
188
+ // The bug had TWO legs and either one alone kept an abandoned box alive:
189
+ // (1) claude_active is a latch only a ping can clear, and the sweep honoured it
190
+ // with no time bound at all -> unreapable at every threshold, forever;
191
+ // (2) load > 0.2 kept last_activity_at fresh, so the idle clock never expired.
192
+ // The reported box had BOTH, which is why these tests model a FRESH heartbeat:
193
+ // a fix that only bounds (1) leaves the box alive through (2) and looks green
194
+ // against a stale-heartbeat fixture.
195
+ const CAP = 12;
196
+ const HOURS = (n) => n * 60 * 60000;
197
+ const at = (ms) => new Date(ms).toISOString();
198
+
199
+ // The reported incident, to scale: droplet up 5 days, heartbeat 5 min old, a
200
+ // `claude` process running, nobody attached for 21h.
201
+ const abandonedBox = () => ({
202
+ state: 'active', github_login: 'abandoned', claude_active: true,
203
+ claude_active_since: at(NOW - HOURS(21)),
204
+ last_activity_at: at(NOW - 5 * 60000),
205
+ last_attached_at: at(NOW - HOURS(21)),
206
+ active_since: at(NOW - 5 * 24 * 60 * 60000),
207
+ });
208
+ const swept = (rows, over = {}) => boxes.selectIdleBoxes(rows, {
209
+ idleMinutes: 30, nowMs: NOW, unattendedMaxHours: CAP, ...over,
210
+ }).map((r) => r.github_login);
211
+
212
+ t('selectIdleBoxes: an unattended box past the cap is reaped even with a FRESH heartbeat', () => {
213
+ // The regression that matters: last_activity_at is 5 min old, so the ordinary
214
+ // idle rule can never fire. Only the attachment clock catches this box.
215
+ assert.deepEqual(swept([abandonedBox()]), ['abandoned']);
216
+ });
217
+
218
+ t('selectIdleBoxes: a box with a human attached is spared regardless of the cap', () => {
219
+ assert.deepEqual(swept([{ ...abandonedBox(), last_attached_at: at(NOW - 10 * 60000) }]), []);
220
+ assert.deepEqual(swept([{ ...abandonedBox(), last_attached_at: at(NOW - HOURS(11)) }]), []);
221
+ });
222
+
223
+ t('selectIdleBoxes: the cap boundary', () => {
224
+ assert.deepEqual(swept([{ ...abandonedBox(), last_attached_at: at(NOW - HOURS(12) + 60000) }]), []);
225
+ assert.deepEqual(swept([{ ...abandonedBox(), last_attached_at: at(NOW - HOURS(12) - 60000) }]), ['abandoned']);
226
+ });
227
+
228
+ t('selectIdleBoxes: a latched claude_active box that stopped heartbeating is reaped', () => {
229
+ // Leg (1) on its own: silence cannot clear the flag, so before this fix the
230
+ // row survived every sweep at every threshold.
231
+ assert.deepEqual(swept([{
232
+ state: 'active', github_login: 'zombie', claude_active: true,
233
+ claude_active_since: at(NOW - 365 * 24 * 60 * 60000),
234
+ last_activity_at: at(NOW - 365 * 24 * 60 * 60000),
235
+ last_attached_at: null,
236
+ }]), ['zombie']);
237
+ });
238
+
239
+ t('selectIdleBoxes: a box with NO attachment data keeps the old behaviour', () => {
240
+ // A box still running the pre-1003507 heartbeat never reports `attached`, so
241
+ // last_attached_at stays NULL. NULL must read as "no data", never as
242
+ // "unattended" — otherwise this fix parks every un-upgraded box mid-work.
243
+ // (core_238 deliberately does not backfill the column for the same reason.)
244
+ assert.deepEqual(swept([{
245
+ state: 'active', github_login: 'legacy', claude_active: true,
246
+ claude_active_since: at(NOW - HOURS(2)),
247
+ last_activity_at: at(NOW - 5 * 60000),
248
+ last_attached_at: null,
249
+ }]), []);
250
+ // ...but it is still reapable the ordinary way once it goes quiet.
251
+ assert.deepEqual(swept([{
252
+ state: 'active', github_login: 'legacy2', claude_active: false,
253
+ last_activity_at: at(NOW - HOURS(1)), last_attached_at: null,
254
+ }]), ['legacy2']);
255
+ });
256
+
257
+ t('selectIdleBoxes: omitting the cap preserves the unbounded veto exactly', () => {
258
+ assert.deepEqual(swept([abandonedBox()], { unattendedMaxHours: undefined }), []);
259
+ assert.deepEqual(swept([abandonedBox()], { unattendedMaxHours: 0 }), []);
260
+ });
261
+
262
+ t('attendedRecently: NULL is unknown (true), not unattended', () => {
263
+ assert.equal(boxes.attendedRecently({ last_attached_at: null }, { nowMs: NOW, unattendedMaxHours: CAP }), true);
264
+ assert.equal(boxes.attendedRecently({ last_attached_at: at(NOW - HOURS(1)) }, { nowMs: NOW, unattendedMaxHours: CAP }), true);
265
+ assert.equal(boxes.attendedRecently({ last_attached_at: at(NOW - HOURS(20)) }, { nowMs: NOW, unattendedMaxHours: CAP }), false);
266
+ // No cap configured -> nothing is ever "unattended".
267
+ assert.equal(boxes.attendedRecently({ last_attached_at: at(NOW - HOURS(999)) }, { nowMs: NOW }), true);
268
+ });
269
+
270
+ t('claudeVetoHolds: a true flag with no recorded start is refused', () => {
271
+ // An unknown latch age is exactly the forever-latch being fixed, so it must
272
+ // NOT be honoured — core_238 stamps every box that is claude_active on deploy.
273
+ assert.equal(boxes.claudeVetoHolds(
274
+ { claude_active: true, claude_active_since: null }, { nowMs: NOW, unattendedMaxHours: CAP }
275
+ ), false);
276
+ assert.equal(boxes.claudeVetoHolds(
277
+ { claude_active: true, claude_active_since: at(NOW - HOURS(2)) }, { nowMs: NOW, unattendedMaxHours: CAP }
278
+ ), true);
279
+ assert.equal(boxes.claudeVetoHolds(
280
+ { claude_active: true, claude_active_since: at(NOW - HOURS(20)) }, { nowMs: NOW, unattendedMaxHours: CAP }
281
+ ), false);
282
+ assert.equal(boxes.claudeVetoHolds({ claude_active: false }, { nowMs: NOW, unattendedMaxHours: CAP }), false);
283
+ });
284
+
285
+ t('idleReason: names the clock that actually selected the box', () => {
286
+ const opts = { nowMs: NOW, idleMinutes: 30, unattendedMaxHours: CAP };
287
+ assert.match(boxes.idleReason(abandonedBox(), opts), /unattended 21h/);
288
+ assert.match(boxes.idleReason({
289
+ state: 'active', claude_active: false,
290
+ last_activity_at: at(NOW - HOURS(1)), last_attached_at: at(NOW - 60000),
291
+ }, opts), /idle 60m/);
292
+ });
293
+
186
294
  t('selectDormantBoxes: picks parked boxes past the dormant threshold', () => {
187
295
  const rows = [
188
296
  { state: 'parked', github_login: 'gone', parked_at: new Date(NOW - 20 * DAY).toISOString() },
@@ -1264,6 +1372,7 @@ function sweepHarness({ idleRows, parkThrows = null }) {
1264
1372
  async listBoxes() { return idleRows; },
1265
1373
  selectIdleBoxes: boxes.selectIdleBoxes,
1266
1374
  effectiveActivityMs: boxes.effectiveActivityMs,
1375
+ idleReason: boxes.idleReason,
1267
1376
  async setBoxState(_db, _bid, state, patch) { states.push({ state, patch }); return {}; },
1268
1377
  async recordComputeCost() { return 0; },
1269
1378
  async recordEvent(_db, e) { events.push(e); },
@@ -1321,17 +1430,65 @@ await ta('cmdSweepIdle: dry-run reports "would park" and touches nothing', async
1321
1430
  assert.equal(h.states.length, 0, 'no state written in dry-run');
1322
1431
  });
1323
1432
 
1324
- await ta('cmdSweepIdle: a box running Claude is never parked mid-session', async () => {
1433
+ await ta('cmdSweepIdle: a box mid-session is never parked', async () => {
1434
+ // Genuinely in use: Claude started 20 min ago and somebody is attached.
1325
1435
  const busy = {
1326
1436
  id: 4, builder_id: 4, state: 'active', droplet_id: '1', github_login: 'busy',
1327
1437
  last_activity_at: new Date(Date.now() - 99 * 3600_000).toISOString(), claude_active: true,
1438
+ claude_active_since: new Date(Date.now() - 20 * 60_000).toISOString(),
1439
+ last_attached_at: new Date(Date.now() - 20 * 60_000).toISOString(),
1328
1440
  };
1329
1441
  const h = sweepHarness({ idleRows: [busy] });
1330
1442
  const res = await boxCli.cmdSweepIdle({}, h.fakeBoxes, { apply: true });
1331
- assert.equal(res.parked, 0, 'claude_active holds the box open regardless of age');
1443
+ assert.equal(res.parked, 0, 'a live session still holds the box open');
1332
1444
  assert.equal(h.states.length, 0);
1333
1445
  });
1334
1446
 
1447
+ await ta('cmdSweepIdle: task 1003507 — a box claiming claude_active for 99h with nobody attached IS parked', async () => {
1448
+ // The reported incident, end to end through the command. Before this task the
1449
+ // sweep skipped the row on claude_active alone and reported 0 idle; it now
1450
+ // parks it (snapshot kept), and the log names the clock that selected it.
1451
+ const abandoned = {
1452
+ id: 5, builder_id: 5, state: 'active', droplet_id: '591743808', github_login: 'abandoned',
1453
+ last_activity_at: new Date(Date.now() - 5 * 60_000).toISOString(),
1454
+ claude_active: true,
1455
+ claude_active_since: new Date(Date.now() - 99 * 3600_000).toISOString(),
1456
+ last_attached_at: new Date(Date.now() - 99 * 3600_000).toISOString(),
1457
+ };
1458
+ const h = sweepHarness({ idleRows: [abandoned] });
1459
+ const lines = [];
1460
+ const origLog = console.log;
1461
+ console.log = (...a) => lines.push(a.join(' '));
1462
+ let res;
1463
+ try {
1464
+ res = await boxCli.cmdSweepIdle({}, h.fakeBoxes, { apply: false });
1465
+ } finally { console.log = origLog; }
1466
+ assert.equal(res.parked, 0, 'dry-run writes nothing');
1467
+ assert.ok(lines.some((l) => /would park .*abandoned/.test(l)), 'the abandoned box is selected');
1468
+ assert.ok(lines.some((l) => /unattended 99h/.test(l)), 'the log names the unattended clock, not the idle one');
1469
+ });
1470
+
1471
+ await ta('loadDeps: task 1003507 — resolving deps does not throw ReferenceError', async () => {
1472
+ // _deps was UNDECLARED, so loadDeps() threw on its first call and every
1473
+ // command reaching makeDo()/makeCf() died with it — including
1474
+ // `sweep-idle --apply`, which builds its DO client before the park loop, so
1475
+ // the sweep could never park anything. Reached here through the same
1476
+ // apply:true path that hid the bug: the assertion is that we get PAST dep
1477
+ // resolution, whatever the fake DO client then does.
1478
+ const row = {
1479
+ id: 6, builder_id: 6, state: 'active', droplet_id: '1', github_login: 'reap',
1480
+ last_activity_at: new Date(Date.now() - 99 * 3600_000).toISOString(), claude_active: false,
1481
+ };
1482
+ const h = sweepHarness({ idleRows: [row] });
1483
+ const origLog = console.log;
1484
+ console.log = () => {};
1485
+ let err = null;
1486
+ try {
1487
+ await boxCli.cmdSweepIdle({}, h.fakeBoxes, { apply: true });
1488
+ } catch (e) { err = e; } finally { console.log = origLog; }
1489
+ assert.ok(!(err && /_deps is not defined/.test(err.message)), `loadDeps threw: ${err && err.message}`);
1490
+ });
1491
+
1335
1492
  console.log('\ntask 1002727 — the dormant sweep warns before it takes:');
1336
1493
 
1337
1494
  const DORMANT_DAY = 86_400_000;
@@ -46,18 +46,113 @@ test('a planted "governance" on the government surface REDS the build', () => {
46
46
  // Plant a line into a real government-surface file, run the check, restore.
47
47
  // Uses the real file rather than a fixture so the test exercises the same path
48
48
  // resolution the CI gate does.
49
- function withPlantedLine(line) {
49
+ function withPlantedLine(line, relParts = ['modules', 'government', 'resolver.js'], check = 'checkVocabularyWall') {
50
50
  const fs = require('node:fs');
51
- const target = path.join(ROOT, 'modules', 'government', 'resolver.js');
51
+ const target = path.join(ROOT, ...relParts);
52
52
  const original = fs.readFileSync(target, 'utf8');
53
53
  try {
54
54
  fs.writeFileSync(target, `${original}\n${line}\n`);
55
- return fitness.checkVocabularyWall().violations;
55
+ return fitness[check]().violations;
56
56
  } finally {
57
57
  fs.writeFileSync(target, original);
58
58
  }
59
59
  }
60
60
 
61
+ const MD = ['modules', 'government', 'CLAUDE.md'];
62
+
63
+ // ---------- markdown is inside the wall (task 1003120) ----------
64
+ //
65
+ // The gap this closes: a nested CLAUDE.md is INSTRUCTION — it loads on demand
66
+ // for every agent working in the module, and the agent that reads the wrong
67
+ // vocabulary there writes it into the code next. It sat outside the very rule
68
+ // it describes, which is how it came to teach the INVERSE of ADR 0174 and
69
+ // survive a criterion review (task 1003118).
70
+
71
+ test('the wall scans markdown — the doc that TEACHES the rule is inside it', () => {
72
+ assert.ok(fitness.VOCAB_EXTS.has('.md'),
73
+ 'markdown must be scanned: the highest-leverage vocabulary surface is the nested '
74
+ + 'CLAUDE.md an agent reads before writing code, not the code itself');
75
+ const scanned = fitness.vocabFilesToScan().map((f) => f.rel);
76
+ assert.ok(scanned.includes('modules/government/CLAUDE.md'),
77
+ `the module's own nested doc must be in scope; scanned: ${scanned.join(', ')}`);
78
+ });
79
+
80
+ test('a planted "role" in a government MARKDOWN file REDS the build', () => {
81
+ const planted = withPlantedLine('A custom role may hold this permission.', MD);
82
+ assert.ok(planted.some((v) => /CLAUDE\.md.*"role"/.test(v)),
83
+ 'a doc that teaches the banned vocabulary must be caught — otherwise adding '
84
+ + '.md to VOCAB_EXTS bought nothing');
85
+ });
86
+
87
+ // ---------- the citation rule is an exemption, not a hole ----------
88
+ //
89
+ // Preferred over three more VOCAB_ALLOW entries (which would have taken the cap
90
+ // of 5 to its limit) because the exemption belongs to the CITATION, not to
91
+ // whichever file happens to hold it. So it has to be narrow, and both halves of
92
+ // that are load-bearing: it lets the rule be NAMED, and nothing else.
93
+
94
+ test('a line that CITES the rule may say the banned word', () => {
95
+ const planted = withPlantedLine('On this surface "role" is banned — see ADR 0174.', MD);
96
+ assert.ok(!planted.some((v) => /CLAUDE\.md/.test(v)),
97
+ 'meta-commentary about the ban has to be able to name it, or the doc could '
98
+ + 'not state the rule it enforces');
99
+ });
100
+
101
+ test('the citation rule does NOT excuse the rest of the line', () => {
102
+ // Same file, same banned word, no citation: still caught. Without this the
103
+ // exemption could be silently widened to "this file is fine" and stay green.
104
+ const planted = withPlantedLine('A custom role may hold this permission.', MD);
105
+ assert.ok(planted.some((v) => /CLAUDE\.md/.test(v)),
106
+ 'a line with no citation must still red — an exemption that covers the file '
107
+ + 'rather than the sentence is an allowlist wearing a regex');
108
+ });
109
+
110
+ test('the allowlist did not grow to absorb the markdown surface', () => {
111
+ // The whole point of the citation rule: bringing .md into scope surfaced three
112
+ // legitimate lines, and NONE of them cost an allowlist slot.
113
+ assert.ok(fitness.VOCAB_ALLOW.length <= 4,
114
+ `the allowlist grew to ${fitness.VOCAB_ALLOW.length} when .md came into scope. `
115
+ + 'The citation rule exists so it would not have to.');
116
+ });
117
+
118
+ // ---------- check 19c: the rename arrow a sweep ate both sides of ----------
119
+ //
120
+ // The defect checks 19 and 19b are structurally blind to. Task 1003118 ran a
121
+ // find-and-replace over a doc DESCRIBING the rename and swept both sides of
122
+ // every pair: `governance` → `government` became `government` → `government`.
123
+ // No banned word survives that edit, so the wall passed while the doc taught
124
+ // the next agent that the old and new names were the same word.
125
+
126
+ test('the government surface carries no identity rename arrows today', () => {
127
+ const r = fitness.checkGovernmentRenameIdentity();
128
+ assert.equal(r.ok, true,
129
+ `an \`X\` → \`X\` arrow is on the surface:\n${r.violations.join('\n')}`);
130
+ assert.ok(r.note.includes('file(s) scanned'), 'the check must report what it scanned');
131
+ });
132
+
133
+ test('a planted `X` → `X` pair REDS the build', () => {
134
+ const planted = withPlantedLine('The sweep renamed `ranks.js` → `ranks.js` here.', MD, 'checkGovernmentRenameIdentity');
135
+ assert.ok(planted.some((v) => /ranks\.js/.test(v)),
136
+ 'an arrow between two identical names states nothing and is only ever the '
137
+ + 'fossil of a sweep — it must be caught');
138
+ });
139
+
140
+ test('a REAL rename arrow is left alone', () => {
141
+ // The check must not punish the sentence it exists to protect: a doc
142
+ // describing the rename correctly names two different things.
143
+ const planted = withPlantedLine('The sweep renamed `governance_001` → `government_001` here.', MD, 'checkGovernmentRenameIdentity');
144
+ assert.equal(planted.length, 0,
145
+ `a legitimate A → B rename must pass, or the rule teaches docs not to describe `
146
+ + `renames at all:\n${planted.join('\n')}`);
147
+ });
148
+
149
+ test('the rename-identity check has no allowlist to erode', () => {
150
+ const r = fitness.checkGovernmentRenameIdentity();
151
+ assert.ok(/no allowlist/.test(r.note),
152
+ 'an arrow between identical names has no legitimate form, so there is nothing '
153
+ + 'to excuse — and no slot for a future exemption to grow into');
154
+ });
155
+
61
156
  // ---------- the allowlist is small, reasoned, and real ----------
62
157
 
63
158
  test('every allowlist entry carries a written reason', () => {