@bongos/core 1.19.484 → 1.19.486

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.
Files changed (48) hide show
  1. package/.bongos-core.json +102 -52
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +7 -0
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0232-rot-is-derived-not-swept-and-water-is-the-only-new-verb.md +155 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +147 -3
  10. package/docs/api-reference.md +7 -4
  11. package/docs/copy-inventory.md +169 -178
  12. package/docs/copy-registry.json +295 -405
  13. package/docs/file-map.md +1 -1
  14. package/docs/module-api-changelog.md +4 -0
  15. package/migrations/core_231_project_settings_and_attention_water.sql +163 -0
  16. package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
  17. package/modules/hall-ui/public/goal-inbox.js +119 -1
  18. package/modules/hall-ui/public/not-ready.html +14 -10
  19. package/modules/hall-ui/public/not-ready.states.json +14 -0
  20. package/modules/hall-ui/public/pair.css +17 -67
  21. package/modules/hall-ui/public/pair.html +8 -1
  22. package/modules/hall-ui/public/pair.states.json +14 -0
  23. package/modules/hall-ui/public/panel.css +333 -0
  24. package/modules/hall-ui/public/profile.css +85 -0
  25. package/modules/hall-ui/public/profile.html +12 -5
  26. package/modules/hall-ui/public/profile.states.json +15 -0
  27. package/modules/hall-ui/public/settings.css +119 -336
  28. package/modules/hall-ui/public/settings.html +28 -19
  29. package/modules/hall-ui/public/settings.js +44 -35
  30. package/modules/hall-ui/public/settings.states.json +16 -0
  31. package/modules/hall-ui/public/style.css +5 -2
  32. package/modules/lifecycle/module.json +2 -1
  33. package/modules/lifecycle/rot.js +210 -0
  34. package/modules/lifecycle/routes/rot.js +160 -0
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/scripts/gds/fitness-checks-vocabulary.js +0 -1
  38. package/scripts/gds/fitness.js +9 -0
  39. package/src/bongos/project-settings.js +193 -0
  40. package/src/module-api.js +29 -1
  41. package/tests/hall_settings_world.mjs +274 -0
  42. package/tests/join_application_note.mjs +7 -15
  43. package/tests/module-scope-map.mjs +1 -1
  44. package/tests/module_api.mjs +1 -0
  45. package/tests/rot_inbox.mjs +532 -0
  46. package/modules/hall-ui/public/my-projects.css +0 -62
  47. package/modules/hall-ui/public/my-projects.html +0 -73
  48. package/modules/hall-ui/public/my-projects.js +0 -153
package/docs/file-map.md CHANGED
@@ -130,7 +130,7 @@
130
130
  │ ├── sessions/ ← CORE-DOMAIN carve (BV1.R82, default-on): the session_records corpus + data plane (upload, cross-builder search/deep-dive), the BFG read-the-room scorer, session-pulse/stale-claim helpers. Registers no port — reached via HTTP POST to its own /sessions route; resolves the economy `reward` port for the cost-plus token reward. CLAUDE.md, module.json, routes/sessions.js, bfg.js, pulse.js.
131
131
  │ ├── autonomy/ ← CORE-DOMAIN carve (BV1.R83, default-on): the MAS autonomy surfaces — the $0 deterministic autonomy gate (System 3, inert until armed), the conductor/architect CLIs, and the in-process routine timers (incl. the weekly peer-vote-tally poller). Registers no port; resolves the lifecycle `tallyPeerVotes` doorway. CLAUDE.md, module.json, routes/autonomy.js, autonomy.js.
132
132
  │ ├── status-ui/ ← CORE-DOMAIN WEB-SURFACE carve (BV1.R84, default-on): the public status dashboard at status.<apex>, moved from public-status/. NO routes/db/port — declares contributes.webSurfaces:[{host:"status.",dir:"public"}], served by the loader web-surface seam (moduleWebSurfaces → serve-internal). CLAUDE.md, module.json, public/{index.html,status.js,style.css,cursors/,snapshots/}.
