@bongos/core 1.19.840 → 1.19.842

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.840",
6
- "core_contract": "1.19.840",
7
- "source_commit": "47fbba53d86033101f57cf1e1a85aef7367fe320",
5
+ "core_version": "1.19.842",
6
+ "core_contract": "1.19.842",
7
+ "source_commit": "ffa8d1ce17675d5981142ca6588ef641d0893058",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-17T17:45:41.665Z",
9
+ "built_at": "2026-09-17T18:21:47.399Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 495,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2262,
14
+ "functional_verbatim": 2266,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2783,
20
- "tree_sha256": "5ddd99652c37c0ad56f34d618732a671733aed9b055c1c4ee161135581d0c80a",
19
+ "file_count": 2787,
20
+ "tree_sha256": "204ee5631e422b90ff1d79a7fde81706ca492bea501801a162421ebd04f6bfdb",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -404,6 +404,11 @@
404
404
  "mode": "0000644",
405
405
  "sha256": "9bdd8eb1fbfb9053ed0fb921c0f669dcdaad2fe8b2629ce4cc9457dca995afbe"
406
406
  },
407
+ {
408
+ "path": "config/scheduled-routines.json",
409
+ "mode": "0000644",
410
+ "sha256": "9086dbccb8319aaca6b88757249bc3a5001612f4b5e2afb47ac41e9dfe4fd28f"
411
+ },
407
412
  {
408
413
  "path": "docker-compose.yml",
409
414
  "mode": "0000644",
@@ -2572,7 +2577,7 @@
2572
2577
  {
2573
2578
  "path": "docs/module-api-changelog.md",
2574
2579
  "mode": "0000644",
2575
- "sha256": "f8fa8c024b4d6741ea672a7be33957d6cc58c6b85867ef2b5499bcc31fdf4b50"
2580
+ "sha256": "033de93b8ea7781edf94b6b1146741c2bde76443ecf7b6c8b1bf47d8919d6bab"
2576
2581
  },
2577
2582
  {
2578
2583
  "path": "docs/modules-contract.md",
@@ -4517,7 +4522,7 @@
4517
4522
  {
4518
4523
  "path": "modules/government/protected-surfaces.json",
4519
4524
  "mode": "0000644",
4520
- "sha256": "ec125c2bfba680c7645dda0b65dd6ece183dd57ceecdb5616ea2b8b34b776e56"
4525
+ "sha256": "aba8a8b1ce22708f5a7c05a64cbb4d1a08db295339a09df3c50a91f2735c1d47"
4521
4526
  },
4522
4527
  {
4523
4528
  "path": "modules/government/ranks.js",
@@ -6514,15 +6519,20 @@
6514
6519
  "mode": "0000644",
6515
6520
  "sha256": "bf4a392ce4c545feb246b818ad2b37dd5e92050538d5202fb8980941d0ec52c9"
6516
6521
  },
6522
+ {
6523
+ "path": "modules/provisioning/pollers/liveness-notify.js",
6524
+ "mode": "0000644",
6525
+ "sha256": "b359eb2c731563e3570f8fbea7128f78070e7bd5737110f739a2b2fcb2cf382b"
6526
+ },
6517
6527
  {
6518
6528
  "path": "modules/provisioning/pollers/liveness-sweep.js",
6519
6529
  "mode": "0000644",
6520
- "sha256": "f47e30b7d4bc1275f65e29a1d9f987c7f20e6baa0a6c441fb4ddbb56f11fe58f"
6530
+ "sha256": "90db999a2e1d3dec2e3910281e2e5dccd55cf294d7c5c38233456c29d71bacd3"
6521
6531
  },
6522
6532
  {
6523
6533
  "path": "modules/provisioning/provisioning.js",
6524
6534
  "mode": "0000644",
6525
- "sha256": "b70d138fb6ed6f0fe4f04d2a37a7a195364250ca10b46a933c5aad0057061c10"
6535
+ "sha256": "c2e7574b84038bd2f4da93d5ec54f8e577353d9c37e5585bff1dd9b8f855cccf"
6526
6536
  },
6527
6537
  {
6528
6538
  "path": "modules/provisioning/rate-limit.js",
@@ -6569,10 +6579,15 @@
6569
6579
  "mode": "0000644",
6570
6580
  "sha256": "cb2ca3bdf6cd99ee66d5242709fed3c167840a90cff9efe83ddb193c00c9719e"
6571
6581
  },
6582
+ {
6583
+ "path": "modules/provisioning/tests/liveness-notify.mjs",
6584
+ "mode": "0000644",
6585
+ "sha256": "27c83fd37966796a6dd0dd55c69b434e77f7782bebe7452706ff00cc99308db8"
6586
+ },
6572
6587
  {
6573
6588
  "path": "modules/provisioning/tests/liveness.mjs",
6574
6589
  "mode": "0000644",
6575
- "sha256": "ead476fd677f78a3daed878fe8916acecbdac25e1ae6e29f340a8d08428c8fbf"
6590
+ "sha256": "c5f7c2d8880f9bfd45346ac8a7b24e3391daa9d96aeffad806d074ac4fffabf4"
6576
6591
  },
6577
6592
  {
6578
6593
  "path": "modules/provisioning/tests/provisioning.mjs",
@@ -7877,12 +7892,12 @@
7877
7892
  {
7878
7893
  "path": "package-lock.json",
7879
7894
  "mode": "0000644",
7880
- "sha256": "d54803c0123c3ee0b857092be850581f787ed85dc36cae70e737977f14f0356b"
7895
+ "sha256": "21b80f9aba47da61edcb4a374ad4aad35f2e5c563cccb1daae278b6d892d0255"
7881
7896
  },
7882
7897
  {
7883
7898
  "path": "package.json",
7884
7899
  "mode": "0000644",
7885
- "sha256": "0a21605b0152921f84f2e67716df7e251b09a23b24c3b9569d3332d8d98e754c"
7900
+ "sha256": "61f48c57b02632310e7ba92115fdc6939efe7967f2ee7542fa7b12834f8af6c2"
7886
7901
  },
7887
7902
  {
7888
7903
  "path": "public-docs/index.html",
@@ -8827,7 +8842,7 @@
8827
8842
  {
8828
8843
  "path": "scripts/gds/publish-manifest.js",
8829
8844
  "mode": "0000644",
8830
- "sha256": "3663bea0a86f5f0a32d4bd48d98a78405fcc7a2840b4db081cade8601e9efa5b"
8845
+ "sha256": "da74b7bf9aa1453eb56f922f3733aa06ac2d00216f668a06dc134934f1d4fde7"
8831
8846
  },
8832
8847
  {
8833
8848
  "path": "scripts/gds/publish-watch.js",
@@ -9812,7 +9827,7 @@
9812
9827
  {
9813
9828
  "path": "src/module-api.js",
9814
9829
  "mode": "0000644",
9815
- "sha256": "8bfa67a85b56ce33dbb502537edced8bf39de30551c7949bf3aa9460a1deffa3"
9830
+ "sha256": "84e310a550189d0d3d86f825cc04d61d81e0c0dc78428056661357a1a5076e74"
9816
9831
  },
9817
9832
  {
9818
9833
  "path": "src/module-loader/catalog.js",
@@ -9954,6 +9969,11 @@
9954
9969
  "mode": "0000644",
9955
9970
  "sha256": "919ca0ea3fe5f78a194781f6b634b9da23212073cf0c7cbe8ae36a65cc3fecdb"
9956
9971
  },
9972
+ {
9973
+ "path": "tests/agents_roster.mjs",
9974
+ "mode": "0000644",
9975
+ "sha256": "acc25f4d72cb22719d6977f939b41f9f17e0d102880976699b6ebcda1c64c523"
9976
+ },
9957
9977
  {
9958
9978
  "path": "tests/agents_routes.mjs",
9959
9979
  "mode": "0000644",
@@ -11432,7 +11452,7 @@
11432
11452
  {
11433
11453
  "path": "tests/government_protected_surfaces.mjs",
11434
11454
  "mode": "0000644",
11435
- "sha256": "1d8502e8d25eedb54ed0fcbe3ce1a0fe87495d03bd8bda7322e55d6004669a63"
11455
+ "sha256": "c3c72c3a2cf970222c80051787df7fd7d155b5abd2f80b797a6a58bf5e95e0eb"
11436
11456
  },
11437
11457
  {
11438
11458
  "path": "tests/government_rank_sync.mjs",
@@ -0,0 +1,29 @@
1
+ {
2
+ "_note": "Scheduled-routines portability manifest (task 2014 / BONGOS-V1, ADR 0115). Declares, per autonomous routine under `.claude/scheduled-tasks/<key>/`, how it ports to a self-hosted, multi-builder instance: its SCOPE, whether it's ON by default, its cadence, and whether it needs the autonomy flag armed. THE CONTRACT: every routine is INSTANCE-WIDE — one scheduled run per instance (a designated scheduler / control plane), NOT one cron per builder. A routine's per-builder EFFECT (bfg scores each builder; newcomer-floor restocks per discipline) comes from iterating the builder set inside the routine body — there is no per-builder schedule in v1. A fresh instance inherits this whole set from the core template; the operator opts in/out + sets cadence HERE, without editing the routine. Builders do not schedule their own routines; their involvement is governed by their per-builder settings (autonomy opt-in, wandering), not this file. Cadence values are hints for the scheduler; the actual cron lives in the operator's scheduler config. `requiresAutonomy: true` means the routine is a no-op until the instance arms the autonomy flag (<PREFIX>_AUTONOMY_ENABLED) — those default OFF.",
3
+ "defaults": { "scope": "instance-wide", "enabled": true, "requiresAutonomy": false },
4
+ "routines": {
5
+ "artifact-audit": { "scope": "instance-wide", "enabled": true, "cadence": "weekly", "requiresAutonomy": false, "note": "deterministic; task 1003200. Reads main's own history + tree, so it runs in CI (a vendored core is not a git repo) — .github/workflows/artifact-audit.yml." },
6
+ "artifact-staleness": { "scope": "instance-wide", "enabled": true, "cadence": "daily", "requiresAutonomy": false, "note": "deterministic; task 1001930 / ADR 0110 pt.5. INSTANCE-SIDE ONLY, and the opposite of artifact-audit above: it reads the gitignored deploy-generated files on the serving machine's disk, which a CI checkout legitimately does not have — so it declares no cadence in its SKILL.md and no workflow fires it. This cadence is the hint the operator's own scheduler reads." },
7
+ "bfg": { "scope": "instance-wide", "builderAware": true, "enabled": true, "cadence": "nightly", "requiresAutonomy": false, "note": "Parts A+B read-only + always-on; Part C (cross-builder write) is separately gated by BFG_WRITE_ENABLED + a provisioned principal token." },
8
+ "bfg-session-eval": { "scope": "instance-wide", "builderAware": true, "enabled": true, "cadence": "nightly", "requiresAutonomy": false },
9
+ "box-dormant-reclaim": { "scope": "instance-wide", "enabled": false, "cadence": "daily", "requiresAutonomy": false, "note": "RETIRED with the dev-box module (goal 1000091, task 1003886). Held OFF so the fleet is inert while the removal lands; the routine body is deleted by the dev-box teardown tasks." },
10
+ "box-idle-suspend": { "scope": "instance-wide", "enabled": false, "cadence": "hourly", "requiresAutonomy": false, "note": "RETIRED with the dev-box module (goal 1000091, task 1003886). Held OFF so the fleet is inert while the removal lands; the routine body is deleted by the dev-box teardown tasks." },
11
+ "core-update-subscription": { "scope": "instance-wide", "enabled": true, "cadence": "15min", "requiresAutonomy": true, "note": "Patch-only auto-upgrade of subscribed consumer instances (ADR 0136 / task 2150). Default OFF + opt-in: the roster (config/update-subscriptions.json) ships empty, so this is a no-op until an operator arms autonomy AND lists an instance. Every upgrade is health-gated with auto-rollback (task 2149). CADENCE (task 1003674): this is the knob that decides how long a published release waits before a subscribed instance takes it — at 'daily' a builder's ship reached the live site up to 24h late, because everything upstream is already fast (CI publishes to npm within ~2 min of merge) and this sweep was the only slow step. 15min is the worst-case lag, not a re-check cost: the sweep exits immediately when the registry has nothing newer. FLOOR ~5min: an upgrading run takes ~2 min, and runs must not overlap. Installing the period is the operator's half — see docs/recipes/upgrading-the-core.md." },
12
+ "dep-audit-nightly": { "scope": "instance-wide", "enabled": true, "cadence": "nightly", "requiresAutonomy": false },
13
+ "diagram-drift": { "scope": "instance-wide", "enabled": true, "cadence": "daily", "requiresAutonomy": false },
14
+ "idea-triage-nightly": { "scope": "instance-wide", "enabled": false, "cadence": "nightly", "requiresAutonomy": true },
15
+ "main-audit": { "scope": "instance-wide", "enabled": true, "cadence": "on-merge", "requiresAutonomy": false, "note": "Trust-boundary backstop — audits commits landed on main." },
16
+ "newcomer-floor": { "scope": "instance-wide", "enabled": true, "cadence": "daily", "requiresAutonomy": false },
17
+ "newcomer-generator": { "scope": "instance-wide", "enabled": true, "cadence": "daily", "requiresAutonomy": false },
18
+ "overnight-builder": { "scope": "instance-wide", "enabled": false, "cadence": "nightly", "requiresAutonomy": true, "note": "Autonomous claim+ship; OFF until the instance arms autonomy." },
19
+ "overnight-code-review": { "scope": "instance-wide", "enabled": false, "cadence": "nightly", "requiresAutonomy": true },
20
+ "peer-vote-tally": { "scope": "instance-wide", "enabled": true, "cadence": "daily", "requiresAutonomy": false },
21
+ "rank-roles-audit": { "scope": "instance-wide", "enabled": true, "cadence": "nightly", "requiresAutonomy": false, "note": "deterministic; read-only; never fatal at boot (idea 1000622) — task 1002873" },
22
+ "redteam-weekly": { "scope": "instance-wide", "enabled": true, "cadence": "weekly", "requiresAutonomy": false, "note": "deterministic; task 1003128" },
23
+ "repo-simplify-sweep": { "scope": "instance-wide", "enabled": true, "cadence": "weekly", "requiresAutonomy": false },
24
+ "security-review-weekly": { "scope": "instance-wide", "enabled": true, "cadence": "weekly", "requiresAutonomy": false },
25
+ "skill-usage-review": { "scope": "instance-wide", "enabled": true, "cadence": "nightly", "requiresAutonomy": false },
26
+ "stale-claim-release": { "scope": "instance-wide", "enabled": true, "cadence": "hourly", "requiresAutonomy": false },
27
+ "stranded-rows": { "scope": "instance-wide", "enabled": true, "requiresAutonomy": false, "note": "no cadence: nothing schedules it yet, so it is run by hand — declaring one with no cron firing it fails fitness Check 25 (task 1003775)" }
28
+ }
29
+ }
@@ -2154,5 +2154,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2154
2154
  landed since 1.19.838 with no explicit bump. run 35252591179. (task 1002620)
2155
2155
  1.19.840 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2156
2156
  landed since 1.19.839 with no explicit bump. run 35254616836. (task 1002620)
2157
+ 1.19.841 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2158
+ landed since 1.19.840 with no explicit bump. run 35257419225. (task 1002620)
2159
+ 1.19.842 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2160
+ landed since 1.19.841 with no explicit bump. run 35258288930. (task 1002620)
2157
2161
  ---------------------------------------------------------------------------
2158
2162
  ```
@@ -89,6 +89,7 @@
89
89
  { "glob": "docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md", "group": "docs", "why": "this gate's own ADR" },
90
90
  { "glob": "CLAUDE.md", "group": "docs", "why": "the methodology core" },
91
91
  { "glob": ".claude/hooks/", "group": "automation", "why": "local automation that could weaken a gate" },
92
- { "glob": ".claude/settings.json", "group": "automation", "why": "local automation that could weaken a gate" }
92
+ { "glob": ".claude/settings.json", "group": "automation", "why": "local automation that could weaken a gate" },
93
+ { "glob": "config/scheduled-routines.json", "group": "automation", "why": "task 1002500. The ROUTINE SWITCHBOARD: per routine it decides enabled, cadence and requiresAutonomy — so an edit here can arm overnight-builder (autonomous claim+ship) or silence main-audit, the post-merge trust-boundary backstop ADR 0043 relies on. Sibling of .claude/hooks/ and .claude/settings.json above: the routine BODIES under .claude/scheduled-tasks/ are the autonomy module's source surface and stay unprotected, because protectedness derives per module and walling them is a separate policy decision (the .claude/agents/ precedent recorded in tests/government_protected_surfaces.mjs). This file is kernel-owned territory (module-scope-map's kernel key carries 'config/'), which is already protected, so registering it widens no module wall. The set is computed, not listed: the routine-switch tripwire in tests/government_protected_surfaces.mjs re-detects every config/*.json declaring routine switches and reds if one is unregistered." }
93
94
  ]
94
95
  }
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ // modules/provisioning/pollers/liveness-notify.js — the owner-facing half of the
4
+ // liveness sweep: when does an outage become something worth saying out loud, and
5
+ // in what words (task 1002894, from idea 1000703; the owner's YES of 2026-08-12,
6
+ // scoped out of task 1002696).
7
+ //
8
+ // WHY. The sweep has knocked on every active project's /healthz every five minutes
9
+ // since task 1002696 and told nobody — an owner learned their project had stopped
10
+ // answering by opening the projects page. This turns that record into one message
11
+ // on the DOWN edge, and one when it comes back.
12
+ //
13
+ // THE THREE RULES that keep a heartbeat from becoming a siren:
14
+ // * EDGE, not state. The fall is only visible in the row's PRIOR last_verified_up,
15
+ // which recordLivenessProbe now returns. A row that was already down when this
16
+ // process started has no edge to see, so a restarted control plane re-posts
17
+ // nothing — the notice state is deliberately in memory, seeded only by an edge
18
+ // this process observed.
19
+ // * CONFIRM before speaking. One failed probe is a blip; the notice waits for the
20
+ // outage to survive a second consecutive sweep, and a project that recovers in
21
+ // between was never announced, so nothing needs unsaying.
22
+ // * COLLAPSE a fan-out. When several projects fall in the same sweep, the cause is
23
+ // almost always on this side — a control-plane restart, a prober-side network
24
+ // blip — so they become ONE message that says so, never one per project.
25
+ //
26
+ // The copy is owner-audience and names a project only when the project is public on
27
+ // the platform's map; a private one is described, never identified.
28
+
29
+ const { execFile } = require('node:child_process');
30
+ const path = require('node:path');
31
+
32
+ // Consecutive down probes before an outage is announced. Two ≈ one sweep interval
33
+ // of patience, which is what makes a single refused connection cost nothing.
34
+ const DOWN_CONFIRM = Number(process.env.PROVISIONING_LIVENESS_DOWN_CONFIRM) || 2;
35
+ // Simultaneous falls that mean "look at the platform, not the projects".
36
+ const FLEET_GUARD = Number(process.env.PROVISIONING_LIVENESS_FLEET_GUARD) || 3;
37
+ // scripts/discord/post.js is invoked from the repo root regardless of cwd.
38
+ const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
39
+
40
+ // What the owner reads. `name` is null for a project that is not public — it is
41
+ // described rather than identified, so a private project's slug never rides a
42
+ // Discord channel. A `count` with no name is the collapsed fleet message. PURE.
43
+ function noticeText({ kind, name = null, count = 0 } = {}) {
44
+ if (!name && count > 1) {
45
+ return kind === 'up'
46
+ ? `✅ All ${count} projects that went quiet are answering again.`
47
+ : `⚠️ ${count} projects stopped answering at the same moment. When that many go quiet together it is usually the platform's own check that is having trouble, not the projects themselves — worth a look here first.`;
48
+ }
49
+ const who = name ? `**${name}**` : 'A private project';
50
+ if (kind === 'up') return `✅ ${who} is answering again.`;
51
+ return `⚠️ ${who} has stopped answering. The platform knocked twice in a row and heard nothing back. It will say so here when the project answers again.`;
52
+ }
53
+
54
+ // One collapsed message, or one per project — never both. PURE.
55
+ function collapse(rows, kind, fleetGuard) {
56
+ if (!rows.length) return [];
57
+ if (rows.length >= fleetGuard) return [{ kind, ids: rows.map((r) => r.id), text: noticeText({ kind, count: rows.length }) }];
58
+ return rows.map((r) => ({ kind, ids: [r.id], text: noticeText({ kind, name: r.isPublic ? r.slug : null }) }));
59
+ }
60
+
61
+ // The decision, given this sweep's observations ({ id, slug, isPublic, up, prevUp })
62
+ // and the notice state carried between sweeps. MUTATES `state` (a Map, id → { streak,
63
+ // notified }) and returns what to post; everything else about it is pure, so the
64
+ // rules above are unit-tested without a sweep, a database or a network.
65
+ function planNotices(observations, state, { confirm = DOWN_CONFIRM, fleetGuard = FLEET_GUARD } = {}) {
66
+ const downs = [], ups = [];
67
+ for (const o of observations || []) {
68
+ const prev = state.get(o.id);
69
+ if (o.up) {
70
+ // Only a project we actually announced gets an all-clear; a blip that never
71
+ // reached a notice just drops its streak.
72
+ if (prev && prev.notified) ups.push(o);
73
+ state.delete(o.id);
74
+ continue;
75
+ }
76
+ let streak;
77
+ if (prev) streak = prev.streak + 1;
78
+ else if (o.prevUp === true) streak = 1; // the fall, observed in this process
79
+ else continue; // already down before we were watching
80
+ const notified = !!(prev && prev.notified);
81
+ if (!notified && streak >= confirm) downs.push(o);
82
+ state.set(o.id, { streak, notified: notified || streak >= confirm });
83
+ }
84
+ const notices = [...collapse(downs, 'down', fleetGuard), ...collapse(ups, 'up', fleetGuard)];
85
+ const collapsedCount = (list) => (list.length >= fleetGuard ? list.length : 0);
86
+ return { notices, suppressed: collapsedCount(downs) + collapsedCount(ups) };
87
+ }
88
+
89
+ // Fire-and-forget to Discord #ship-news — the owner-facing feed the build broadcast
90
+ // already uses (owner's call, 2026-09-17: no new channel, no new webhook to place).
91
+ // Mirrors fireDiscordBroadcast in src/bongos/routes/builders.js: non-blocking,
92
+ // CHAT_DRY_RUN=1 short-circuits for the smoke path, and a Discord hiccup can never
93
+ // stall a sweep tick.
94
+ function postNotice(notice, { log = console.log } = {}) {
95
+ const text = notice && notice.text;
96
+ if (!text) return;
97
+ if (process.env.CHAT_DRY_RUN === '1') {
98
+ log(`[liveness] [broadcast dry-run] would post: ${text}`);
99
+ return;
100
+ }
101
+ try {
102
+ execFile('node', ['scripts/discord/post.js', text], { cwd: REPO_ROOT, timeout: 10_000 }, (err) => {
103
+ if (err) log(`[liveness] notice broadcast failed (non-blocking): ${err.message}`);
104
+ });
105
+ } catch (e) {
106
+ log(`[liveness] notice broadcast failed (non-blocking): ${e && e.message ? e.message : e}`);
107
+ }
108
+ }
109
+
110
+ module.exports = { planNotices, noticeText, postNotice, DOWN_CONFIRM, FLEET_GUARD };
@@ -27,6 +27,19 @@ const dns = require('node:dns').promises;
27
27
  const { Agent: UndiciAgent } = require('undici');
28
28
  const api = require('../../../src/module-api');
29
29
  const provisioning = require('../provisioning');
30
+ const { planNotices, postNotice } = require('./liveness-notify');
31
+
32
+ // Notice state between sweeps (task 1002894): id → { streak, notified }. In memory
33
+ // on purpose — it is seeded only by a fall this process observed, which is exactly
34
+ // what stops a restarted control plane re-announcing an outage it arrived to find.
35
+ const noticeState = new Map();
36
+ function resetNoticeState() { noticeState.clear(); }
37
+
38
+ // Name a project in an owner-facing message only when it is public on the platform's
39
+ // map; a private or stealth one is described, never identified.
40
+ function isPublicProject(row) {
41
+ try { return provisioning.effectiveSettings(row).visibility === 'public'; } catch (_) { return false; }
42
+ }
30
43
 
31
44
  // ~5-minute cadence — matches the display contract's "fresh ≤ 2 cadences" window
32
45
  // and is cheap: one HTTP GET per active instance. Override for tests/ops.
@@ -198,8 +211,8 @@ async function sweepOnce(deps = {}) {
198
211
  pin = gate.pin || null;
199
212
  }
200
213
  const probe = await probeHealthz(url, { timeoutMs: deps.timeoutMs, fetchImpl: deps.fetchImpl, pin });
201
- await provisioning.recordLivenessProbe(db, row.id, probe.up);
202
- return { row, probed: true, ...probe };
214
+ const rec = await provisioning.recordLivenessProbe(db, row.id, probe.up);
215
+ return { row, probed: true, prevUp: rec ? rec.prev_up : null, ...probe };
203
216
  }));
204
217
 
205
218
  const probed = results.filter((r) => r.probed);
@@ -217,6 +230,17 @@ async function sweepOnce(deps = {}) {
217
230
  // Nothing active ⇒ nothing to say. A vanilla control plane would otherwise emit
218
231
  // an empty account every five minutes forever.
219
232
  if (rows.length) log(formatSweepLine(out));
233
+
234
+ // Tell the owner what changed (task 1002894). Best-effort like everything else
235
+ // here: a broadcast that throws is logged and the tick still returns its account.
236
+ const notify = deps.notify || ((n) => postNotice(n, { log }));
237
+ const plan = planNotices(
238
+ probed.map((r) => ({ id: r.row.id, slug: r.row.slug, isPublic: isPublicProject(r.row), up: !!r.up, prevUp: r.prevUp })),
239
+ noticeState
240
+ );
241
+ for (const notice of plan.notices) {
242
+ try { notify(notice); } catch (e) { log(`[liveness] notice failed (non-blocking): ${e && e.message ? e.message : e}`); }
243
+ }
220
244
  return out;
221
245
  }
222
246
 
@@ -255,6 +279,7 @@ module.exports = {
255
279
  stop,
256
280
  // exported for tests
257
281
  sweepOnce,
282
+ resetNoticeState,
258
283
  probeHealthz,
259
284
  healthzUrl,
260
285
  formatSweepLine,
@@ -979,7 +979,7 @@ async function clearReleasedDomains(db, id) {
979
979
  // against it would only ever record a false "down". `limit` is a runaway guard.
980
980
  async function listActiveInstancesForLiveness(db, { limit = 500 } = {}) {
981
981
  const { rows } = await db.query(
982
- `SELECT id, slug, domain, port FROM provisioning_instances
982
+ `SELECT id, slug, domain, port, settings FROM provisioning_instances
983
983
  WHERE status = 'active'
984
984
  ORDER BY id
985
985
  LIMIT $1`,
@@ -988,16 +988,16 @@ async function listActiveInstancesForLiveness(db, { limit = 500 } = {}) {
988
988
  return rows;
989
989
  }
990
990
 
991
- // Record ONE probe observation. Deliberately narrow: it writes the two overlay
992
- // columns and nothing else — not `status`, and not `updated_at` either, so a
993
- // 5-minute heartbeat can never reshuffle the owner's updated_at-ordered card list
994
- // or fake activity on an instance nobody touched.
991
+ // Record ONE probe observation. Deliberately narrow: it writes the two overlay columns
992
+ // and nothing else — not `status`, and not `updated_at` either, so a 5-minute heartbeat
993
+ // can never reshuffle the owner's updated_at-ordered card list or fake activity on an
994
+ // instance nobody touched. `prev_up` rides the RETURNING (task 1002894): the FALL is visible
995
+ // only in the value this write replaces — one statement, so no separate SELECT races a sweep.
995
996
  async function recordLivenessProbe(db, id, up) {
996
997
  const { rows } = await db.query(
997
- `UPDATE provisioning_instances
998
- SET last_verified_at = now(), last_verified_up = $2
999
- WHERE id = $1
1000
- RETURNING id, last_verified_at, last_verified_up`,
998
+ `UPDATE provisioning_instances p SET last_verified_at = now(), last_verified_up = $2
999
+ FROM (SELECT id, last_verified_up FROM provisioning_instances WHERE id = $1) prev WHERE p.id = prev.id
1000
+ RETURNING p.id, p.last_verified_at, p.last_verified_up, prev.last_verified_up AS prev_up`,
1001
1001
  [id, !!up]
1002
1002
  );
1003
1003
  return rows[0] || null;
@@ -0,0 +1,199 @@
1
+ // modules/provisioning/tests/liveness-notify.mjs
2
+ //
3
+ // The owner-facing outage notice the liveness sweep posts (task 1002894, from
4
+ // idea 1000703). The sweep has probed every active instance since task 1002696 and
5
+ // told nobody. Covered here:
6
+ // • planNotices — the edge rules: one notice per outage, silence afterwards, a
7
+ // single-sweep blip never posts, a restarted poller never re-posts a fall it
8
+ // did not see, recovery only after a down notice actually went out
9
+ // • the fleet guard — many falls at once collapse into ONE message, because that
10
+ // shape is the platform's own checker, not N projects
11
+ // • noticeText — owner-audience copy that never names a non-public project
12
+ // • sweepOnce — the notices ride the sweep, and a broadcast that throws cannot
13
+ // stall a tick
14
+ //
15
+ // DB-free, network-free: `db` is a recording fake and `notify` is injected.
16
+ //
17
+ // Run standalone: node modules/provisioning/tests/liveness-notify.mjs
18
+
19
+ import { strict as assert } from 'node:assert';
20
+ import { createRequire } from 'node:module';
21
+
22
+ // ESM named imports, not createRequire: knip cannot trace a require()'d export from a
23
+ // test, so a test-only export would read as dead code in the CI ratchet.
24
+ import { planNotices, noticeText, DOWN_CONFIRM, FLEET_GUARD } from '../pollers/liveness-notify.js';
25
+
26
+ const require = createRequire(import.meta.url);
27
+ const liveness = require('../pollers/liveness-sweep.js');
28
+
29
+ let passed = 0, failed = 0;
30
+ function t(name, fn) {
31
+ try { fn(); passed++; console.log(` PASS ${name}`); }
32
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
33
+ }
34
+ async function ta(name, fn) {
35
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
36
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
37
+ }
38
+
39
+ // One observation of one instance in one sweep. prevUp is what the row said
40
+ // BEFORE this probe was recorded — the transition is only visible there.
41
+ const obs = (over = {}) => ({ id: 1, slug: 'mercury', isPublic: true, up: false, prevUp: true, ...over });
42
+ // Drive N sweeps through one state Map and collect what each would post.
43
+ function sweeps(observations, { state = new Map(), opts = {} } = {}) {
44
+ return observations.map((row) => planNotices([].concat(row), state, opts).notices);
45
+ }
46
+
47
+ console.log('planNotices (the edge rules):');
48
+
49
+ t('a fall posts exactly ONE notice, after the outage is confirmed, then stays quiet', () => {
50
+ assert.equal(DOWN_CONFIRM >= 2, true, 'one probe is a blip, not an outage');
51
+ const rounds = sweeps([
52
+ obs(), // the fall: prior up, now down
53
+ obs({ prevUp: false }), // still down — confirms it
54
+ obs({ prevUp: false }), // and again
55
+ obs({ prevUp: false }),
56
+ ]);
57
+ assert.deepEqual(rounds.map((n) => n.length), [0, 1, 0, 0]);
58
+ assert.equal(rounds[1][0].kind, 'down');
59
+ assert.match(rounds[1][0].text, /mercury/);
60
+ });
61
+
62
+ t('a one-sweep blip never posts — it recovers before the outage is confirmed', () => {
63
+ const rounds = sweeps([obs(), obs({ up: true, prevUp: false })]);
64
+ assert.deepEqual(rounds.map((n) => n.length), [0, 0], 'nothing was said, so nothing needs unsaying');
65
+ });
66
+
67
+ t('a restarted poller never re-posts an outage that fell before it started', () => {
68
+ // Fresh state (the restart) and a row that is ALREADY down: no edge to see.
69
+ const rounds = sweeps([obs({ prevUp: false }), obs({ prevUp: false }), obs({ prevUp: false })]);
70
+ assert.deepEqual(rounds.map((n) => n.length), [0, 0, 0]);
71
+ });
72
+
73
+ t('recovery posts once, and only when a down notice actually went out', () => {
74
+ const state = new Map();
75
+ const rounds = sweeps([obs(), obs({ prevUp: false }), obs({ up: true, prevUp: false }), obs({ up: true, prevUp: true })], { state });
76
+ assert.deepEqual(rounds.map((n) => n.length), [0, 1, 1, 0]);
77
+ assert.equal(rounds[2][0].kind, 'up');
78
+ assert.equal(state.size, 0, 'a healed instance leaves no state behind');
79
+ });
80
+
81
+ t('a second outage on the same instance posts again — the notice is per outage, not per lifetime', () => {
82
+ const state = new Map();
83
+ const rounds = sweeps([obs(), obs({ prevUp: false }), obs({ up: true, prevUp: false }), obs(), obs({ prevUp: false })], { state });
84
+ assert.deepEqual(rounds.map((n) => n.length), [0, 1, 1, 0, 1]);
85
+ assert.equal(rounds[4][0].kind, 'down');
86
+ });
87
+
88
+ t('the fleet guard collapses a mass fall into ONE message and never fans out', () => {
89
+ const state = new Map();
90
+ const fleet = Array.from({ length: FLEET_GUARD }, (_, i) => ({ id: i + 1, slug: `p${i + 1}`, isPublic: true, up: false, prevUp: true }));
91
+ assert.equal(planNotices(fleet, state).notices.length, 0, 'still unconfirmed on the first sweep');
92
+ const second = planNotices(fleet.map((r) => ({ ...r, prevUp: false })), state);
93
+ assert.equal(second.notices.length, 1);
94
+ assert.equal(second.suppressed, FLEET_GUARD);
95
+ assert.match(second.notices[0].text, new RegExp(`${FLEET_GUARD} projects`));
96
+ assert.doesNotMatch(second.notices[0].text, /p1|p2/, 'a fleet-wide message names no project');
97
+ // …and the recovery collapses the same way, rather than posting one per project.
98
+ const back = planNotices(fleet.map((r) => ({ ...r, up: true, prevUp: false })), state);
99
+ assert.equal(back.notices.length, 1);
100
+ assert.equal(back.notices[0].kind, 'up');
101
+ assert.equal(state.size, 0);
102
+ });
103
+
104
+ t('below the guard, each project still gets its own named notice', () => {
105
+ const state = new Map();
106
+ const few = Array.from({ length: FLEET_GUARD - 1 }, (_, i) => ({ id: i + 1, slug: `p${i + 1}`, isPublic: true, up: false, prevUp: true }));
107
+ planNotices(few, state);
108
+ const out = planNotices(few.map((r) => ({ ...r, prevUp: false })), state);
109
+ assert.equal(out.notices.length, few.length);
110
+ assert.equal(out.suppressed, 0);
111
+ assert.match(out.notices[0].text, /p1/);
112
+ });
113
+
114
+ console.log('\nnoticeText (owner-audience copy):');
115
+
116
+ t('a public project is named; a non-public one never is', () => {
117
+ const named = noticeText({ kind: 'down', name: 'mercury' });
118
+ assert.match(named, /mercury/);
119
+ const hidden = noticeText({ kind: 'down', name: null });
120
+ assert.doesNotMatch(hidden, /mercury|null|undefined/);
121
+ assert.match(hidden, /project/i);
122
+ });
123
+
124
+ t('the copy carries no jargon and no internals', () => {
125
+ for (const kind of ['down', 'up']) {
126
+ for (const name of ['mercury', null]) {
127
+ const text = noticeText({ kind, name, count: 4 });
128
+ assert.doesNotMatch(text, /healthz|probe|ECONNREFUSED|http \d|instance|sweep|poller|null|undefined/i, `${kind}/${name}: ${text}`);
129
+ assert.ok(text.length > 20 && text.length < 320, 'one short paragraph');
130
+ }
131
+ }
132
+ });
133
+
134
+ t('a non-public project is not named in the sweep observation either', () => {
135
+ const state = new Map();
136
+ const row = { id: 9, slug: 'secret-co', isPublic: false, up: false, prevUp: true };
137
+ planNotices([row], state);
138
+ const out = planNotices([{ ...row, prevUp: false }], state);
139
+ assert.equal(out.notices.length, 1);
140
+ assert.doesNotMatch(out.notices[0].text, /secret-co/);
141
+ });
142
+
143
+ console.log('\nsweepOnce (the notices ride the sweep):');
144
+
145
+ // A fake db that answers the sweep's two statements, with the prior value the
146
+ // edge rules read. `up` decides what the probe will find.
147
+ function fakeDb(rows, priorUp = new Map()) {
148
+ return {
149
+ query: async (sql, params) => {
150
+ if (/FROM provisioning_instances/.test(sql) && /WHERE status = 'active'/.test(sql)) return { rows };
151
+ if (/UPDATE provisioning_instances/.test(sql)) {
152
+ const prev = priorUp.has(params[0]) ? priorUp.get(params[0]) : true;
153
+ priorUp.set(params[0], params[1]);
154
+ return { rows: [{ id: params[0], last_verified_at: new Date(), last_verified_up: params[1], prev_up: prev }] };
155
+ }
156
+ throw new Error(`unexpected SQL: ${sql}`);
157
+ },
158
+ };
159
+ }
160
+
161
+ await ta('a down instance posts one notice on the confirming sweep, through the injected broadcaster', async () => {
162
+ liveness.resetNoticeState();
163
+ const rows = [{ id: 1, slug: 'mercury', domain: null, port: 1, settings: {} }];
164
+ const db = fakeDb(rows);
165
+ const posted = [];
166
+ const deps = { pool: db, log: () => {}, timeoutMs: 50, notify: (n) => posted.push(n), fetchImpl: async () => { throw new Error('ECONNREFUSED'); } };
167
+ await liveness.sweepOnce(deps);
168
+ assert.equal(posted.length, 0, 'the first down is not yet an outage');
169
+ await liveness.sweepOnce(deps);
170
+ assert.equal(posted.length, 1);
171
+ assert.equal(posted[0].kind, 'down');
172
+ await liveness.sweepOnce(deps);
173
+ assert.equal(posted.length, 1, 'and it stays quiet');
174
+ });
175
+
176
+ await ta('a broadcaster that throws cannot stall a sweep tick', async () => {
177
+ liveness.resetNoticeState();
178
+ const rows = [{ id: 1, slug: 'mercury', domain: null, port: 1, settings: {} }];
179
+ const db = fakeDb(rows);
180
+ const deps = { pool: db, log: () => {}, timeoutMs: 50, notify: () => { throw new Error('discord is down'); }, fetchImpl: async () => { throw new Error('ECONNREFUSED'); } };
181
+ await liveness.sweepOnce(deps);
182
+ const out = await liveness.sweepOnce(deps);
183
+ assert.equal(out.down, 1, 'the sweep still reports what it saw');
184
+ });
185
+
186
+ await ta('a gated project is never named in what the sweep posts', async () => {
187
+ liveness.resetNoticeState();
188
+ const rows = [{ id: 7, slug: 'secret-co', domain: null, port: 1, settings: { visibility: 'private' } }];
189
+ const db = fakeDb(rows);
190
+ const posted = [];
191
+ const deps = { pool: db, log: () => {}, timeoutMs: 50, notify: (n) => posted.push(n), fetchImpl: async () => { throw new Error('ECONNREFUSED'); } };
192
+ await liveness.sweepOnce(deps);
193
+ await liveness.sweepOnce(deps);
194
+ assert.equal(posted.length, 1);
195
+ assert.doesNotMatch(posted[0].text, /secret-co/);
196
+ });
197
+
198
+ console.log(`\nprovisioning liveness notice: ${passed} passed, ${failed} failed`);
199
+ process.exitCode = failed ? 1 : 0;
@@ -59,10 +59,11 @@ function fakeDb(rows) {
59
59
  return {
60
60
  updates,
61
61
  query: async (sql, params) => {
62
- if (/SELECT id, slug, domain, port FROM provisioning_instances/.test(sql)) return { rows };
63
- if (/UPDATE provisioning_instances/.test(sql)) {
62
+ if (/FROM provisioning_instances/.test(sql) && /WHERE status = 'active'/.test(sql)) return { rows };
63
+ if (/^UPDATE provisioning_instances/.test(sql)) {
64
64
  updates.push({ sql, id: params[0], up: params[1] });
65
- return { rows: [{ id: params[0], last_verified_at: new Date(), last_verified_up: params[1] }] };
65
+ // prev_up rides the write (task 1002894) — the notice reads the value replaced.
66
+ return { rows: [{ id: params[0], last_verified_at: new Date(), last_verified_up: params[1], prev_up: true }] };
66
67
  }
67
68
  throw new Error(`unexpected SQL: ${sql}`);
68
69
  },
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.840",
3
+ "version": "1.19.842",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.840",
9
+ "version": "1.19.842",
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.840",
3
+ "version": "1.19.842",
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",
@@ -80,6 +80,7 @@ const PUBLISH_ALLOWLIST = [
80
80
  'config/design-tokens.schema.json', // the design-token contract (generic schema, no instance values)
81
81
  'config/hierarchy.neutral.json',
82
82
  'config/government.neutral.json', // the starting rank templates a fresh instance inherits (BV1.R96; renamed from governance.neutral.json by ADR 0174)
83
+ 'config/scheduled-routines.json', // the routine SWITCHBOARD (ADR 0115) — per routine: enabled, cadence, requiresAutonomy. It carries no *.neutral.json twin because it IS the neutral starter: ADR 0115's contract is that "a fresh instance inherits this whole set from the core template", and the operator then opts in/out HERE. PUBLISHES rather than topology-excludes for that reason — a released core without it would hand a new instance an empty scheduler and no record of what the routines under .claude/scheduled-tasks/ are for. It reached this list when task 1002500 registered it as a protected surface, which forces the choice; before that it was default-denied by silence, which is the accident that rule exists to catch.
83
84
  // --- the portable methodology core, governance, and sanitized docs ---
84
85
  'CLAUDE.md',
85
86
  'README.md',
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.840'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.842'; // 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');
@@ -0,0 +1,132 @@
1
+ // tests/agents_roster.mjs — the SHIPPED roster in `.claude/agents/` (task
2
+ // 1002496, goal 1000095).
3
+ //
4
+ // The definitions are prose files, so nothing about them is checked by the type
5
+ // system, and the sync that imports them is fail-open at deploy (a bad file makes
6
+ // the registry smaller, never aborts the deploy — tests/agents_sync.mjs pins
7
+ // that). The consequence is that a malformed definition is INVISIBLE until
8
+ // someone goes looking in the registry for an agent that quietly never imported.
9
+ // This file is the guard: every roster file must survive the REAL parser and the
10
+ // REAL validator, not a hand-built object that resembles one.
11
+ //
12
+ // Tested at the round-trip altitude on purpose. A test over parseAgentFile alone
13
+ // passes on a key the validator later drops, which is exactly the defect task
14
+ // 1004007 records: a top-level `touches:` validates ok and is silently normalized
15
+ // away. Parse and validate together, or the test agrees with the bug.
16
+ //
17
+ // In tests/ rather than modules/agents/tests/ for the same reason as
18
+ // tests/agents_sync.mjs: the module is default:false and module-local tests are
19
+ // discovered only when the module is ENABLED, so they would never run here.
20
+ //
21
+ // Run: node tests/agents_roster.mjs
22
+
23
+ import { strict as assert } from 'node:assert';
24
+ import { createRequire } from 'node:module';
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+
28
+ const require = createRequire(import.meta.url);
29
+ const sync = require('../scripts/gds/agents-sync.js');
30
+ const validate = require('../modules/agents/lib/validate.js');
31
+
32
+ const validateDefinition = validate.validateAgentDefinition || validate;
33
+
34
+ let passed = 0;
35
+ let failed = 0;
36
+ function t(name, fn) {
37
+ try { fn(); console.log(` PASS ${name}`); passed += 1; }
38
+ catch (err) { console.log(` FAIL ${name}\n ${err.message}`); failed += 1; }
39
+ }
40
+
41
+ const ROSTER_DIR = new URL('../.claude/agents/', import.meta.url);
42
+ const files = fs.readdirSync(ROSTER_DIR)
43
+ .filter((f) => f.endsWith('.md'))
44
+ .sort();
45
+
46
+ // Parse once, reuse across the cases below. The name comes from the FILENAME,
47
+ // which is how agents-sync identifies a file-sourced definition.
48
+ const roster = files.map((f) => {
49
+ const name = f.replace(/\.md$/, '');
50
+ const text = fs.readFileSync(new URL(f, ROSTER_DIR), 'utf8');
51
+ return { name, file: f, candidate: sync.parseAgentFile(text, { name }) };
52
+ });
53
+
54
+ console.log('every shipped definition survives parse + validate:');
55
+
56
+ // An empty roster would pass every per-file assertion below by vacuity — the
57
+ // failure mode where a green suite means "nothing ran". Pin the floor.
58
+ t('the roster is not empty', () => {
59
+ assert.ok(roster.length > 0, '.claude/agents/ holds no definitions — a vacuous pass');
60
+ });
61
+
62
+ for (const { name, file, candidate } of roster) {
63
+ t(`${file} validates against the real validator`, () => {
64
+ // authorRank archon so a scoped definition is judged on its shape rather
65
+ // than refused for rank; allowedModules mirrors what the sync passes.
66
+ const verdict = validateDefinition(candidate, {
67
+ authorRank: 'archon',
68
+ allowedModules: ['agents'],
69
+ protectedModules: [],
70
+ });
71
+ assert.ok(
72
+ verdict.ok,
73
+ `${file} would not import: ${JSON.stringify(verdict.errors)}`,
74
+ );
75
+ });
76
+
77
+ t(`${file} carries a persona worth sending`, () => {
78
+ // The BODY is the persona, and it is sent to a paid model on every fire. A
79
+ // file whose frontmatter parsed but whose body did not is the quiet failure
80
+ // this catches — it validates as a string and says nothing.
81
+ const persona = String(candidate.persona || '');
82
+ assert.ok(persona.length > 200, `${file}: persona is ${persona.length} chars — too thin to be a role prompt`);
83
+ assert.match(persona, new RegExp(name.replace(/-/g, '[- ]')), `${file}: the persona never names the agent it defines`);
84
+ });
85
+ }
86
+
87
+ console.log('\nthe on-demand roster this task shipped:');
88
+
89
+ t('historian, planner and onboarding-buddy are all present', () => {
90
+ const names = roster.map((r) => r.name);
91
+ for (const want of ['historian', 'planner', 'onboarding-buddy']) {
92
+ assert.ok(names.includes(want), `${want} is missing from the roster`);
93
+ }
94
+ });
95
+
96
+ t('each of the three is on-demand', () => {
97
+ for (const want of ['historian', 'planner', 'onboarding-buddy']) {
98
+ const entry = roster.find((r) => r.name === want);
99
+ assert.equal(entry.candidate.trigger_type, 'on-demand',
100
+ `${want} must be on-demand — an event trigger here would fire on ships nobody asked it about`);
101
+ }
102
+ });
103
+
104
+ console.log('\nthe trap an event definition would walk into:');
105
+
106
+ // TRIPWIRE for task 1004007, and the reason it is HERE rather than in that task:
107
+ // this directory is where the mistake gets made. A file cannot express
108
+ // `trigger_spec.touches` today (agents-sync.js parses a flat frontmatter and
109
+ // folds only `event:` into the spec), and event-router.js:168 reads an absent
110
+ // filter as FIRES ON EVERY SHIP at roughly $0.90 a fire. So the first
111
+ // file-authored event agent is a standing bill, silently.
112
+ //
113
+ // This reds the moment someone adds one, which is the moment the warning is
114
+ // worth something. Once 1004007 lands, tighten it to assert the filter SURVIVED
115
+ // into trigger_spec rather than refusing event definitions outright.
116
+ t('no file-authored event agent until a file can declare its filter', () => {
117
+ const eventBacked = roster.filter((r) => r.candidate.trigger_type === 'event');
118
+ for (const entry of eventBacked) {
119
+ const spec = entry.candidate.trigger_spec;
120
+ const globs = spec && Array.isArray(spec.touches) ? spec.touches
121
+ : spec && Array.isArray(spec.touches_globs) ? spec.touches_globs
122
+ : [];
123
+ assert.ok(
124
+ globs.length > 0,
125
+ `${entry.file} is an event definition with no touches filter, so it would fire on EVERY ship (~$0.90 each). `
126
+ + 'A file cannot declare one until task 1004007 lands — see tests/agents_roster.mjs.',
127
+ );
128
+ }
129
+ });
130
+
131
+ console.log(`\nagents_roster: ${passed} passed, ${failed} failed`);
132
+ process.exit(failed ? 1 : 0);
@@ -17,6 +17,7 @@ import { test } from 'node:test';
17
17
  import { createRequire } from 'node:module';
18
18
  import { execFileSync } from 'node:child_process';
19
19
  import fs from 'node:fs';
20
+ import os from 'node:os';
20
21
  import path from 'node:path';
21
22
  import { fileURLToPath } from 'node:url';
22
23
 
@@ -226,6 +227,19 @@ const ADDED_SINCE_R101 = [
226
227
  + 'that computed set is expected. Registered because it is the code that puts the '
227
228
  + 'credential on the wire.',
228
229
  },
230
+ // task 1002500. The routine SWITCHBOARD — the counterpart to the hooks/settings entries
231
+ // in group 'automation', which protect how a session behaves but said nothing about what
232
+ // fires with no session at all. The routine-switch tripwire below recomputes this set
233
+ // from config/ by SHAPE, so a second switchboard cannot land unregistered.
234
+ {
235
+ after: '.claude/settings.json',
236
+ glob: 'config/scheduled-routines.json',
237
+ why: 'decides, per routine, enabled / cadence / requiresAutonomy. Arming '
238
+ + 'overnight-builder there starts an autonomous claim-and-ship loop; clearing '
239
+ + "main-audit's entry silences the post-merge backstop that catches a sub-Metic "
240
+ + 'edit which slipped past the pre-push hook and the grader — the one ADR 0043 '
241
+ + 'layer that does not depend on the builder cooperating.',
242
+ },
229
243
  ];
230
244
 
231
245
  // Surfaces RENAMED since R101 — still protected, under a new path. Distinct from
@@ -561,6 +575,173 @@ test('registering the credential readers widened no module wall', () => {
561
575
  + 'is not, the premise of the box.js entry has changed.');
562
576
  });
563
577
 
578
+ // ---------- the routine-switch tripwire: every SWITCHBOARD is a protected surface ----------
579
+ //
580
+ // task 1002500. Group 'automation' protected how a SESSION behaves — .claude/hooks/ and
581
+ // .claude/settings.json — and said nothing about what fires when no session exists at all.
582
+ // config/scheduled-routines.json is that file: per routine it carries enabled, cadence and
583
+ // requiresAutonomy, so one edit there arms overnight-builder (an autonomous claim-and-ship
584
+ // loop) or silences main-audit, which is the only ADR 0043 layer that does not depend on
585
+ // the builder cooperating.
586
+ //
587
+ // DETECTED BY SHAPE, NOT BY NAME. Naming the one file we know about today is the failure
588
+ // this project keeps re-learning (the audit's stale "gen-* trio" above, the filing task's
589
+ // wrong path for the Cloudflare reader): the registry would record the gap as closed while
590
+ // a second switchboard lands beside it unprotected. So the set is recomputed from config/
591
+ // on every run, and an entry whose values decide whether-and-how-often a routine runs must
592
+ // be registered or this reds.
593
+ //
594
+ // WHY ONLY THE SWITCHBOARD, NOT THE ROUTINE BODIES. .claude/scheduled-tasks/ is the
595
+ // autonomy module's source surface, and `protected` DERIVES per module from overlap with
596
+ // this registry — so registering the bodies would rank-wall the whole autonomy module and
597
+ // every goal scoped to it. That is a policy decision with three deliberate edits attached
598
+ // (registry, publish-manifest carve-out, the module-scope-map roster), exactly as recorded
599
+ // for .claude/agents/ at the head of this file. Protecting the switchboard gates who may
600
+ // ARM a routine; it deliberately says nothing about who may edit what one does.
601
+ const CONFIG_DIR = path.join(ROOT, 'config');
602
+
603
+ // Does this JSON declare routine SWITCHES — entries deciding whether a routine runs and
604
+ // how often? True for an object carrying `requiresAutonomy`, or `enabled` alongside a
605
+ // `cadence`. Recursive so a flat manifest is caught as readily as the nested `routines`
606
+ // map today; `enabled` ALONE is deliberately not enough, or every feature-flag file in
607
+ // config/ would match and the tripwire would mean nothing.
608
+ //
609
+ // MAX_SWITCH_DEPTH is the deepest nesting a switchboard is searched at. The shapes that
610
+ // exist are depth 0 (a flat `{ "<routine>": {...} }` map, where the entry sits one level
611
+ // in) and depth 1 (today's `{ "routines": { "<routine>": {...} } }`). Three is that plus a
612
+ // level of headroom, and it is a bound rather than a guess: the cost of it being too low
613
+ // is a switchboard buried deeper than any we write going unnoticed, and the cost of
614
+ // removing it is walking arbitrarily deep into unrelated config for a key that only ever
615
+ // appears near the top. Raise it and the tripwire still holds; the negative test below is
616
+ // what would catch a raise that starts matching ordinary config.
617
+ const MAX_SWITCH_DEPTH = 3;
618
+
619
+ function declaresRoutineSwitches(node, depth = 0) {
620
+ if (!node || typeof node !== 'object' || Array.isArray(node) || depth > MAX_SWITCH_DEPTH) return false;
621
+ if (typeof node.requiresAutonomy === 'boolean') return true;
622
+ if (typeof node.enabled === 'boolean' && typeof node.cadence === 'string') return true;
623
+ return Object.values(node).some((v) => declaresRoutineSwitches(v, depth + 1));
624
+ }
625
+
626
+ // Every config/*.json that declares routine switches, as repo-relative paths.
627
+ function routineSwitchManifests(dir = CONFIG_DIR) {
628
+ const out = [];
629
+ for (const name of fs.readdirSync(dir).sort()) {
630
+ if (!name.endsWith('.json')) continue;
631
+ let doc;
632
+ try { doc = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8')); } catch (_) { continue; }
633
+ if (declaresRoutineSwitches(doc)) out.push(`config/${name}`);
634
+ }
635
+ return out;
636
+ }
637
+
638
+ test('declaresRoutineSwitches detects a switchboard in both shapes', () => {
639
+ assert.equal(declaresRoutineSwitches({ routines: { a: { enabled: true, cadence: 'daily' } } }), true,
640
+ 'the nested shape config/scheduled-routines.json uses today');
641
+ assert.equal(declaresRoutineSwitches({ 'overnight-builder': { requiresAutonomy: true } }), true,
642
+ 'a FLAT manifest — the shape a second switchboard is most likely to arrive in');
643
+ assert.equal(declaresRoutineSwitches({ a: { b: { c: { requiresAutonomy: false } } } }), true,
644
+ 'requiresAutonomy:false still declares the switch — the value is policy, the key is the shape');
645
+ });
646
+
647
+ test('declaresRoutineSwitches does not fire on a near-miss', () => {
648
+ assert.equal(declaresRoutineSwitches({ game: false, discord: true }), false,
649
+ 'a feature-flag map is not a routine switchboard');
650
+ assert.equal(declaresRoutineSwitches({ mod: { enabled: true } }), false,
651
+ 'enabled ALONE is not enough — it must decide how often something fires');
652
+ assert.equal(declaresRoutineSwitches({ chart: { cadence: 'weekly' } }), false,
653
+ 'a cadence with nothing enabling it is prose, not a switch');
654
+ assert.equal(declaresRoutineSwitches(null), false);
655
+ assert.equal(declaresRoutineSwitches([{ enabled: true, cadence: 'daily' }]), false,
656
+ 'an array is not an entry map');
657
+ });
658
+
659
+ // The near-miss test above uses hand-built objects, which only proves the detector rejects
660
+ // the shapes I thought to write. This proves the negative against the REAL directory: of
661
+ // the config files actually in the tree, exactly one is a switchboard. Without it, a
662
+ // detector that matched everything in config/ would still pass the tripwire (every other
663
+ // file would simply have to be registered too, and the failure would read as a coverage
664
+ // gap rather than as an over-broad detector).
665
+ test('exactly one file in the real config/ is a switchboard — the rest do not match', () => {
666
+ const all = fs.readdirSync(CONFIG_DIR).filter((n) => n.endsWith('.json')).sort();
667
+ assert.ok(all.length >= 5,
668
+ `fixture sanity: config/ should hold several json files, found ${all.length}`);
669
+ const matched = routineSwitchManifests();
670
+ assert.deepEqual(matched, ['config/scheduled-routines.json'],
671
+ `the detector matched ${matched.length} of the ${all.length} files in config/. One is `
672
+ + 'correct. More means the shape test is too loose — a feature-flag or token file is '
673
+ + 'being read as a routine switchboard, which would turn this tripwire into a demand '
674
+ + 'to rank-wall ordinary config. Scanned: ' + all.join(', '));
675
+ });
676
+
677
+ test('every routine switchboard in config/ is a protected surface (1002500 tripwire)', () => {
678
+ const boards = routineSwitchManifests();
679
+ assert.ok(boards.includes('config/scheduled-routines.json'),
680
+ 'fixture sanity: the known switchboard must be detected, or this tripwire is vacuous '
681
+ + '— the failure mode where a scan passes by quietly finding nothing');
682
+ for (const rel of boards) {
683
+ const surface = ppc.surfaceFor(rel);
684
+ assert.ok(surface,
685
+ `${rel} decides whether scheduled routines run and how often — arming one starts `
686
+ + 'unattended work, silencing one removes a gate — but no protected surface covers '
687
+ + 'it. Add it to modules/government/protected-surfaces.json (group "automation") and '
688
+ + 'declare it in ADDED_SINCE_R101 above.');
689
+ assert.ok(ppc.RANK_TIER[surface.floor] >= ppc.RANK_TIER.metic,
690
+ `${rel} is a routine switchboard floored at '${surface.floor}' — below Metic it `
691
+ + 'stays writable by the ranks ADR 0043 keeps out of the pipeline.');
692
+ }
693
+ });
694
+
695
+ // The tripwire above passes today because the one switchboard IS registered — which is
696
+ // also what it would look like if it were broken and finding nothing. This proves the RED
697
+ // path without mutating the shipped registry: a switchboard that is not in it must be
698
+ // detected AND must resolve to no protected surface, which is exactly the pair the loop
699
+ // above turns into a failure.
700
+ test('the tripwire fires on an UNREGISTERED switchboard', () => {
701
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bongos-switchboard-probe-'));
702
+ try {
703
+ fs.writeFileSync(path.join(dir, 'unrelated.json'), JSON.stringify({ game: false }));
704
+ fs.writeFileSync(path.join(dir, 'rogue-routines.json'),
705
+ JSON.stringify({ routines: { 'overnight-builder': { enabled: true, cadence: 'nightly', requiresAutonomy: true } } }));
706
+ const found = routineSwitchManifests(dir);
707
+ assert.deepEqual(found, ['config/rogue-routines.json'],
708
+ 'the detector must find the rogue switchboard and ignore the feature-flag file');
709
+ assert.equal(ppc.surfaceFor('config/rogue-routines.json'), null,
710
+ 'an unregistered config path must resolve to no surface — the falsy half the '
711
+ + 'tripwire asserts on. If this ever returns a surface, a config/ subtree glob was '
712
+ + 'added and the tripwire can no longer distinguish registered from unregistered.');
713
+ } finally {
714
+ fs.rmSync(dir, { recursive: true, force: true });
715
+ }
716
+ });
717
+
718
+ test('registering the switchboard widened no module wall', () => {
719
+ // The same second-order effect asserted for the credential readers above. config/ is
720
+ // kernel territory (module-scope-map's kernel key), and kernel was already protected via
721
+ // src/bongos/auth.js and db.js — so this entry must change nothing.
722
+ //
723
+ // `agents` is the module to watch, and the ONLY one this test can honestly cite.
724
+ // `autonomy` — which owns .claude/scheduled-tasks/, the routine bodies — was ALREADY
725
+ // protected before this entry, through scripts/gds/conductor-dispatch.js in group
726
+ // 'pipeline' (verified glob by glob: none of its other five globs is protected). So its
727
+ // flag is not evidence either way here, and asserting it unprotected would have been a
728
+ // test that fails for a reason unrelated to what it claims to check.
729
+ const map = require(path.join(ROOT, 'src', 'bongos', 'module-scope-map.js'));
730
+ const protectedNow = map.protectedModules();
731
+ assert.ok(protectedNow.includes('kernel'),
732
+ 'kernel owns config/ and was already protected before this entry; if it is not, the '
733
+ + 'premise that registering a config/ file widens nothing has changed.');
734
+ assert.ok(!protectedNow.includes('agents'),
735
+ 'the agents module derived protected:true — a routine entry reached modules/agents/ '
736
+ + 'or .claude/agents/, which rank-walls every goal scoped to it. Protect the '
737
+ + 'SWITCHBOARD, not the agent or routine sources; walling that module is the separate '
738
+ + 'policy decision recorded for .claude/agents/ at the head of this file.');
739
+ assert.ok(ppc.surfaceFor('scripts/gds/conductor-dispatch.js'),
740
+ 'the autonomy module was protected through conductor-dispatch.js before this entry. '
741
+ + 'If that glob is gone, re-derive which modules this registry walls before trusting '
742
+ + 'the assertion above.');
743
+ });
744
+
564
745
  // Metic is a FLOOR, not a fixed value (task 1003191). Pinning every surface to
565
746
  // exactly 'metic' made the registry's own contract untestable and, worse,
566
747
  // illegal: governance owns this data and raising one surface to 'archon' is a