@bongos/core 1.19.1000 → 1.19.1002

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.1000",
6
- "core_contract": "1.19.1000",
7
- "source_commit": "418cf03d39cecaf2d846caa21fdb61edb6d8b0f3",
5
+ "core_version": "1.19.1002",
6
+ "core_contract": "1.19.1002",
7
+ "source_commit": "e0937954f00712025cbc3f2c9b8a400a35654037",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-24T01:30:19.307Z",
9
+ "built_at": "2026-09-24T15:24:05.587Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 521,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2408,
14
+ "functional_verbatim": 2410,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2955,
20
- "tree_sha256": "7199a5097f99b2342ed5b37fc07050fa409594d5d006e6a72c3228e7b3f2f7dd",
19
+ "file_count": 2957,
20
+ "tree_sha256": "8a53ccc546dbeb0297ea0095d653db33cedcebd26e566f28b616cbd0e7cf980c",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2157,12 +2157,12 @@
2157
2157
  {
2158
2158
  "path": "docs/api-reference.md",
2159
2159
  "mode": "0000644",
2160
- "sha256": "a92aafc05e0bf1434d96a9032b5b2d4b1d13177d9969ee02d9f6287777a64df7"
2160
+ "sha256": "20ab80e06f03ad1c1f1f33e3f804d402d011bca4c7b2b9df0090269958009d92"
2161
2161
  },
2162
2162
  {
2163
2163
  "path": "docs/api/openapi.json",
2164
2164
  "mode": "0000644",
2165
- "sha256": "36beb96ae7c9b5a49e02252ff10b222cfae14224eaf5ed639d7515f36a3f1f73"
2165
+ "sha256": "51f4a9dd2fcd6d34bd3eaa08285acf9b7c9c1ccc294d1e616c1338607701439e"
2166
2166
  },