133
- │ ├── hall-ui/ ← CORE-DOMAIN WEB-SURFACE carve (BV1.R85, default-on): the authenticated builders' hall at builders.<apex>, moved from public-builders/ (~13k LOC, the largest). NO routes/db/port — declares contributes.webSurfaces:[{host:"builders.",dir:"public"}] for discovery, but UNLIKE status-ui keeps its DEDICATED serving block in serve-internal (the /builders shim + canonical redirects + citizen/Archon page gates + asset-stamping + hall-widget injection), partitioned out of the generic web-surface loop and served from BUILDERS_DIR. CLAUDE.md, module.json, public/{index.html,builders.js,style.css,work,watch,harbor,gate,pair,settings,ranks,sessions,primer,diagrams,atlas,...}.{html,js},cursors/,chime.wav. Three ARCHETYPE sheets sit beside the per-page ones: `record.css` (the task + idea records, task 1003305), `room.css` (the READING ROOMS — the primer + the diagrams index, task 1003306: the reading measure and rhythm, the table of contents, the progress hairline and the framed figure diagram-viewer.js upgrades on both pages) and `oversight.css` (the OVERSIGHT family — government, watch, harbor, gate, task 1003469: the row, the figure band inside a panel, the group head, the inline form, the grant toggle and the shared confirm modal; named oversight because the vocabulary wall bans "governance" on the government surface). A page sheet then holds only what one page has and the others do not — `primer.css` the acknowledge gate, `diagrams.css` the legend strip, `gate.css` the hold reasons and file chips, `watch.css` the severities, the report reader and the grade trend, `people.css` the directory's identity cell (task 1003470 made the legacy door `/scouting` ship the same body and script as `/people`; task 1003440 then deleted the door — `scouting.html` and `scouting.states.json` are gone and the URL 301s to `/people`).
133
+ │ ├── hall-ui/ ← CORE-DOMAIN WEB-SURFACE carve (BV1.R85, default-on): the authenticated builders' hall at builders.<apex>, moved from public-builders/ (~13k LOC, the largest). NO routes/db/port — declares contributes.webSurfaces:[{host:"builders.",dir:"public"}] for discovery, but UNLIKE status-ui keeps its DEDICATED serving block in serve-internal (the /builders shim + canonical redirects + citizen/Archon page gates + asset-stamping + hall-widget injection), partitioned out of the generic web-surface loop and served from BUILDERS_DIR. CLAUDE.md, module.json, public/{index.html,builders.js,style.css,work,watch,harbor,gate,pair,settings,ranks,sessions,primer,diagrams,atlas,...}.{html,js},cursors/,chime.wav. Four ARCHETYPE sheets sit beside the per-page ones: `record.css` (the task + idea records, task 1003305), `room.css` (the READING ROOMS — the primer + the diagrams index, task 1003306: the reading measure and rhythm, the table of contents, the progress hairline and the framed figure diagram-viewer.js upgrades on both pages), `oversight.css` (the OVERSIGHT family — government, watch, harbor, gate, task 1003469: the row, the figure band inside a panel, the group head, the inline form, the grant toggle and the shared confirm modal; named oversight because the vocabulary wall bans "governance" on the government surface) and `panel.css` (the PANEL family — settings, profile, pair, not-ready, task 1003308: the row, the switch, the field with its label above, the chip control, the four voices and the sign-in gate; the hall's my-projects page was DELETED in the same task — "my projects" is the hub's user-level page, and the hall keeps no link or redirect). A page sheet then holds only what one page has and the others do not — `primer.css` the acknowledge gate, `diagrams.css` the legend strip, `gate.css` the hold reasons and file chips, `watch.css` the severities, the report reader and the grade trend, `settings.css` the rail layout, the group head, the token ladder and the voices grid, `profile.css` the identity band and the shelf, `pair.css` the device row, `people.css` the directory's identity cell (task 1003470 made the legacy door `/scouting` ship the same body and script as `/people`; task 1003440 then deleted the door — `scouting.html` and `scouting.states.json` are gone and the URL 301s to `/people`).
134
134
  │ ├── lifecycle/ ← CORE-DOMAIN carve (BV1.R86, default-on): the build state machine — tasks · claims · versions · done-when · goals · dependencies · claim→ship→grade→credit · github-push + merge-lock + conflict-resolve + publish-reconciler. The LAST, most-coupled carve; registers the `lifecycle` kernel port (createTask, classifyTaskKind, tallyPeerVotes, work-tracking reads). CLAUDE.md, module.json, routes/{tasks,claims,versions,done-when,goals,dependencies,gate-approvals,analytics}.js, lifecycle.js, github-push.js, merge-lock.js, conflict-resolve.js, publish-reconciler.js, task-visuals.js + task-visual-db.js + routes/visuals.js (the optional ship-time visual, task 1003109 — the route file is NOT named task-*.js on purpose; see the module CLAUDE.md).
135
135
  │ ├── copy-desk/ ← CORE-DOMAIN module (task 1003113 / R02 of goal 1000074, default-on): the COPY half of the design contract — ADR 0081 gave design TOKENS a repo-owned source of truth, text had none. Owns the FLAG (anyone in any role marks a string or a whole surface as needing an artist's attention, with a reason required in BOTH the route and a CHECK constraint) and the QUEUE an artist reads it from. READS R01's committed registry (docs/copy-registry.json, generated by scripts/gds/copy-inventory.js) and never writes it. NO LIVE CMS is the hard non-goal: no column here could hold replacement text, and copy_no_cms.mjs pins that. Key is `copy-desk`, not `copy` — the branding contract already owns a `copy` block, so the bare word reds the "core carries no module code" fitness check. CLAUDE.md, module.json, registry.js, flags.js, routes/copy-desk.js, migrations/copy_desk_001_flags.sql, tests/{copy_flags,copy_queue,copy_no_cms}.mjs. Its hall surface lives with hall-ui (public/copy-desk.{html,js,css}), per the one-key-per-web-surface precedent.
136
136
  │ ├── ui-design/ ← CORE-DOMAIN module (task 1003324 / ADR 0197, default-on): the design capability every builder on every instance gets — ships METHOD, never a world. Absorbs ADR 0081's layer: adapters/{claude-design,figma}/index.js (moved from src/ui/adapters/), scripts/{design-sync,figma-design-sync,validate-design}.js (moved from scripts/gds/, which keeps thin shims), docs/design-contract.md (moved from docs/). Declaration-only: contributes.skills design (the /design playbook, new) + design-sync + figma-design-sync; no routes/port/migrations, so disabling it touches no served surface. CLAUDE.md separates the platform FLOORS (the Fifteen Rule, the dark twins, no page :root, the 24px floor, AA in both modes, the kill switch) from the instance's WORLD (its packs + DESIGN.md). kit/ (task 1003320) is the look-before-you-ship kit: lib.js (the floors, the states/probes contracts, the action + expectation vocabularies, the WCAG maths, the stub spawner), serve.js (any surface through the doorway's real transforms + fixtures/), render.js (states × 1440/390/320 × dark/light, audited), probe.js (PASS/FAIL contracts), check-mock.js (platform rules + the instance's DESIGN.md taste bans, then the detector), tells.js (task 1003329 / ADR 0223: the anti-pattern detector as two tiers — floors FAIL, craft tells WARN, kit-ignore waivers per file; the rendered twins live in lib.js factsInPage), contrast.js + png.js; a page's <page>.states.json / <page>.probes.json sit BESIDE the surface (modules/public-landing/public/, modules/hall-ui/public/). Recipe: docs/recipes/ui-look-before-you-ship.md. skills/ (task 1003321 / ADR 0198) is the vendoring policy: policy.json (licence allowlist, verdicts that may land, sidecar fields, preamble markers), PREAMBLE.md (the platform preamble every vendored SKILL.md opens with), README.md (the roster — both origins scanned dangerous on 2026-08-28, landed as rebuild specs; nothing vendored — and the landing recipe); a landable copy goes under skills/vendor/<name>/ with PROVENANCE.md + LICENSE and is materialised into an instance's .claude/skills/ by claude-materialize.js step 1b. skills/<name>/ (task 1003325 / ADR 0220) are the in-house rebuilds from the specs, first-party and counted: design-taste-frontend (the default taste skill), design-taste-frontend-v1 (the earlier generation, kept distinct), high-end-visual-design (the polish pass), redesign-existing-projects (the in-place upgrade); each opens with the same preamble and carries a "Rebuilt from" section instead of a sidecar. impeccable/ (task 1003326 / ADR 0221) is the design-craft method as ONE skill with sub-commands: SKILL.md (the router, the menu, the craft floor, the bounded finish) + reference/<sub-command>.md (eighteen playbooks, one loaded per request); no hooks, no bypass agent, no telemetry, no live server. minimalist-ui/, industrial-brutalist-ui/, gpt-taste/, stitch-design-taste/ (task 1003327 / ADR 0230) are the four looks, composition grammars with no colours of their own: the palette is the instance's pack and the texture is a named entry in styles/; a substrate is an archetype that still ships both modes, gpt-taste's selection is derived from the route rather than rolled, and stitch-design-taste writes a DESIGN.md-shaped contract document whose thirteen are transcribed from the pack. image-to-code/, imagegen-frontend-web/, imagegen-frontend-mobile/, brandkit/ (task 1003328 / ADR 0222) are the image family under the hero-only rule: plates and hero objects only, the pair (JPEG dark plate + WebP alpha cutout) with a <file>.json sidecar beside each, through the instance's art pipeline key; the grove specimen's styles/grove/assets/ is the worked example. style/ (task 1003475 / ADR 0231) is the module's first FIRST-PARTY skill (no upstream, so a `## First party` section instead of `## Rebuilt from`): the /style session in which an owner authors their own look in four bounded steps, rendering the nearest library entries before asking, drafting inside the contract, retuning by measurement, and handing over the one-step adoption without ever writing config/branding.json. styles/ (task 1003323 / ADR 0219) is the style library: <name>/pack.json (the theme.ui overlay, exactly the fifteen) + DESIGN.md (the look's derived recipes, thirteen in lockstep with the pack) + mock.html (the specimen); chrome-world (pinned to the neutral pack), expedition, grove, blueprint (the first cool look, authored through /style at task 1003475); STYLE=<name> on the kit lays a look over the instance's pack through GDS_BRANDING_FILE; tests/ui_design_styles.mjs runs the world/cosmos/hall token suites once per alternate (UI_DESIGN_PACK). config/design-tokens.* + design-sources.json STAY in config/ as the host boundary; public-landing keeps its own key (first customer, not owner).
@@ -1405,5 +1405,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1405
1405
  landed since 1.19.482 with no explicit bump. run 33674657478. (task 1002620)
1406
1406
  1.19.484 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1407
1407
  landed since 1.19.483 with no explicit bump. run 33675143968. (task 1002620)
1408
+ 1.19.485 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1409
+ landed since 1.19.484 with no explicit bump. run 33681828446. (task 1002620)
1410
+ 1.19.486 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1411
+ landed since 1.19.485 with no explicit bump. run 33682949187. (task 1002620)
1408
1412
  ---------------------------------------------------------------------------