2167
2167
  {
2168
2168
  "path": "docs/architecture.md",
@@ -2722,7 +2722,7 @@
2722
2722
  {
2723
2723
  "path": "docs/module-api-changelog.md",
2724
2724
  "mode": "0000644",
2725
- "sha256": "a9c208d7638a739dbbe12a7b1ee4a52e4b3c4ad047a043ccf5e3e4ae262eb805"
2725
+ "sha256": "9fa60f085e9a51dea701f575ca3f5ed132c2deec0c5a311b46d8bc5dec1079e0"
2726
2726
  },
2727
2727
  {
2728
2728
  "path": "docs/modules-contract.md",
@@ -6634,6 +6634,11 @@
6634
6634
  "mode": "0000644",
6635
6635
  "sha256": "d2ff6733aaab0c009cfe2e90e756fa9742193dba7c5078c5e1d5fa2fd6ef6e8a"
6636
6636
  },
6637
+ {
6638
+ "path": "modules/provisioning/cost-ledger.js",
6639
+ "mode": "0000644",
6640
+ "sha256": "0f6a25220ce3786ba3d813bcba2e39ef902aa25ed4420264efaca36b9ddb3432"
6641
+ },
6637
6642
  {
6638
6643
  "path": "modules/provisioning/credential-preflight.js",
6639
6644
  "mode": "0000644",
@@ -6827,7 +6832,7 @@
6827
6832
  {
6828
6833
  "path": "modules/provisioning/provisioning.js",
6829
6834
  "mode": "0000644",
6830
- "sha256": "5c409a38ac511fe0fa331a5f012c4eda8598d7e8532070e13a42ba707f934f27"
6835
+ "sha256": "fba7da2883254cc0f7f2eafee6c437aeac00f97fb1f8a35acade67e48c6a6eff"
6831
6836
  },
6832
6837
  {
6833
6838
  "path": "modules/provisioning/rate-limit.js",
@@ -6872,7 +6877,7 @@
6872
6877
  {
6873
6878
  "path": "modules/provisioning/routes/provisioning.js",
6874
6879
  "mode": "0000644",
6875
- "sha256": "7738dad310a0de636739fecd9d98036c1d9e4381f7e972337fa8c01d59ccfe9c"
6880
+ "sha256": "6f123553f9fd1f3d48f5356dcddb628c78660c10615f3d7e137f71bf2e7d0750"
6876
6881
  },
6877
6882
  {
6878
6883
  "path": "modules/provisioning/routes/repo-private.js",
@@ -8202,12 +8207,12 @@
8202
8207
  {
8203
8208
  "path": "package-lock.json",
8204
8209
  "mode": "0000644",
8205
- "sha256": "eb742b3a898d8b78fc95893d284f6db4151a98f35ddc81a131f16ff586466a98"
8210
+ "sha256": "ce35548a4758316756e143ba9b49e1d860899d125a16a4b92ab1569174f5f3f0"
8206
8211
  },
8207
8212
  {
8208
8213
  "path": "package.json",
8209
8214
  "mode": "0000644",
8210
- "sha256": "130f3e4d93f2846e676c68bd54fb3a318311dfc0912ffa48994fcb2b941a2dfd"
8215
+ "sha256": "eb8f4069bbf120c7d6322a857238a6c5fba334c8c2112738fcc97460e29bcbef"
8211
8216
  },
8212
8217
  {
8213
8218
  "path": "public-docs/index.html",
@@ -8857,7 +8862,7 @@
8857
8862
  {
8858
8863
  "path": "scripts/gds/gen-file-map.js",
8859
8864
  "mode": "0000644",
8860
- "sha256": "a8531787384fc528eb75dde142ca8d24982e47e226c934d6e692c53e6e738a0a"
8865
+ "sha256": "ac2308077104c2ff899f4b12ff9da7baa965a9c0d3e4ef2f92e6bdf9d3613fb6"
8861
8866
  },
8862
8867
  {
8863
8868
  "path": "scripts/gds/gen-freshness.js",
@@ -10252,7 +10257,7 @@
10252
10257
  {
10253
10258
  "path": "src/module-api.js",
10254
10259
  "mode": "0000644",
10255
- "sha256": "24c5dcae4a5e4be1d9c543791a1be89713e71e8f06150e414463e6064628e6b9"
10260
+ "sha256": "7be77918a0d109201d7c85c399d36f8f88473314c8c9a433a8d08ff7db44776a"
10256
10261
  },
10257
10262
  {
10258
10263
  "path": "src/module-loader/catalog.js",
@@ -11517,7 +11522,7 @@
11517
11522
  {
11518
11523
  "path": "tests/file_map.mjs",
11519
11524
  "mode": "0000644",
11520
- "sha256": "e426eea6852d2ea63b79d27e17b71c53031ca65ee22644192eb4807df26f7482"
11525
+ "sha256": "c6a7da467336246652fae6db5b5d7faa147e71ac1db89a66ec143dcf03bf8013"
11521
11526
  },
11522
11527
  {
11523
11528
  "path": "tests/filing_abuse_matrix.mjs",
@@ -13434,6 +13439,11 @@
13434
13439
  "mode": "0000644",
13435
13440
  "sha256": "406ea4d32fe1bbb7aa1f69e0c4dc967feb2fc43c8bf0f70848547fdc6faf8762"
13436
13441
  },
13442
+ {
13443
+ "path": "tests/provisioning_cost_ledger.mjs",
13444
+ "mode": "0000644",
13445
+ "sha256": "a0176f26c37067860379df4da0274646771c7d6c427d84fad0145763c7366152"
13446
+ },
13437
13447
  {
13438
13448
  "path": "tests/provisioning_credential_preflight.mjs",
13439
13449
  "mode": "0000644",
@@ -12819,7 +12819,7 @@
12819
12819
  "provisioning"
12820
12820
  ],
12821
12821
  "summary": "GET /provisioning/fleet/cost-ledger",
12822
- "description": "GET /provisioning/fleet/cost-ledger — per-instance monthly estimate + total. rank: archon — cost oversight across the fleet (the /boxes/cost-ledger precedent). Declared among the archon routes (archon-first) for correct auditor classification.\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `provisioning.fleet.manage` (all required).",
12822
+ "description": "GET /provisioning/fleet/cost-ledger — per-instance monthly cost to us + estimated cost to its owner, with totals. rank: archon — cost oversight across the fleet (the /boxes/cost-ledger precedent). Declared among the archon routes (archon-first) for correct auditor classification.\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `provisioning.fleet.manage` (all required).",
12823
12823
  "x-rank": "metic+archon",
12824
12824
  "x-source": "modules/provisioning/routes/provisioning.js",
12825
12825
  "x-permissions": [
@@ -515,7 +515,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust
515
515
  |---|---|---|---|---|
516
516
  | GET | `/api/bongos/provisioning/control-plane` | `archon` | — | GET /provisioning/control-plane — "which row is this platform, and where does it stand?" The deploy page's first call: the self row is cr… |
517
517
  | GET | `/api/bongos/provisioning/fleet` | `metic+archon` | — | GET /provisioning/fleet — the full instance roster across all owners. |
518
- | GET | `/api/bongos/provisioning/fleet/cost-ledger` | `metic+archon` | — | GET /provisioning/fleet/cost-ledger — per-instance monthly estimate + total. |
518
+ | GET | `/api/bongos/provisioning/fleet/cost-ledger` | `metic+archon` | — | GET /provisioning/fleet/cost-ledger — per-instance monthly cost to us + estimated cost to its owner, with totals. |
519
519
  | GET | `/api/bongos/provisioning/github-app/callback` | `public` | — | GET /provisioning/github-app/callback?code=&state= — GitHub redirects the owner's BROWSER here after they approve the App Manifest (task … |
520
520
  | GET | `/api/bongos/provisioning/github-app/status` | `public` | — | GET /provisioning/github-app/status?state=<> — the PUBLIC progress read the onboarding callback page polls (task 2096) to know when setup… |
521
521
  | GET | `/api/bongos/provisioning/health` | `public` | — | GET /provisioning/health — liveness + the core version this module mounted against. |
@@ -2474,5 +2474,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2474
2474
  landed since 1.19.998 with no explicit bump. run 35942048393. (task 1002620)
2475
2475
  1.19.1000 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2476
2476
  landed since 1.19.999 with no explicit bump. run 35943151697. (task 1002620)
2477
+ 1.19.1001 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2478
+ landed since 1.19.1000 with no explicit bump. run 36016738965. (task 1002620)
2479
+ 1.19.1002 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2480
+ landed since 1.19.1001 with no explicit bump. run 36019839845. (task 1002620)
2477
2481
  ---------------------------------------------------------------------------
2478
2482
  ```
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+ // The fleet cost ledger (GET /provisioning/fleet/cost-ledger) — VISIBILITY only (ADR 0111 §6).
3
+ //
4
+ // It asks two questions, one column each (task 1004148, ADR 0327). Before, one column
5
+ // asked both and reported a confident $0 for every project:
6
+ // platform_monthly_usd — what a row costs US. A cloud-host runs on its owner's own
7
+ // account, so $0 is correct; the one thing we pay for is the control plane's box.
8
+ // owner_monthly_usd — an ESTIMATE of the owner's own hosting bill, never ours.
9
+ // null in either column means UNPRICED — the row names no plan we can price — and is
10
+ // never folded into a total as 0.
11
+
12
+ const { HOSTING_SHAPES, TIER_MONTHLY_USD } = require('./provisioning');
13
+
14
+ // What a cloud-host's owner pays Render per month, keyed by the plan stored in `tier`:
15
+ // web service + Postgres, ~$13 at the default plans (ADR 0327). No row names a plan yet;
16
+ // the Render standup (task 1004183) writes one, and the pilot (task 1004142) confirms
17
+ // these names and prices.
18
+ const RENDER_PLAN_MONTHLY_USD = Object.freeze({
19
+ 'starter+basic-256mb': 13,
20
+ });
21
+
22
+ function priceOf(table, key) {
23
+ return typeof key === 'string' && Object.hasOwn(table, key) ? table[key] : null;
24
+ }
25
+
26
+ function platformMonthlyUsd({ hostingShape, tier } = {}) {
27
+ // The box the projects share today is priced once, here, by its droplet size.
28
+ if (hostingShape === 'control-plane') return priceOf(TIER_MONTHLY_USD, tier);
29
+ return HOSTING_SHAPES.includes(hostingShape) ? 0 : null;
30
+ }
31
+
32
+ function ownerMonthlyUsd({ hostingShape, tier } = {}) {
33
+ if (hostingShape === 'cloud-host') return priceOf(RENDER_PLAN_MONTHLY_USD, tier);
34
+ // co-tenant shares our box and the control plane is ours: no owner is billed for either.
35
+ if (hostingShape === 'co-tenant' || hostingShape === 'control-plane') return 0;
36
+ return null; // byo-host runs on the owner's own machine, whose bill we cannot see
37
+ }
38
+
39
+ function columnTotal(instances, key) {
40
+ const priced = instances.map((i) => i[key]).filter((v) => v !== null);
41
+ const sum = priced.reduce((a, v) => a + v, 0);
42
+ return { monthly_usd: Number(sum.toFixed(4)), unpriced: instances.length - priced.length };
43
+ }
44
+
45
+ // Per-instance costs + a total per column, over the instances not torn down.
46
+ async function costLedger(db, { limit = 500 } = {}) {
47
+ const { rows } = await db.query(
48
+ `SELECT pi.id, pi.slug, pi.hosting_shape, pi.tier, pi.status, b.github_login
49
+ FROM provisioning_instances pi
50
+ JOIN builders b ON b.id = pi.owner_builder_id
51
+ WHERE pi.status <> 'torn_down'
52
+ ORDER BY pi.updated_at DESC
53
+ LIMIT $1`,
54
+ [Math.min(Math.max(Number(limit) || 500, 1), 2000)]
55
+ );
56
+ const instances = rows.map((r) => {
57
+ const shape = { hostingShape: r.hosting_shape, tier: r.tier };
58
+ return {
59
+ id: r.id, slug: r.slug, owner: r.github_login,
60
+ hosting_shape: r.hosting_shape, tier: r.tier, status: r.status,
61
+ platform_monthly_usd: platformMonthlyUsd(shape),
62
+ owner_monthly_usd: ownerMonthlyUsd(shape),
63
+ };
64
+ });
65
+ return {
66
+ instances,
67
+ totals: {
68
+ platform: columnTotal(instances, 'platform_monthly_usd'),
69
+ owner: columnTotal(instances, 'owner_monthly_usd'),
70
+ },
71
+ };
72
+ }
73
+
74
+ module.exports = { RENDER_PLAN_MONTHLY_USD, platformMonthlyUsd, ownerMonthlyUsd, costLedger };
@@ -307,9 +307,10 @@ function isValidSlug(slug) {
307
307
  // 'dedicated' was the only shape that priced above zero and provisioning_027 folded it into
308
308
  // 'cloud-host', so no row can carry it. ADR 0327 says a ledger of OUR spend "should read
309
309
  // ~$0 per cloud-host and be *correct*"; what an owner is worth telling is THEIR platform
310
- // cost, a different column — task 1004148. The 'dedicated' arm and the tier table stay
311
- // because this is pure and its tests call it with that value; the droplet code path is
312
- // task 1004185's to delete.
310
+ // cost, a different column — ownerMonthlyUsd in cost-ledger.js, which the fleet ledger
311
+ // reads instead of this stored value. The 'dedicated' arm and the tier table stay because
312
+ // this is pure and its tests call it with that value; the droplet code path is task
313
+ // 1004185's to delete.
313
314
  function costEstimateUsd({ hostingShape, tier } = {}) {
314
315
  if (hostingShape !== 'dedicated') return 0;
315
316
  // An ABSENT tier is not an unknown one. The runner substitutes its own default
@@ -1434,30 +1435,6 @@ async function planetChoicesForHosts(db, hosts) {
1434
1435
  return out;
1435
1436
  }
1436
1437
 
1437
- // Fleet cost ledger (Archon): per-instance monthly estimate + a total, over the
1438
- // instances that still cost something (not torn_down). VISIBILITY only (ADR 0111 §6).
1439
- async function costLedger(db, { limit = 500 } = {}) {
1440
- const { rows } = await db.query(
1441
- `SELECT pi.id, pi.slug, pi.hosting_shape, pi.tier, pi.status,
1442
- pi.cost_estimate_usd, b.github_login
1443
- FROM provisioning_instances pi
1444
- JOIN builders b ON b.id = pi.owner_builder_id
1445
- WHERE pi.status <> 'torn_down'
1446
- ORDER BY pi.cost_estimate_usd DESC, pi.updated_at DESC
1447
- LIMIT $1`,
1448
- [Math.min(Math.max(Number(limit) || 500, 1), 2000)]
1449
- );
1450
- const totalMonthlyUsd = rows.reduce((a, r) => a + Number(r.cost_estimate_usd || 0), 0);
1451
- return {
1452
- instances: rows.map((r) => ({
1453
- id: r.id, slug: r.slug, owner: r.github_login,
1454
- hosting_shape: r.hosting_shape, tier: r.tier, status: r.status,
1455
- cost_estimate_usd: Number(r.cost_estimate_usd || 0),
1456
- })),
1457
- total_monthly_usd: Number(totalMonthlyUsd.toFixed(4)),
1458
- };
1459
- }
1460
-
1461
1438
  module.exports = {
1462
1439
  // constants
1463
1440
  INSTANCE_STATES, HOSTING_SHAPES, ONBOARD_MODES, PROJECT_TYPES, INTENT_ACTIONS, TEARDOWN_FROM,
@@ -1488,7 +1465,7 @@ module.exports = {
1488
1465
  recordSettingsPushOwed,
1489
1466
  createInstance, setInstanceStatus, updateInstanceDomain, clearReleasedDomains, updateInstanceSettings, updateInstanceDetail,
1490
1467
  enqueueIntent, enqueueTeardown, enqueueSettingsApply, enqueueRestart, getOpenIntent, claimNextIntent, resolveIntent,
1491
- recordEvent, instanceStateForSlug, planetChoicesForHosts, projectOwnedBy, costLedger,
1468
+ recordEvent, instanceStateForSlug, planetChoicesForHosts, projectOwnedBy,
1492
1469
  // data access — GitHub App Manifest attempts (task 2080)
1493
1470
  createOAuthManifest, getOAuthManifestByState, attachManifestCode,
1494
1471
  getLatestOAuthManifestForInstance, claimNextOAuthManifest, resolveOAuthManifest,
@@ -29,7 +29,7 @@
29
29
  // GET /provisioning/instances/:id/invite-suggestions — who is worth inviting (R28, ADR 0251)
30
30
  // Archon-scoped (requireRank('archon') — the fleet, the /boxes precedent):
31
31
  // GET /provisioning/fleet — the full instance roster + the box's slot `capacity` (ADR 0285)
32
- // GET /provisioning/fleet/cost-ledger — per-instance + total cost estimate
32
+ // GET /provisioning/fleet/cost-ledger — per-instance cost to us + to its owner, with totals
33
33
  // POST /provisioning/instances/:id/force-teardown — tear down ANY instance
34
34
  // public:
35
35
  // GET /provisioning/health — liveness + core version
@@ -49,6 +49,7 @@ const express = require('express');
49
49
  const api = require('../../../src/module-api');
50
50
  const auth = api, { pool, validateOrRespond, parseId } = api;
51
51
  const provisioning = require('../provisioning');
52
+ const { costLedger } = require('../cost-ledger');
52
53
  const { onboardPlan, stepsForShape } = require('../onboard-plan');
53
54
  const { ALWAYS_ON_CORE, OPTIONAL_MODULES, starterBundleAnswerTable, optionalModules, effectiveModules } = require('../starter-bundles');
54
55
  const { recommendationsForDetails } = require('../recommendations');
@@ -214,14 +215,14 @@ module.exports = function provisioningRoutes() {
214
215
  }
215
216
  });
216
217
 
217
- // GET /provisioning/fleet/cost-ledger — per-instance monthly estimate + total.
218
+ // GET /provisioning/fleet/cost-ledger — per-instance monthly cost to us + estimated cost to its owner, with totals.
218
219
  // rank: archon — cost oversight across the fleet (the /boxes/cost-ledger precedent).
219
220
  // Declared among the archon routes (archon-first) for correct auditor classification.
220
221
  router.get('/provisioning/fleet/cost-ledger', auth.requireBuilder, auth.requirePermission('provisioning.fleet.manage'), async (req, res) => {
221
222
  try {
222
223
  // R14 (#2001 / ADR 0119): shared helper; per-endpoint cap preserved (default 500, max 2000).
223
224
  const { limit } = api.parsePagination(req, { defaultLimit: 500, maxLimit: 2000 });
224
- const out = await provisioning.costLedger(pool, { limit });
225
+ const out = await costLedger(pool, { limit });
225
226
  res.json(out);
226
227
  } catch (err) {
227
228
  log.error('[provisioning] GET /provisioning/fleet/cost-ledger', err);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1000",
3
+ "version": "1.19.1002",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1000",
9
+ "version": "1.19.1002",
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.1000",
3
+ "version": "1.19.1002",
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",
@@ -13,7 +13,7 @@
13
13
  // and .claude/scheduled-tasks/ — are now GENERATED from what is on disk. The per-entry one-line
14
14
  // notes (the prose after the ←, which no directory walk can know) live in a sidecar,
15
15
  // docs/file-map.notes.json, the ONE place a human writes them. The contract enforced by --check:
16
- // • every skill/routine dir on disk MUST have a note → a new skill can't land undescribed;
16
+ // • every TRACKED skill/routine dir MUST have a note → a new skill can't land undescribed;
17
17
  // • every note MUST map to a dir that exists → the map can't carry ghosts;
18
18
  // • the rendered block MUST match what's committed → no stale tree.
19
19
  // MODULE COVERAGE (task 1004066). The same honor system that let a skill land unmapped let a whole
@@ -41,6 +41,7 @@
41
41
 
42
42
  const fs = require('node:fs');
43
43
  const path = require('node:path');
44
+ const { execFileSync } = require('node:child_process');
44
45
  const { resolveInstanceRoot } = require('../../src/instance-config'); // ADR 0108 §1: configurable-root
45
46
  const { sameContent } = require('./gen-freshness'); // eol-insensitive freshness (CRLF checkouts)
46
47
 
@@ -87,6 +88,21 @@ function loadNotes() {
87
88
  const { readModuleSkillsManifest } = require('./claude-materialize.js');
88
89
  function materializedNames(absDir) { return new Set(Object.keys(readModuleSkillsManifest(absDir).skills)); }
89
90
 
91
+ // The subdirs of `relDir` holding at least one TRACKED file (task 1004213). An untracked one is the
92
+ // builder's own — a locally-installed plugin skill, or the empty husk a plugin extract leaves — and
93
+ // listing it would publish one machine's tooling into a shared doc on every post-merge regen.
94
+ // null when `root` is not the top of a git work tree (a packaged install, ADR 0108 §1): nothing to ask.
95
+ function trackedSubdirs(root, relDir) {
96
+ const git = (args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
97
+ try {
98
+ if (git(['rev-parse', '--show-prefix']).trim() !== '') return null;
99
+ return new Set(git(['ls-files', '-z', '--', relDir]).split('\0')
100
+ .map((p) => p.slice(relDir.length + 1).split('/'))
101
+ .filter((segs) => segs.length > 1)
102
+ .map((segs) => segs[0]));
103
+ } catch { return null; }
104
+ }
105
+
90
106
  // Immediate subdirectories of an absolute dir, sorted by name (deterministic); materialised
91
107
  // module copies excluded.
92
108
  function listSubdirs(absDir) {
@@ -103,7 +119,9 @@ function listSubdirs(absDir) {
103
119
  // classes the freshness contract enforces (a dir with no note; a note with no dir).
104
120
  function buildSection(section, notes) {
105
121
  const absDir = path.join(REPO_ROOT, section.dir);
106
- const names = listSubdirs(absDir);
122
+ const present = listSubdirs(absDir);
123
+ const tracked = trackedSubdirs(REPO_ROOT, section.dir);
124
+ const names = tracked ? present.filter((n) => tracked.has(n)) : present;
107
125
  const sectionNotes = (notes && notes[section.notesKey]) || {};
108
126
  const entries = names.map((name) => {
109
127
  // Skills + scheduled-tasks each carry a SKILL.md; label it explicitly when present (matches the
@@ -114,7 +132,8 @@ function buildSection(section, notes) {
114
132
  return { name, label, note };
115
133
  });
116
134
  const missing = entries.filter((e) => e.note == null).map((e) => `${section.dir}/${e.name}`);
117
- const onDisk = new Set(names);
135
+ // A note is a ghost only when its dir is gone from disk — one not yet `git add`ed is not.
136
+ const onDisk = new Set(present);
118
137
  const stale = Object.keys(sectionNotes).filter((k) => !onDisk.has(k)).map((k) => `${section.notesKey}.${k}`);
119
138
  return { ...section, absDir, entries, missing, stale };
120
139
  }
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.1000'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1002'; // 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');
@@ -3,7 +3,8 @@
3
3
  import assert from 'node:assert/strict';
4
4
  import { test } from 'node:test';
5
5
  import { execFileSync } from 'node:child_process';
6
- import { readFileSync, existsSync, readdirSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
6
+ import { readFileSync, existsSync, readdirSync, mkdirSync, writeFileSync, rmSync, mkdtempSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
7
8
  import { createRequire } from 'node:module';
8
9
  import { fileURLToPath } from 'node:url';
9
10
  import path from 'node:path';
@@ -40,8 +41,11 @@ test('EVERY skill + scheduled-task dir on disk is listed AND has a note — no s
40
41
  // A module skill materialised into this checkout (task 1003330) is an untracked copy the
41
42
  // module documents; the generator skips it by the manifest beside it, and so does this.
42
43
  const materialised = gen.materializedNames(path.join(ROOT, section.dir));
44
+ // An untracked dir is the builder's own (a plugin install, an empty husk — task 1004213).
45
+ const tracked = new Set(execFileSync('git', ['ls-files', '-z', '--', section.dir], { cwd: ROOT, encoding: 'utf8' })
46
+ .split('\0').map((p) => p.slice(section.dir.length + 1).split('/')).filter((s) => s.length > 1).map((s) => s[0]));
43
47
  const onDisk = readdirSync(path.join(ROOT, section.dir), { withFileTypes: true })
44
- .filter((e) => e.isDirectory() && !e.name.startsWith('.') && !materialised.has(e.name))
48
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.') && !materialised.has(e.name) && tracked.has(e.name))
45
49
  .map((e) => e.name)
46
50
  .sort();
47
51
  const listed = section.entries.map((e) => e.name).sort();
@@ -69,20 +73,68 @@ test('the generated blocks live between markers and carry one ← note per entry
69
73
  }
70
74
  });
71
75
 
72
- test('--check FAILS (exit 2) when a new skill has no note — a skill can no longer land undescribed', () => {
73
- const probe = path.join(ROOT, '.claude', 'skills', '__filemap_test__');
76
+ // A throwaway instance root (task 1004213). `new-skill` is staged, so git tracks it; `local-plugin`
77
+ // is untracked with a SKILL.md, as a real plugin install is; `empty-husk` is the bare dir a plugin
78
+ // extract leaves behind. The generator falls back to cwd for its root, so no env is needed.
79
+ function fixtureRoot({ git }) {
80
+ const root = mkdtempSync(path.join(tmpdir(), 'file-map-'));
81
+ const skills = path.join(root, '.claude', 'skills');
82
+ for (const n of ['new-skill', 'local-plugin']) {
83
+ mkdirSync(path.join(skills, n), { recursive: true });
84
+ writeFileSync(path.join(skills, n, 'SKILL.md'), `---\nname: ${n}\n---\n`);
85
+ }
86
+ mkdirSync(path.join(skills, 'empty-husk'));
87
+ if (git) {
88
+ execFileSync('git', ['init', '-q'], { cwd: root, stdio: 'ignore' });
89
+ execFileSync('git', ['add', '.claude/skills/new-skill'], { cwd: root, stdio: 'ignore' });
90
+ }
91
+ return root;
92
+ }
93
+ const skillsSectionAt = (cwd) => JSON.parse(execFileSync(process.execPath,
94
+ ['-e', `process.stdout.write(JSON.stringify(require(${JSON.stringify(GEN)}).buildModel()))`],
95
+ { cwd, encoding: 'utf8' })).find((s) => s.id === 'skills');
96
+ const skillNamesAt = (cwd) => skillsSectionAt(cwd).entries.map((e) => e.name);
97
+
98
+ test('--check FAILS (exit 2) when a new tracked skill has no note — a skill can no longer land undescribed', () => {
99
+ const root = fixtureRoot({ git: true });
74
100
  try {
75
- mkdirSync(probe, { recursive: true });
76
- writeFileSync(path.join(probe, 'SKILL.md'), '---\nname: __filemap_test__\n---\n');
77
- const r = run(['--check']);
101
+ let r;
102
+ try { execFileSync(process.execPath, [GEN, '--check'], { cwd: root, encoding: 'utf8', stdio: 'pipe' }); r = { code: 0, stderr: '' }; }
103
+ catch (e) { r = { code: e.status, stderr: String(e.stderr) }; }
78
104
  assert.equal(r.code, 2, 'an undescribed skill must fail the gate');
79
- assert.match(r.stderr, /notes MISSING/);
80
- assert.match(r.stderr, /__filemap_test__/);
105
+ assert.match(r.stderr, /notes MISSING for: \.claude\/skills\/new-skill(?! *,)/, 'the tracked skill, and only it, is missing a note');
106
+ assert.doesNotMatch(r.stderr, /local-plugin|empty-husk/);
81
107
  } finally {
82
- rmSync(probe, { recursive: true, force: true });
108
+ rmSync(root, { recursive: true, force: true });
109
+ }
110
+ });
111
+
112
+ test('an untracked skill dir is the builder\'s own — a plugin install and an empty husk never reach the map', () => {
113
+ const root = fixtureRoot({ git: true });
114
+ try {
115
+ assert.deepEqual(skillNamesAt(root), ['new-skill']);
116
+ // A note written before `git add` is not a ghost: its dir is on disk.
117
+ mkdirSync(path.join(root, 'docs'));
118
+ writeFileSync(path.join(root, 'docs', 'file-map.notes.json'), JSON.stringify({ skills: { 'local-plugin': 'x', gone: 'y' } }));
119
+ assert.deepEqual(skillsSectionAt(root).stale, ['skills.gone']);
120
+ } finally {
121
+ rmSync(root, { recursive: true, force: true });
122
+ }
123
+ });
124
+
125
+ test('outside a git work tree (a packaged install) every dir on disk is still listed', () => {
126
+ const root = fixtureRoot({ git: false });
127
+ const parent = fixtureRoot({ git: true });
128
+ const nested = path.join(parent, 'instance');
129
+ try {
130
+ assert.deepEqual(skillNamesAt(root), ['empty-husk', 'local-plugin', 'new-skill']);
131
+ // A root that is a SUBDIR of some repo is not that repo's top: its files are not the repo's to judge.
132
+ mkdirSync(path.join(nested, '.claude', 'skills', 'inner'), { recursive: true });
133
+ assert.deepEqual(skillNamesAt(nested), ['inner']);
134
+ } finally {
135
+ rmSync(root, { recursive: true, force: true });
136
+ rmSync(parent, { recursive: true, force: true });
83
137
  }
84
- // and clean again once removed
85
- assert.equal(run(['--check']).code, 0, 'gate is clean after the probe dir is removed');
86
138
  });
87
139
 
88
140
  test('generation is deterministic + idempotent (a second run is a no-op)', () => {
@@ -0,0 +1,98 @@
1
+ // tests/provisioning_cost_ledger.mjs — the fleet cost ledger asks two questions, one
2
+ // column each (task 1004148, ADR 0327): what a row costs US, and an estimate of what it
3
+ // costs its OWNER. A row neither can price reads null (unpriced), never 0 — the old
4
+ // single column reported a confident $0 for every project, with no way to tell
5
+ // "costs nothing" from "we don't know".
6
+ //
7
+ // DB-free: CI has no Postgres, so costLedger runs against a recording fake.
8
+ //
9
+ // Run: node tests/provisioning_cost_ledger.mjs
10
+ import { strict as assert } from 'node:assert';
11
+ import { makeRunner } from './helpers.mjs';
12
+ import {
13
+ platformMonthlyUsd, ownerMonthlyUsd, costLedger, RENDER_PLAN_MONTHLY_USD,
14
+ } from '../modules/provisioning/cost-ledger.js';
15
+
16
+ const { test, summary } = makeRunner();
17
+
18
+ const PLAN = Object.keys(RENDER_PLAN_MONTHLY_USD)[0];
19
+
20
+ await test('a cloud-host with no plan named is unpriced for its owner, not $0', () => {
21
+ assert.equal(ownerMonthlyUsd({ hostingShape: 'cloud-host', tier: null }), null);
22
+ assert.equal(ownerMonthlyUsd({ hostingShape: 'cloud-host' }), null);
23
+ assert.equal(ownerMonthlyUsd({ hostingShape: 'cloud-host', tier: '' }), null);
24
+ });
25
+
26
+ await test('a cloud-host on a named plan is priced from the plan table', () => {
27
+ assert.equal(ownerMonthlyUsd({ hostingShape: 'cloud-host', tier: PLAN }), RENDER_PLAN_MONTHLY_USD[PLAN]);
28
+ assert.ok(RENDER_PLAN_MONTHLY_USD[PLAN] > 0);
29
+ });
30
+
31
+ await test('an unrecognised plan is unpriced, including an Object.prototype key', () => {
32
+ assert.equal(ownerMonthlyUsd({ hostingShape: 'cloud-host', tier: 'pro-max+pro-64gb' }), null);
33
+ assert.equal(ownerMonthlyUsd({ hostingShape: 'cloud-host', tier: 'constructor' }), null);
34
+ assert.equal(platformMonthlyUsd({ hostingShape: 'control-plane', tier: 'toString' }), null);
35
+ });
36
+
37
+ await test('a cloud-host costs US a correct $0: it runs on its owner\'s own account', () => {
38
+ assert.equal(platformMonthlyUsd({ hostingShape: 'cloud-host', tier: PLAN }), 0);
39
+ assert.equal(platformMonthlyUsd({ hostingShape: 'cloud-host', tier: null }), 0);
40
+ });
41
+
42
+ await test('byo-host: $0 to us, and unpriced for the owner (their own machine)', () => {
43
+ assert.equal(platformMonthlyUsd({ hostingShape: 'byo-host' }), 0);
44
+ assert.equal(ownerMonthlyUsd({ hostingShape: 'byo-host' }), null);
45
+ });
46
+
47
+ await test('co-tenant shares our box: no owner is billed, and the box is priced once', () => {
48
+ assert.equal(platformMonthlyUsd({ hostingShape: 'co-tenant' }), 0);
49
+ assert.equal(ownerMonthlyUsd({ hostingShape: 'co-tenant' }), 0);
50
+ });
51
+
52
+ await test('the control plane is OUR cost, priced by its droplet size; no size is unpriced', () => {
53
+ assert.equal(platformMonthlyUsd({ hostingShape: 'control-plane', tier: 's-1vcpu-2gb' }), 12);
54
+ assert.equal(platformMonthlyUsd({ hostingShape: 'control-plane', tier: null }), null);
55
+ assert.equal(ownerMonthlyUsd({ hostingShape: 'control-plane', tier: 's-1vcpu-2gb' }), 0);
56
+ });
57
+
58
+ await test('an unknown shape is unpriced in both columns', () => {
59
+ assert.equal(platformMonthlyUsd({ hostingShape: 'dedicated', tier: 's-2vcpu-4gb' }), null);
60
+ assert.equal(ownerMonthlyUsd({ hostingShape: 'dedicated', tier: 's-2vcpu-4gb' }), null);
61
+ });
62
+
63
+ function recordingDb(rows) {
64
+ const calls = [];
65
+ return { calls, query: async (sql, params) => { calls.push({ sql, params }); return { rows }; } };
66
+ }
67
+ const row = (id, hosting_shape, tier = null) =>
68
+ ({ id, slug: `p${id}`, hosting_shape, tier, status: 'active', github_login: 'owner' });
69
+
70
+ await test('the live fleet today: every total says how many rows it could not price', async () => {
71
+ // Measured 2026-09-24: the control plane (no size recorded) + five cloud-hosts (no plan).
72
+ const db = recordingDb([row(1, 'control-plane'), ...[2, 3, 4, 5, 6].map((id) => row(id, 'cloud-host'))]);
73
+ const out = await costLedger(db);
74
+ assert.deepEqual(out.totals.platform, { monthly_usd: 0, unpriced: 1 });
75
+ assert.deepEqual(out.totals.owner, { monthly_usd: 0, unpriced: 5 });
76
+ assert.equal(out.instances[0].platform_monthly_usd, null);
77
+ assert.equal(out.instances[1].owner_monthly_usd, null);
78
+ });
79
+
80
+ await test('totals sum only priced rows, per column', async () => {
81
+ const db = recordingDb([
82
+ row(1, 'control-plane', 's-1vcpu-2gb'), row(2, 'cloud-host', PLAN), row(3, 'cloud-host', PLAN),
83
+ row(4, 'cloud-host'), row(5, 'byo-host'),
84
+ ]);
85
+ const out = await costLedger(db);
86
+ assert.deepEqual(out.totals.platform, { monthly_usd: 12, unpriced: 0 });
87
+ assert.deepEqual(out.totals.owner, { monthly_usd: 2 * RENDER_PLAN_MONTHLY_USD[PLAN], unpriced: 2 });
88
+ assert.equal('cost_estimate_usd' in out.instances[0], false, 'the one-column-two-questions field is gone');
89
+ });
90
+
91
+ await test('the query skips torn-down rows and clamps the limit', async () => {
92
+ const db = recordingDb([]);
93
+ await costLedger(db, { limit: 99999 });
94
+ assert.match(db.calls[0].sql, /status <> 'torn_down'/);
95
+ assert.deepEqual(db.calls[0].params, [2000]);
96
+ });
97
+
98
+ summary();