1409
1413
  ```
@@ -0,0 +1,163 @@
1
+ -- core_231_project_settings_and_attention_water.sql — the rot clock and the knob
2
+ -- that sets it (task 1003279, goal 1000072, criterion legibility-sort-and-staleness).
3
+ --
4
+ -- THE GAP. Goal 1000072's rotting half asks that "untouched-for-X triggers a
5
+ -- configured action rather than a silent decay". Two things were missing to make
6
+ -- that sentence true, and this migration adds exactly those two:
7
+ --
8
+ -- 1. X had nowhere to live. src/stale-timer.js (task 1003476, ADR 0226) owns the
9
+ -- CLAIM-silence timer — hours, env-configured, and the sweep that ACTS on it
10
+ -- releases someone's claim. The rot timer is a different measurement with a
11
+ -- different unit and a different consequence (days; it asks a human a
12
+ -- question), and task 1003280 requires it be "live, DB-backed" and warns
13
+ -- "do not fork a second store". So: one generic project_settings table, with
14
+ -- the rot timer as its first key.
15
+ --
16
+ -- 2. "Water" had nothing to write. The owner's four verbs are prune, kill,
17
+ -- prioritise and WATER — leave the thing alone and reset its clock. Without
18
+ -- somewhere to record that, watering would have to fake a real edit, and the
19
+ -- same line would come back on the next read; the inbox teaches people to
20
+ -- ignore it within a week. attention_watered_at IS the reset.
21
+ --
22
+ -- WHY A COLUMN AND NOT AN ITEMS TABLE. There is deliberately no `attention_items`
23
+ -- row anywhere in this design. The inbox line is DERIVED on read from the rot clock
24
+ -- (below) vs the timer, which makes "exactly one open item per stale thing, not one
25
+ -- per sweep" true by construction rather than by a de-duplicating sweep that has to
26
+ -- be got right.
27
+ -- Acting on the line (prune/kill/prioritise) changes status and the row stops
28
+ -- matching; watering pushes the clock forward and it stops matching. Nothing to
29
+ -- reconcile, nothing to garbage-collect, and no way to accrete duplicates.
30
+ --
31
+ -- Additive + forward-safe: every column is nullable with no backfill, nothing is
32
+ -- renamed, and no existing reader changes meaning (tests/migrations_forward_safe.mjs).
33
+
34
+ BEGIN;
35
+
36
+ -- ---------------------------------------------------------------------------
37
+ -- 1. project_settings — the instance's live, DB-backed knobs.
38
+ -- ---------------------------------------------------------------------------
39
+ --
40
+ -- Deliberately a generic key/value table rather than one column per knob: task
41
+ -- 1003280 puts five project fundamentals on one page, and a table that grows a
42
+ -- migration per knob would make that page a schema change every time. Values are
43
+ -- stored as TEXT and parsed by a typed accessor at the read edge
44
+ -- (src/bongos/project-settings.js), the same shape src/stale-timer.js already uses
45
+ -- for env values — one parser, one range check, one documented default.
46
+ --
47
+ -- A key that has never been set has NO ROW. Absence means "use the code default",
48
+ -- which is what lets a fresh instance boot correctly with an empty table, and what
49
+ -- lets a settings page distinguish "never configured" from "deliberately set to
50
+ -- the same value the default happens to be".
51
+ CREATE TABLE IF NOT EXISTS project_settings (
52
+ key text PRIMARY KEY,
53
+ value text NOT NULL,
54
+ updated_at timestamptz NOT NULL DEFAULT now(),
55
+ updated_by bigint REFERENCES builders(id)
56
+ );
57
+
58
+ COMMENT ON TABLE project_settings IS
59
+ 'Live, DB-backed per-project knobs (task 1003279). Key/value so task 1003280''s settings page can grow a row without a migration. No row = never configured = use the code default; that absence is meaningful, so do not seed defaults here.';
60
+ COMMENT ON COLUMN project_settings.value IS
61
+ 'Stored as text and parsed by a typed accessor at the read edge (src/bongos/project-settings.js). A value that fails its range check is IGNORED with a receipt, never coerced silently — the ADR 0226 posture.';
62
+ COMMENT ON COLUMN project_settings.updated_by IS
63
+ 'The builder who last set this knob. NULL = set by a system path with no builder in the request (the goals.created_by convention, migration 160).';
64
+
65
+ -- ---------------------------------------------------------------------------
66
+ -- 2. The water mark, on both tiers that can rot.
67
+ -- ---------------------------------------------------------------------------
68
+ --
69
+ -- NULL = never watered, and the thing's own activity timestamp is the whole clock.
70
+ -- This is why the read expression is a GREATEST and not a bare COALESCE: a real edit
71
+ -- after a watering must move the clock forward again, and a watering must never make
72
+ -- a thing look OLDER than its last genuine touch.
73
+ --
74
+ -- THE ROT CLOCK, AND WHY IT IS SHAPED LIKE THIS:
75
+ --
76
+ -- tasks: GREATEST(updated_at, COALESCE(shipped_at, updated_at),
77
+ -- COALESCE(attention_watered_at, updated_at))
78
+ -- goals: GREATEST(updated_at, COALESCE(attention_watered_at, updated_at))
79
+ --
80
+ -- The first two terms are NOT a new definition of "touched" — they are exactly the
81
+ -- one modules/hall-ui/public/task-lib.js already publishes for the Tasks board and
82
+ -- the Goals page (`taskActivityMs = max(updated_at, shipped_at)`, task 1003278, this
83
+ -- same criterion), which folds in shipped_at because a ship path can stamp it
84
+ -- without touching updated_at. This migration adds a THIRD term to that definition
85
+ -- and nothing else. Had the server invented its own two-term clock, the board and
86
+ -- the inbox would disagree about which rows are stale — which is the 6h-vs-24h
87
+ -- failure of ADR 0226 reappearing one tier up, and the reason the indexes below
88
+ -- spell the expression out in full rather than approximating it.
89
+ -- (Goals carry no shipped_at column, so their clock has two terms, not three.)
90
+ ALTER TABLE tasks
91
+ ADD COLUMN IF NOT EXISTS attention_watered_at timestamptz,
92
+ ADD COLUMN IF NOT EXISTS attention_watered_by bigint REFERENCES builders(id);
93
+
94
+ ALTER TABLE goals
95
+ ADD COLUMN IF NOT EXISTS attention_watered_at timestamptz,
96
+ ADD COLUMN IF NOT EXISTS attention_watered_by bigint REFERENCES builders(id);
97
+
98
+ COMMENT ON COLUMN tasks.attention_watered_at IS
99
+ 'When a human last said "leave it, it is fine" about this task rotting (task 1003279). Resets the rot clock without touching the work. NULL = never watered.';
100
+ COMMENT ON COLUMN goals.attention_watered_at IS
101
+ 'When a human last said "leave it, it is fine" about this goal rotting (task 1003279). Resets the rot clock without touching the work. NULL = never watered.';
102
+
103
+ -- The rot read is "open things whose clock is older than N days", so the index is
104
+ -- on (status, clock) — status first because it is the equality predicate and it
105
+ -- cuts the table to the open rows before the range scan ever runs.
106
+ CREATE INDEX IF NOT EXISTS idx_tasks_attention_rot
107
+ ON tasks (status, (GREATEST(updated_at,
108
+ COALESCE(shipped_at, updated_at),
109
+ COALESCE(attention_watered_at, updated_at))));
110
+ CREATE INDEX IF NOT EXISTS idx_goals_attention_rot
111
+ ON goals (status, (GREATEST(updated_at, COALESCE(attention_watered_at, updated_at))));
112
+
113
+ -- ---------------------------------------------------------------------------
114
+ -- 3. Rebuild the two `t.*` views so the new columns are projected.
115
+ -- ---------------------------------------------------------------------------
116
+ --
117
+ -- A Postgres view created with `SELECT t.*` pins its column list at CREATE VIEW
118
+ -- time. core_227's header records six columns that silently never reached the claim
119
+ -- feed exactly this way; bodies below are identical to core_227's (the wildcard
120
+ -- re-expands to every current column).
121
+
122
+ DROP VIEW IF EXISTS claimable_tasks;
123
+ CREATE VIEW claimable_tasks AS
124
+ SELECT t.*,
125
+ pms_calibrated_minutes(t.est_minutes, t.kind) AS est_minutes_calibrated
126
+ FROM tasks t
127
+ WHERE t.status = 'ready'
128
+ AND t.title NOT LIKE '\_\_smoke\_\_%' ESCAPE '\'
129
+ AND NOT EXISTS (
130
+ SELECT 1 FROM task_blockers tb WHERE tb.task_id = t.id
131
+ )
132
+ AND NOT EXISTS (
133
+ SELECT 1 FROM claims c
134
+ JOIN tasks t2 ON t2.id = c.task_id
135
+ WHERE c.released_at IS NULL
136
+ AND t2.touches && t.touches
137
+ );
138
+
139
+ DROP VIEW IF EXISTS tasks_session_fit;
140
+ CREATE VIEW tasks_session_fit AS
141
+ SELECT t.*,
142
+ CASE
143
+ WHEN t.automation_tag = 'overnight-safe' AND COALESCE(t.manual_degree, 5) <= 2
144
+ AND NOT EXISTS (SELECT 1 FROM task_blockers tb WHERE tb.task_id = t.id)
145
+ THEN ARRAY['autonomous','check-in','active']
146
+ WHEN t.automation_tag = 'live-only' OR COALESCE(t.manual_degree, 5) >= 4
147
+ THEN ARRAY['active']
148
+ ELSE ARRAY['check-in','active']
149
+ END AS fits_session_modes,
150
+ pms_calibrated_minutes(t.est_minutes, t.kind) AS est_minutes_calibrated,
151
+ CASE
152
+ WHEN t.credits_reward > 0
153
+ AND pms_calibrated_minutes(t.est_minutes, t.kind) IS NOT NULL
154
+ AND pms_calibrated_minutes(t.est_minutes, t.kind) > 0
155
+ THEN ROUND(t.credits_reward::numeric / pms_calibrated_minutes(t.est_minutes, t.kind), 3)
156
+ ELSE NULL
157
+ END AS credits_per_minute
158
+ FROM tasks t;
159
+
160
+ COMMIT;
161
+
162
+ INSERT INTO schema_migrations (version) VALUES ('core_231_project_settings_and_attention_water')
163
+ ON CONFLICT (version) DO NOTHING;
@@ -386,6 +386,8 @@ function createClient(opts = {}) {
386
386
  postGoalsIdTransfer: (args) => request("POST", "/goals/{id}/transfer", { hasBody: true }, args),
387
387
  // PATCH /goals/{id}/visibility — rank: any-builder — PATCH /goals/:id/visibility
388
388
  patchGoalsIdVisibility: (args) => request("PATCH", "/goals/{id}/visibility", { hasBody: true }, args),
389
+ // POST /goals/{id}/water — rank: any-builder — POST /goals/:id/water
390
+ postGoalsIdWater: (args) => request("POST", "/goals/{id}/water", { hasBody: true }, args),
389
391
  // GET /goals/inbox — rank: any-builder — GET /goals/inbox
390
392
  getGoalsInbox: (args) => request("GET", "/goals/inbox", { hasBody: false }, args),
391
393
  // POST /goals/reorder — rank: metic+archon — POST /goals/reorder
@@ -466,6 +468,8 @@ function createClient(opts = {}) {
466
468
  getInboxAwaitingNod: (args) => request("GET", "/inbox/awaiting-nod", { hasBody: false }, args),
467
469
  // GET /inbox/by-builder/{id} — rank: any-builder — GET /inbox/by-builder/:id
468
470
  getInboxByBuilderId: (args) => request("GET", "/inbox/by-builder/{id}", { hasBody: false }, args),
471
+ // GET /inbox/rotting — rank: any-builder — GET /inbox/rotting
472
+ getInboxRotting: (args) => request("GET", "/inbox/rotting", { hasBody: false }, args),
469
473
  // POST /inbox/score — rank: any-builder — POST /inbox/score
470
474
  postInboxScore: (args) => request("POST", "/inbox/score", { hasBody: true }, args),
471
475
  // GET /inbox/sparks — rank: any-builder — GET /inbox/sparks
@@ -838,6 +842,8 @@ function createClient(opts = {}) {
838
842
  postTasksIdVisual: (args) => request("POST", "/tasks/{id}/visual", { hasBody: true }, args),
839
843
  // POST /tasks/{id}/vote — rank: metic+archon — POST /tasks/:id/vote
840
844
  postTasksIdVote: (args) => request("POST", "/tasks/{id}/vote", { hasBody: true }, args),
845
+ // POST /tasks/{id}/water — rank: any-builder — POST /tasks/:id/water
846
+ postTasksIdWater: (args) => request("POST", "/tasks/{id}/water", { hasBody: true }, args),
841
847
  // GET /tasks/claimable — rank: any-builder — GET /tasks/claimable
842
848
  getTasksClaimable: (args) => request("GET", "/tasks/claimable", { hasBody: false }, args),
843
849
  // GET /tasks/newcomer-floor — rank: any-builder — GET /tasks/newcomer-floor
@@ -167,6 +167,19 @@
167
167
  }
168
168
 
169
169
  function onClick(e) {
170
+ // task 1003279: a rot verb. Handled FIRST because its rows carry no data-goal —
171
+ // the path is server-declared and already tier-correct, so this branch needs to
172
+ // know nothing about which of the four verbs was pressed beyond what to say
173
+ // afterwards. Refreshes the ROT slice only: repainting the goals list would
174
+ // leave the acted-on row sitting there (the task-1003066 lesson).
175
+ const rotBtn = e.target.closest('[data-rot-path]');
176
+ if (rotBtn) {
177
+ const verb = rotBtn.dataset.rotVerb;
178
+ act(() => postJSON(`${API}${rotBtn.dataset.rotPath}`, {}),
179
+ ROT_TOASTS[verb] || 'Done.', loadRot);
180
+ return;
181
+ }
182
+
170
183
  // task 1003066 (BV1.R05): the nod. It reuses the EXISTING promote route —
171
184
  // POST /tasks/:id/promote, already Metic+ via task.promote — so giving a nod
172
185
  // adds no endpoint and no permission. Handled before the membership branches
@@ -262,6 +275,104 @@
262
275
  .catch(() => { /* a non-triager's 403, or a hiccup: never break the hall */ });
263
276
  }
264
277
 
278
+ // ---- the rot queue (task 1003279, goal 1000072) ---------------------------
279
+ //
280
+ // "Untouched-for-X triggers a configured action rather than a silent decay."
281
+ // The owner's decision (2026-08-25): the action is a LINE IN THIS CARD, not an
282
+ // automatic status change and not a nightly digest. Auto-demoting mutates real
283
+ // work with no human in the loop, and a wrong timer then silently freezes it; a
284
+ // digest is a notification, not an action, and gets scrolled past.
285
+ //
286
+ // THE BUTTONS ARE SERVER-DECLARED. Each row arrives carrying its own `verbs`
287
+ // array — label, method and path — and this renders exactly those. It is not
288
+ // decoration: three of the four verbs reuse EXISTING routes (promote, demote,
289
+ // abandon, archive) whose permissions differ by tier, and a hardcoded button list
290
+ // here would be a second copy of that mapping, free to drift into offering a
291
+ // button the server will refuse. A goal legitimately gets two verbs where a task
292
+ // gets four; this file does not need to know why.
293
+ //
294
+ // No client-side rank gate, for the same reason the nod slice has none: the read
295
+ // is scoped server-side, so a builder is only ever handed rows that are theirs to
296
+ // act on and the button list is already narrowed to what they can press.
297
+ const MAX_ROT_ROWS = 5;
298
+
299
+ function untouchedFor(iso) {
300
+ const t = iso ? Date.parse(iso) : NaN;
301
+ if (Number.isNaN(t)) return 'untouched';
302
+ const days = Math.floor((Date.now() - t) / 86400000);
303
+ if (days < 1) return 'untouched today';
304
+ return `untouched ${days} day${days === 1 ? '' : 's'}`;
305
+ }
306
+
307
+ function rotRowHtml(r) {
308
+ const href = r.tier === 'goal'
309
+ ? goalHref(r.id)
310
+ : `/task/${encodeURIComponent(r.id)}`;
311
+ const where = r.tier === 'goal'
312
+ ? 'goal'
313
+ : (r.goal_title ? escapeHtml(r.goal_title) : `goal ${escapeHtml(String(r.goal_id || '—'))}`);
314
+ // `watered` is worth showing: a row that is back DESPITE having been watered is
315
+ // a different message from one nobody has ever looked at, and hiding that would
316
+ // make the second watering feel like the first one did nothing.
317
+ const again = r.ever_watered ? ' · watered before' : '';
318
+ const buttons = (Array.isArray(r.verbs) ? r.verbs : []).map((v) => {
319
+ const path = String(v.path || '').replace(':id', encodeURIComponent(r.id));
320
+ return `<button class="btn-ghost" type="button" data-rot-path="${escapeHtml(path)}"`
321
+ + ` data-rot-verb="${escapeHtml(String(v.verb || ''))}"`
322
+ + ` title="${escapeHtml(String(v.hint || ''))}">${escapeHtml(String(v.label || v.verb || '?'))}</button>`;
323
+ }).join('');
324
+ return `<li class="ginbox__item" data-rot-tier="${escapeHtml(String(r.tier))}">
325
+ <div class="ginbox__main">
326
+ <a class="ginbox__goal" href="${href}">${escapeHtml(r.title || `${r.tier} #${r.id}`)}</a>
327
+ <span class="ginbox__sub">${escapeHtml(String(r.status || ''))} · ${where} · ${escapeHtml(untouchedFor(r.last_touched_at))}${again}</span>
328
+ </div>
329
+ <div class="ginbox__acts">${buttons}</div>
330
+ </li>`;
331
+ }
332
+
333
+ // What each verb says when it lands. Keyed by the SERVER's verb name so a new verb
334
+ // degrades to a neutral confirmation instead of a blank toast.
335
+ const ROT_TOASTS = {
336
+ prioritise: 'Prioritised — it’s claimable now.',
337
+ prune: 'Pruned — back in the backlog.',
338
+ kill: 'Killed — it won’t come back.',
339
+ water: 'Watered — the clock is reset.',
340
+ };
341
+
342
+ function renderRot(data) {
343
+ const box = host && host.querySelector('#attention-rot');
344
+ if (!box) return;
345
+ const rows = (data && Array.isArray(data.rows)) ? data.rows : [];
346
+ if (!rows.length) {
347
+ box.innerHTML = '';
348
+ window.OTBAttention?.refresh();
349
+ return;
350
+ }
351
+ const shown = rows.slice(0, MAX_ROT_ROWS);
352
+ const more = rows.length > shown.length
353
+ ? `<p class="ginbox__pending">…${rows.length - shown.length} more going quiet.</p>`
354
+ : '';
355
+ // The heading names the TIMER, not just the count. A reader who disagrees with
356
+ // the line ("30 days is too short for a backlog") needs to know which number
357
+ // produced it before they can go change it — and when that number came from a
358
+ // value the server could not use, the receipt says so here, on the screen of
359
+ // somebody who can fix it.
360
+ const t = (data && data.timer) || {};
361
+ const timerNote = t.invalid
362
+ ? `<span class="ginbox__sub"> — ${escapeHtml(String(t.describe || ''))}</span>`
363
+ : `<span class="ginbox__sub"> — untouched ${escapeHtml(String(t.rot_days ?? '?'))}+ days</span>`;
364
+ box.innerHTML = '<h3 class="ginbox__sec-h">Going quiet '
365
+ + `<span class="count-pill">${rows.length}</span>${timerNote}</h3>`
366
+ + `<ul class="ginbox__list">${shown.map(rotRowHtml).join('')}</ul>${more}`;
367
+ window.OTBAttention?.refresh();
368
+ }
369
+
370
+ function loadRot() {
371
+ return getJSON(`${API}/inbox/rotting`)
372
+ .then((data) => renderRot(data))
373
+ .catch(() => { /* signed out, older server, or a hiccup: never break the hall */ });
374
+ }
375
+
265
376
  // task 2169: this widget IS the "Needs your attention" card — ONE capped
266
377
  // summary hosting every actionable item class: goal invitations +
267
378
  // join-requests (this file) and ideas awaiting triage (builders.js renders
@@ -294,10 +405,17 @@
294
405
  // awaiting a nod inside a goal sits nearer the goals slice than idea
295
406
  // triage. Its rows carry the shared .ginbox__item class, so refresh()
296
407
  // counts them with no change to the selector below.
297
- '<div id="attention-nod"></div>';
408
+ '<div id="attention-nod"></div>' +
409
+ // task 1003279 (goal 1000072): work going quiet — a goal or task untouched
410
+ // past the project's rot timer, with the owner's four verbs on it. LAST in
411
+ // the card on purpose: every slice above is somebody waiting on YOU right
412
+ // now, while rot is a standing question that has already waited weeks and
413
+ // can wait until they are read.
414
+ '<div id="attention-rot"></div>';
298
415
  host.addEventListener('click', onClick);
299
416
  load();
300
417
  loadNodQueue();
418
+ loadRot();
301
419
  // task 1002802 (P06): the profile slice lives in its own file (builders.js is
302
420
  // over the size budget); this card owns its div, so it boots it here.
303
421
  window.OTBProfileNudge?.load();
@@ -9,6 +9,8 @@
9
9
  <link rel="preconnect" href="https://fonts.googleapis.com">
10
10
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
11
  <link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
12
+ <!-- The PANEL archetype (task 1003308): this page is its sign-in gate shape. -->
13
+ <link rel="stylesheet" href="/builders/panel.css">
12
14
  </head>
13
15
  <body data-page="not-ready" data-page-title="Locked">
14
16
 
@@ -18,21 +20,23 @@
18
20
  <div class="app-body">
19
21
  <header class="app-topbar" id="app-topbar"></header>
20
22
  <div class="app-content">
23
+ <!-- The page's ONE h1 (one-header-main-h1), in the family's voice. -->
24
+ <div class="page-head page-head--room page-head--panel">
25
+ <h1 id="not-ready-h1" class="page-head__h">not yet</h1>
26
+ <p class="page-head__sub">This page unlocks as you advance.</p>
27
+ </div>
21
28
  <main id="hall-main">
22
- <section class="scroll">
23
- <h2 class="scroll__h">Not yet available</h2>
24
- <p class="scroll__lede">This page unlocks as you advance.</p>
25
- <p>
29
+ <section class="scroll gate" aria-labelledby="not-ready-h">
30
+ <h2 id="not-ready-h" class="gate__h">Not yet available</h2>
31
+ <p class="gate__lede">
26
32
  Xenos builders start with a focused set of pages — ship your first
27
33
  <strong>three tasks</strong> to graduate and open the rest of
28
34
  {{worldName}}'s builder hub.
29
35
  </p>
30
- <p style="margin-top: 18px;">
31
- <a href="/ranks">See how ranks work →</a>
32
- </p>
33
- <p>
34
- <a href="/">← Back to Home</a>
35
- </p>
36
+ <div class="row-end">
37
+ <a class="pbtn pbtn--save" href="/ranks">See how ranks work</a>
38
+ <a class="pbtn pbtn--quiet" href="/">Back to home</a>
39
+ </div>
36
40
  </section>
37
41
  </main>
38
42
  <footer class="app-foot" id="app-foot"></footer>
@@ -0,0 +1,14 @@
1
+ {
2
+ "_": "The NOT-READY page through modules/ui-design/kit/render.js (task 1003308) — the sign-in gate shape of the panel family, static markup with no feed. Renders under the kit's own stub and under the harness alike.",
3
+ "page": "/not-ready",
4
+ "surface": "hall-ui",
5
+ "stub": { "prefix": "/builders/" },
6
+ "modeQuery": false,
7
+ "ignoreRequests": ["/api/(gds|bongos)/"],
8
+ "ignoreConsole": ["/api/(gds|bongos)/", "\b401\b", "\b404\b", "Unauthorized", "Not Found", "load failed"],
9
+ "states": {
10
+ "locked": { "auth": true, "actions": [["wait", 400]], "expect": { "visible": ["#not-ready-h1", "#not-ready-h"] } },
11
+ "out": { "auth": false, "actions": [["wait", 400]], "expect": { "visible": ["#not-ready-h1"] } }
12
+ },
13
+ "reducedMotion": { "auth": true, "actions": [["wait", 400]] }
14
+ }
@@ -1,73 +1,23 @@
1
- /* pair.css — Page-local styles for the dev-box Pair page.
2
- Lifted verbatim out of the page's inline <style> block (task 1002176);
3
- the ?v= cache-bust is stamped automatically from this file's content hash
4
- at boot (src/asset-version.js). */
1
+ /* pair.css — what the Dev Box pairing page has and the rest of the panel family
2
+ does not (task 1003308, goal 1000081; the archetype is panel.css). One panel,
3
+ one device, one button. The device is a row of the family with the platform
4
+ glyph as its figure; the approve button is the page's one accent door. */
5
5
 
6
- /* Page-local styles for the pairing approve page (#904) — built on the
7
- shared design-system tokens in style.css. */
8
6
  #pair-scroll { max-width: 640px; }
9
7
 
10
8
  .pair-device {
11
9
  display: flex;
12
10
  align-items: center;
13
- gap: 14px;
14
- background: var(--surface-2);
15
- border: 1px solid var(--border);
16
- border-radius: var(--radius-sm);
17
- padding: 14px 16px;
18
- margin: 14px 0 18px;
19
- }
20
- .pair-device__glyph {
21
- font-size: 1.7rem;
22
- line-height: 1;
23
- }
24
- .pair-device__name {
25
- font-family: var(--font-display);
26
- font-size: 14px;
27
- font-weight: 600;
28
- color: var(--ink);
29
- }
30
- .pair-device__meta {
31
- font-family: var(--font-body);
32
- font-size: 13px;
33
- color: var(--ink-soft);
34
- }
35
- .pair-code {
36
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
37
- font-size: 12.5px;
38
- letter-spacing: 0.08em;
39
- color: var(--ink-soft);
40
- }
41
- .pair-actions {
42
- display: flex;
43
- align-items: center;
44
- gap: 14px;
45
- flex-wrap: wrap;
46
- }
47
- .pair-approve {
48
- font-family: var(--font-body);
49
- font-size: 13.5px;
50
- font-weight: 600;
51
- padding: 10px 22px;
52
- background: var(--accent);
53
- color: #fff;
54
- border: 1px solid var(--accent);
55
- border-radius: var(--radius-sm);
56
- cursor: pointer;
57
- }
58
- .pair-approve:hover:not(:disabled) { background: var(--accent-deep); border-color: var(--accent-deep); }
59
- .pair-approve:disabled { opacity: 0.55; cursor: default; }
60
- .pair-status {
61
- font-family: var(--font-body);
62
- font-size: 13.5px;
63
- color: var(--ink-soft);
64
- min-height: 1.2em;
65
- }
66
- .pair-status--ok { color: var(--ok); font-weight: 600; }
67
- .pair-status--err { color: var(--danger); }
68
- .pair-note {
69
- font-family: var(--font-body);
70
- font-size: 13px;
71
- color: var(--ink-faint);
72
- margin-top: 16px;
73
- }
11
+ gap: var(--sp-sm);
12
+ margin: var(--sp-sm) 0 var(--sp-md);
13
+ padding: var(--sp-sm) 0;
14
+ border-top: var(--line) solid var(--rule-soft);
15
+ border-bottom: var(--line) solid var(--rule-soft);
16
+ }
17
+ .pair-device__glyph { width: 40px; font-size: 26px; line-height: 1; text-align: center; color: var(--ink-soft); flex: none; }
18
+ .pair-device__name { font-size: 14.5px; font-weight: 500; color: var(--ink); }
19
+ .pair-device__meta { margin-top: 2px; font-size: 13.5px; color: var(--ink-soft); }
20
+ .pair-code { font-family: var(--font-mono); font-size: 12.5px; letter-spacing: .08em; color: var(--ink); }
21
+ .pair-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-xs); }
22
+ .pair-actions .pair-status { margin: 0; }
23
+ .pair-note { margin: var(--sp-sm) 0 0; max-width: var(--measure); font-size: 13px; color: var(--ink-faint); }
@@ -9,6 +9,8 @@
9
9
  <link rel="preconnect" href="https://fonts.googleapis.com">
10
10
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
11
  <link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
12
+ <!-- The PANEL archetype (task 1003308) before the page's own sheet. -->
13
+ <link rel="stylesheet" href="/builders/panel.css">
12
14
  <link rel="stylesheet" href="/builders/pair.css">
13
15
  </head>
14
16
  <body data-page="pair" data-page-title="Device pairing">
@@ -19,7 +21,12 @@
19
21
  <div class="app-body">
20
22
  <header class="app-topbar" id="app-topbar"></header>
21
23
  <div class="app-content">
22
- <div class="page-head"><p class="page-head__sub" id="pair-sub">Reading the request…</p></div>
24
+ <!-- The page's ONE h1 (one-header-main-h1), in the family's voice; the
25
+ sub-line is pair.js's — the request, the approval, or the error. -->
26
+ <div class="page-head page-head--room page-head--panel">
27
+ <h1 id="pair-h1" class="page-head__h">pairing</h1>
28
+ <p class="page-head__sub" id="pair-sub">Reading the request…</p>
29
+ </div>
23
30
  <main id="hall-main">
24
31
  <section class="scroll" id="pair-scroll" aria-labelledby="pair-h">
25
32
  <h2 id="pair-h" class="scroll__h">A device is asking to act as you</h2>
@@ -0,0 +1,14 @@
1
+ {
2
+ "_": "The Dev Box PAIRING page through modules/ui-design/kit/render.js (task 1003308). The device row and the approve button only exist once the pairing request has LOADED, so the populated state runs against the hall-preview harness (see settings.states.json for the two commands; the harness serves scripts/hall-preview/fixtures/auth__app-pair__info.json for any code). `nocode` is the page's own error render — the link arrived without its pairing code — and needs nothing served.",
3
+ "page": "/pair",
4
+ "surface": "hall-ui",
5
+ "stub": { "prefix": "/builders/" },
6
+ "modeQuery": false,
7
+ "ignoreRequests": ["/api/(gds|bongos)/"],
8
+ "ignoreConsole": ["/api/(gds|bongos)/", "\b401\b", "\b404\b", "Unauthorized", "Not Found", "load failed"],
9
+ "states": {
10
+ "request": { "auth": true, "url": "/pair?code=K7QD-4M2X", "actions": [["wait", 700]], "expect": { "visible": ["#pair-h1", "#pair-approve-btn"] } },
11
+ "nocode": { "auth": true, "actions": [["wait", 700]], "expect": { "visible": ["#pair-h1", "#pair-scroll .pair-status--err"] } }
12
+ },
13
+ "reducedMotion": { "auth": true, "url": "/pair?code=K7QD-4M2X", "actions": [["wait", 700]] }
14
+ }