@bongos/core 1.21.70 → 1.21.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.bongos-core.json +114 -59
  2. package/.claude/skills/goal-create/SKILL.md +21 -3
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +2 -0
  5. package/clients/bongos-client/index.cjs +2 -0
  6. package/clients/bongos-client/index.d.ts +5 -3
  7. package/clients/bongos-client/index.mjs +2 -0
  8. package/docs/adr/0368-red-and-blue-teams-practice-on-a-range-never-on-live.md +99 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +46 -5
  11. package/docs/api-reference.md +4 -3
  12. package/docs/copy-inventory.md +25 -21
  13. package/docs/copy-registry.json +76 -30
  14. package/docs/file-map.md +1 -1
  15. package/docs/module-api-changelog.md +2 -0
  16. package/docs/page-readings.json +8 -8
  17. package/docs/recipes/core-release-pipeline.md +6 -0
  18. package/docs/recipes/upgrading-the-core.md +2 -0
  19. package/modules/hall-ui/public/settings-software-update.js +77 -2
  20. package/modules/hall-ui/public/settings.html +1 -1
  21. package/modules/lifecycle/db-tasks.js +12 -0
  22. package/modules/lifecycle/goal-spec-task.js +129 -0
  23. package/modules/lifecycle/routes/goals.js +7 -9
  24. package/modules/lifecycle/routes/task-write-routes.js +11 -1
  25. package/modules/lifecycle/upkeep-home.js +55 -0
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/release-notes.json +37 -0
  29. package/scripts/gds/claude-materialize.js +125 -31
  30. package/scripts/gds/claude-md-template.js +47 -10
  31. package/scripts/gds/cli-lib.js +4 -4
  32. package/scripts/gds/feedback-send.js +3 -3
  33. package/scripts/gds/paste-token.js +1 -1
  34. package/scripts/gds/provision-net.js +1 -8
  35. package/scripts/gds/provision-units.js +28 -2
  36. package/scripts/gds/reauth.js +1 -1
  37. package/scripts/gds/run-unit-tests.js +3 -0
  38. package/scripts/gds/setup.js +1 -1
  39. package/scripts/gds/start.js +4 -4
  40. package/scripts/gds/surface.js +15 -3
  41. package/scripts/gds/task.js +32 -5
  42. package/scripts/gds/upgrade-operator.js +73 -0
  43. package/scripts/gds/upgrade.js +7 -2
  44. package/scripts/hall-preview/server.js +3 -0
  45. package/src/bongos/routes/core-update.js +98 -4
  46. package/src/bongos/routes/feedback.js +11 -1
  47. package/src/bongos/upgrade-request.js +158 -0
  48. package/src/module-api.js +1 -1
  49. package/tests/claude_materialize.mjs +195 -18
  50. package/tests/cli_allowlist_permissions.mjs +6 -2
  51. package/tests/cli_surface.mjs +2 -2
  52. package/tests/fixtures/page-ledger.mjs +5 -0
  53. package/tests/goal_admission_override.mjs +1 -1
  54. package/tests/goal_category.mjs +1 -1
  55. package/tests/goal_create_spec_entry.mjs +61 -0
  56. package/tests/goal_spec_task.mjs +197 -0
  57. package/tests/goal_spec_task_db.mjs +95 -0
  58. package/tests/provision_settings_apply.mjs +25 -1
  59. package/tests/software_update.mjs +18 -1
  60. package/tests/start_env_guard.mjs +2 -2
  61. package/tests/upgrade_operator.mjs +105 -0
  62. package/tests/upgrade_request.mjs +348 -0
  63. package/tests/upkeep_home.mjs +156 -0
@@ -33,6 +33,7 @@ const { readCoreUpdate, readCoreUpgrades } = require('../core-update');
33
33
  const { readSoftwareUpdate } = require('../software-update');
34
34
  const seams = require('../../module-seams');
35
35
  const { loadIdpConfig } = require('../auth-config');
36
+ const upgradeRequest = require('../upgrade-request');
36
37
 
37
38
  // WHERE A HOSTED PROJECT'S OWNER TAKES THE UPGRADE (task 1004174, ADR 0339): the hub's
38
39
  // own deploy door, scoped to this project — `<hub>/deploy?project=<slug>`. The act stays
@@ -73,12 +74,22 @@ function hubManageLinks(config) {
73
74
  // local — this hall runs the door: its own /deploy.
74
75
  // hub — a hosted project: the hub's door for this project (ADR 0339 D3).
75
76
  // cli — neither: a self-hosted instance, upgraded on its server with `bongos upgrade`.
76
- function updateDoor({ provisioning, idp }) {
77
+ // A hosted project that does not federate its sign-in (a cloud-host, or one keeping its own
78
+ // GitHub app) has no idp config to derive the hub from, so provisioning writes the door's
79
+ // address as <PREFIX>_HUB_UPGRADE_URL (task 1004608). Without it, those owners were told to
80
+ // run `bongos upgrade` on a server the platform runs. Only the hub's /deploy path is taken.
81
+ const HUB_UPGRADE_URL_RE = /^https?:\/\/[^\s"'<>/]+\/deploy\?project=[A-Za-z0-9._%-]+$/;
82
+ function updateDoor({ provisioning, idp, hubUpgradeUrl }) {
77
83
  if (provisioning) return { kind: 'local', url: '/deploy' };
78
- const url = hubDeployUrl(idp);
84
+ const url = hubDeployUrl(idp)
85
+ || (typeof hubUpgradeUrl === 'string' && HUB_UPGRADE_URL_RE.test(hubUpgradeUrl.trim()) ? hubUpgradeUrl.trim() : null);
79
86
  return url ? { kind: 'hub', url } : { kind: 'cli', url: null };
80
87
  }
81
88
 
89
+ function safeHubUpgradeUrl() {
90
+ try { return require('../../instance-config').resolveEnv('HUB_UPGRADE_URL') || null; } catch { return null; }
91
+ }
92
+
82
93
  function provisioningEnabled() {
83
94
  try { return require('../../modules').isModuleEnabled('provisioning'); } catch { return false; }
84
95
  }
@@ -164,16 +175,57 @@ async function resolveChannel(selfChannel) {
164
175
  return { channel, source: channel ? 'deploy' : 'default' };
165
176
  }
166
177
 
178
+ // "Ask for this upgrade" (task 1004595): what src/bongos/upgrade-request.js needs from
179
+ // the outside world, resolved per request so a test can stand every piece in. The receipt
180
+ // goes into the SAME store the public redeem route reads (routes/feedback.js).
181
+ function defaultAskDeps() {
182
+ const ps = require('../project-settings');
183
+ const fb = require('./feedback');
184
+ return {
185
+ hub: fb.feedbackHub(),
186
+ host: configuredHost(),
187
+ getSetting: ps.getSetting,
188
+ setSetting: ps.setSetting,
189
+ issueReceipt: (r) => fb.defaultReceiptStore().issue(r),
190
+ };
191
+ }
192
+
193
+ // The host this project answers on, from its OWN branding pack (domains is server-only):
194
+ // the origin the hub calls back to redeem the receipt. Never read from the request: a
195
+ // Host or X-Forwarded-Host header is the caller's to write, and would let them point the
196
+ // hub's call-back at any address they like. '' when the pack names none.
197
+ function configuredHost() {
198
+ try {
199
+ const d = require('../../branding').branding().domains || {};
200
+ return new URL(d.publicOrigin || d.buildersOrigin).host;
201
+ } catch { return ''; }
202
+ }
203
+
204
+ // The page's ask state. Fail-soft: a stored row that cannot be read is "not asked yet",
205
+ // never a 500 on the whole Software update panel.
206
+ async function readAsk({ update, door, deps }) {
207
+ const offered = update && update.reading === 'ok' ? update.offered : null;
208
+ const base = { offered, host: upgradeRequest.projectHost(deps.host), doorKind: door.kind, hub: deps.hub };
209
+ let stored = null;
210
+ if (offered && upgradeRequest.isOperatorRun(base)) {
211
+ try { stored = upgradeRequest.parseStored(await deps.getSetting(upgradeRequest.SETTING_KEY)); } catch { stored = null; }
212
+ }
213
+ return upgradeRequest.askState({ ...base, stored });
214
+ }
215
+
167
216
  function buildCoreUpdateRouter({
168
217
  coreUpdate = readCoreUpdate,
169
218
  selfChannel = storedSelfChannel,
170
219
  upgrades = readCoreUpgrades,
171
220
  softwareUpdate = readSoftwareUpdate,
172
221
  canUpdate = holdsCorePin,
173
- doorInputs = () => ({ provisioning: provisioningEnabled(), idp: safeIdpConfig() }),
222
+ doorInputs = () => ({ provisioning: provisioningEnabled(), idp: safeIdpConfig(), hubUpgradeUrl: safeHubUpgradeUrl() }),
223
+ askDeps = defaultAskDeps,
174
224
  } = {}) {
175
225
  contributeCoreUpdateToDocket();
176
226
  const router = express.Router();
227
+ // One send at a time: a double press waits for the first and is answered from its row.
228
+ let asking = null;
177
229
 
178
230
  router.get(
179
231
  '/core-update',
@@ -229,10 +281,51 @@ function buildCoreUpdateRouter({
229
281
  resolveChannel(selfChannel).then(async ({ channel, source }) => ({ ...(await softwareUpdate({ running, channel })), channel_source: source })),
230
282
  canUpdate(req),
231
283
  ]);
232
- res.json({ ok: true, update, door: updateDoor(doorInputs()), can_update: can === true });
284
+ const door = updateDoor(doorInputs());
285
+ const ask = await readAsk({ update, door, deps: askDeps() });
286
+ res.json({ ok: true, update, door, can_update: can === true, ask });
233
287
  }, { errorCode: 'software_update_failed', message: false }),
234
288
  );
235
289
 
290
+ // POST /software-update/request — "Ask for this upgrade" (task 1004595). On a project
291
+ // the hub's operator runs, sends one request for the version this project is offered
292
+ // to the operator, through the direct feedback door, and remembers it so the page can
293
+ // say it is pending. The version is the server's own reading, never the caller's.
294
+ // Idempotent per version; a different version waits out a 24h cooldown (429).
295
+ // rank: core.pin.move — the same atom as the Update button the panel draws.
296
+ router.post(
297
+ '/software-update/request',
298
+ auth.requireBuilder,
299
+ auth.requirePermission('core.pin.move'),
300
+ asyncHandler('POST /software-update/request', async (req, res) => {
301
+ while (asking) { try { await asking; } catch { /* the next one tries for itself */ } }
302
+ const run = (async () => {
303
+ let running = null;
304
+ try { running = require('../../module-api').CORE_VERSION; } catch { /* version unknown */ }
305
+ const { channel } = await resolveChannel(selfChannel);
306
+ const update = await softwareUpdate({ running, channel });
307
+ const door = updateDoor(doorInputs());
308
+ const deps = askDeps();
309
+ return upgradeRequest.requestUpgrade({
310
+ host: deps.host,
311
+ running,
312
+ wanted: update && update.reading === 'ok' ? update.offered : null,
313
+ builder: req.builder,
314
+ doorKind: door.kind,
315
+ }, deps);
316
+ })();
317
+ asking = run;
318
+ let out;
319
+ try { out = await run; } finally { if (asking === run) asking = null; }
320
+ if (out.status >= 400) {
321
+ const { error, message, ...details } = out.body;
322
+ if (details.retry_after_seconds) res.set('Retry-After', String(details.retry_after_seconds));
323
+ return res.fail(error, { status: out.status, message, details });
324
+ }
325
+ res.status(out.status).json(out.body);
326
+ }, { errorCode: 'upgrade_request_failed', message: false }),
327
+ );
328
+
236
329
  // GET /project/manage-link — the hub's page for THIS project, where its owner takes it
237
330
  // offline or disconnects it (task 1004570). On a hosted project the owner is its Archon,
238
331
  // so the read is Archon-only: a builder below that is refused and the hall draws no
@@ -254,4 +347,5 @@ function buildCoreUpdateRouter({
254
347
  module.exports = buildCoreUpdateRouter;
255
348
  module.exports.hubManageLinks = hubManageLinks;
256
349
  module.exports.hubDeployUrl = hubDeployUrl;
350
+ module.exports.updateDoor = updateDoor;
257
351
  module.exports.coreUpdateDocketMatters = coreUpdateDocketMatters;
@@ -77,7 +77,16 @@ function makeReceiptStore({ now = Date.now, ttlMs = RECEIPT_TTL_MS, max = RECEIP
77
77
  };
78
78
  }
79
79
 
80
- function buildFeedbackRouter({ store = makeReceiptStore(), hub = feedbackHub } = {}) {
80
+ // The process's one receipt store. Shared, because a receipt is redeemed at the public
81
+ // route below whoever issued it: the CLI's POST /feedback/receipt, or this project's own
82
+ // "Ask for this upgrade" (src/bongos/upgrade-request.js, task 1004595).
83
+ let sharedStore = null;
84
+ function defaultReceiptStore() {
85
+ if (!sharedStore) sharedStore = makeReceiptStore();
86
+ return sharedStore;
87
+ }
88
+
89
+ function buildFeedbackRouter({ store = defaultReceiptStore(), hub = feedbackHub } = {}) {
81
90
  const router = express.Router();
82
91
 
83
92
  // rank: any signed-in builder — a receipt only says "this builder sent this
@@ -120,5 +129,6 @@ function buildFeedbackRouter({ store = makeReceiptStore(), hub = feedbackHub } =
120
129
 
121
130
  module.exports = buildFeedbackRouter;
122
131
  module.exports.makeReceiptStore = makeReceiptStore;
132
+ module.exports.defaultReceiptStore = defaultReceiptStore;
123
133
  module.exports.feedbackHub = feedbackHub;
124
134
  module.exports.DEFAULT_FEEDBACK_HUB = DEFAULT_FEEDBACK_HUB;
@@ -0,0 +1,158 @@
1
+ 'use strict';
2
+
3
+ // src/bongos/upgrade-request.js — "Ask for this upgrade" on Settings → Software update
4
+ // (task 1004595).
5
+ //
6
+ // WHY. A project that Cloud Bongos hosts is upgraded by the people who run its server,
7
+ // not by its owner. Its Settings page said "Update ready" and then nothing: no button,
8
+ // no next step (Will, mercury, 2026-10-06, 46 versions behind with nobody told). This
9
+ // file gives that owner one press that reaches the operator, and remembers that it was
10
+ // pressed so the page can say the request is pending.
11
+ //
12
+ // THE CHANNEL IS ONE THAT ALREADY EXISTS: the direct feedback door (task 1004591). The
13
+ // project issues a one-time receipt for the signed-in builder in its OWN receipt store
14
+ // (src/bongos/routes/feedback.js), then posts the request straight to the hub's
15
+ // POST /api/bongos/sso/feedback/direct. The hub redeems the receipt at this project's
16
+ // public URL, so the request lands in the maintainers' backlog attributed to the person
17
+ // who really pressed the button, with no new credential and no new hub route.
18
+ //
19
+ // ONE REQUEST PER TARGET VERSION. Asking again for a version already asked for is
20
+ // answered from the stored row and sends nothing. A different version may be asked for
21
+ // once the cooldown has passed, so a project that falls further behind can say so,
22
+ // without a press per patch release flooding the backlog.
23
+
24
+ const crypto = require('node:crypto');
25
+
26
+ const SETTING_KEY = 'software_update.request';
27
+ const COOLDOWN_MS = 24 * 60 * 60 * 1000;
28
+ const HUB_TIMEOUT_MS = 15000;
29
+
30
+ const VERSION_RE = /^\d{1,4}\.\d{1,5}\.\d{1,6}$/;
31
+ const LOGIN_RE = /^[A-Za-z0-9-]{1,39}$/;
32
+
33
+ // The host this hall answers on, as the hub will need it to redeem the receipt: a bare
34
+ // public DNS name. Anything else (an IP, a port, localhost, a dev name) cannot be
35
+ // reached by the hub, so it is not askable. Mirrors the hub's own projectOrigin check.
36
+ function projectHost(raw) {
37
+ const host = String(raw || '').trim().toLowerCase();
38
+ if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(host)) return null;
39
+ if (/^\d+(\.\d+)+$/.test(host)) return null;
40
+ if (/(^|\.)(localhost|local|internal|test|invalid)$/.test(host)) return null;
41
+ return host;
42
+ }
43
+
44
+ function hubHostOf(hub) {
45
+ try { return new URL(String(hub)).host.toLowerCase(); } catch { return null; }
46
+ }
47
+
48
+ // Is this a project the hub's operator runs? Its door is the hub's, or it answers on a
49
+ // name under the hub's own domain (the same rule the hub uses to call a sender "verified").
50
+ function isOperatorRun({ doorKind, host, hub }) {
51
+ if (doorKind === 'local' || !hub) return false;
52
+ if (doorKind === 'hub') return true;
53
+ const h = hubHostOf(hub);
54
+ return !!(h && host && (host === h || host.endsWith(`.${h}`)));
55
+ }
56
+
57
+ // The stored request, or null. A row this file did not write reads as no request.
58
+ function parseStored(row) {
59
+ if (!row || typeof row.value !== 'string') return null;
60
+ let v;
61
+ try { v = JSON.parse(row.value); } catch { return null; }
62
+ if (!v || !VERSION_RE.test(String(v.version || ''))) return null;
63
+ const at = Date.parse(v.requested_at);
64
+ if (!Number.isFinite(at)) return null;
65
+ return {
66
+ version: String(v.version),
67
+ requested_at: new Date(at).toISOString(),
68
+ by: typeof v.by === 'string' && LOGIN_RE.test(v.by) ? v.by : null,
69
+ };
70
+ }
71
+
72
+ // What the page needs: may it offer the button, and what was asked before.
73
+ // `ask_again_after` is when a request for a DIFFERENT version would be taken.
74
+ function askState({ offered, host, doorKind, hub, stored }) {
75
+ const available = !!offered && isOperatorRun({ doorKind, host, hub }) && !!projectHost(host);
76
+ const ask = { available, hub_host: hub ? hubHostOf(hub) : null, requested: stored || null, ask_again_after: null };
77
+ if (stored) ask.ask_again_after = new Date(Date.parse(stored.requested_at) + COOLDOWN_MS).toISOString();
78
+ return ask;
79
+ }
80
+
81
+ // The text the operator reads. Its first line becomes the hub task's title.
82
+ function requestMessage({ host, running, wanted, login }) {
83
+ const who = login && LOGIN_RE.test(login) ? `@${login}` : 'its owner';
84
+ return [
85
+ `Upgrade request: ${host} asks for core ${wanted} (it runs ${running || 'an unknown version'})`,
86
+ '',
87
+ `${who} pressed "Ask for this upgrade" in Settings, Software update on ${host}.`,
88
+ 'This project cannot upgrade itself: the people who run its server do it.',
89
+ `Move its core to ${wanted} or newer, or tell them when it will be.`,
90
+ ].join('\n');
91
+ }
92
+
93
+ /**
94
+ * Send one upgrade request, or answer from the stored one. Never throws for an
95
+ * expected refusal; returns { status, body } for the route to send.
96
+ *
97
+ * deps: { hub, issueReceipt(receipt) → id|null, fetchImpl, getSetting, setSetting, now }
98
+ */
99
+ async function requestUpgrade({ host, running, wanted, builder, doorKind }, deps) {
100
+ const now = deps.now || Date.now;
101
+ const hub = deps.hub;
102
+ const target = projectHost(host);
103
+ if (!wanted || !VERSION_RE.test(String(wanted))) return { status: 409, body: { error: 'nothing_to_ask', message: 'This project is up to date.' } };
104
+ if (!target || !isOperatorRun({ doorKind, host: target, hub })) {
105
+ return { status: 409, body: { error: 'not_operator_run', message: 'This project is not upgraded by an operator, so there is no one to ask.' } };
106
+ }
107
+
108
+ const stored = parseStored(await deps.getSetting(SETTING_KEY));
109
+ if (stored && stored.version === wanted) return { status: 200, body: { ok: true, already: true, request: stored } };
110
+ if (stored && now() < Date.parse(stored.requested_at) + COOLDOWN_MS) {
111
+ const retry = Math.ceil((Date.parse(stored.requested_at) + COOLDOWN_MS - now()) / 1000);
112
+ return { status: 429, body: { error: 'asked_recently', message: 'This project already asked for an upgrade today. Ask again tomorrow.', retry_after_seconds: retry, request: stored } };
113
+ }
114
+
115
+ const login = builder && typeof builder.github_login === 'string' && LOGIN_RE.test(builder.github_login) ? builder.github_login : null;
116
+ const message = requestMessage({ host: target, running, wanted, login });
117
+ const sha256 = crypto.createHash('sha256').update(message, 'utf8').digest('hex');
118
+ const receipt = deps.issueReceipt({
119
+ github_id: String(builder && builder.github_id),
120
+ github_login: login,
121
+ sha256,
122
+ kind: 'bug',
123
+ core_version: running || null,
124
+ });
125
+ if (!receipt) return { status: 429, body: { error: 'rate_limited', message: 'Too many requests just now. Try again in ten minutes.', retry_after_seconds: 600 } };
126
+
127
+ let res;
128
+ try {
129
+ res = await (deps.fetchImpl || fetch)(`${String(hub).replace(/\/+$/, '')}/api/bongos/sso/feedback/direct`, {
130
+ method: 'POST',
131
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
132
+ body: JSON.stringify({ origin: `https://${target}`, receipt, message, kind: 'bug', context: {} }),
133
+ redirect: 'error',
134
+ signal: AbortSignal.timeout(HUB_TIMEOUT_MS),
135
+ });
136
+ } catch {
137
+ return { status: 502, body: { error: 'operator_unreachable', message: 'Could not reach the people who run this project. Try again in a few minutes.' } };
138
+ }
139
+ if (res.status === 429) return { status: 429, body: { error: 'rate_limited', message: 'Too many requests just now. Try again in an hour.', retry_after_seconds: 3600 } };
140
+ if (res.status !== 202) {
141
+ return { status: 502, body: { error: 'operator_unreachable', message: `The request was not taken (HTTP ${res.status}). Try again later.` } };
142
+ }
143
+
144
+ const request = { version: String(wanted), requested_at: new Date(now()).toISOString(), by: login };
145
+ await deps.setSetting(SETTING_KEY, JSON.stringify(request), builder && builder.id != null ? builder.id : null);
146
+ return { status: 201, body: { ok: true, already: false, request } };
147
+ }
148
+
149
+ module.exports = {
150
+ SETTING_KEY,
151
+ COOLDOWN_MS,
152
+ projectHost,
153
+ isOperatorRun,
154
+ parseStored,
155
+ askState,
156
+ requestMessage,
157
+ requestUpgrade,
158
+ };
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.70'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.71'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -41,24 +41,24 @@ function t(name, fn) {
41
41
 
42
42
  // ---- bongosify -------------------------------------------------------------
43
43
  t('bongosify: a script WITH a dedicated verb → bongos <verb> (args preserved)', () => {
44
- assert.equal(m.bongosify('run `node scripts/gds/ship.js 1885 --force`'), 'run `bongos ship 1885 --force`');
45
- assert.equal(m.bongosify('node scripts/gds/start.js --json'), 'bongos start --json');
44
+ assert.equal(m.bongosify('run `node scripts/gds/ship.js 1885 --force`'), 'run `npx bongos ship 1885 --force`');
45
+ assert.equal(m.bongosify('node scripts/gds/start.js --json'), 'npx bongos start --json');
46
46
  });
47
47
 
48
48
  t('bongosify: a script WITHOUT a dedicated verb → bongos exec (args preserved)', () => {
49
- assert.equal(m.bongosify('node scripts/gds/api.js GET /api/gds/me'), 'bongos exec scripts/gds/api.js GET /api/gds/me');
50
- assert.equal(m.bongosify('node scripts/gds/capture.js "x"'), 'bongos exec scripts/gds/capture.js "x"');
49
+ assert.equal(m.bongosify('node scripts/gds/api.js GET /api/gds/me'), 'npx bongos exec scripts/gds/api.js GET /api/gds/me');
50
+ assert.equal(m.bongosify('node scripts/gds/capture.js "x"'), 'npx bongos exec scripts/gds/capture.js "x"');
51
51
  });
52
52
 
53
53
  t('bongosify: $CLAUDE_PROJECT_DIR + surrounding quotes are stripped', () => {
54
- assert.equal(m.bongosify('node "$CLAUDE_PROJECT_DIR/scripts/gds/session-cost.js"'), 'bongos exec scripts/gds/session-cost.js');
54
+ assert.equal(m.bongosify('node "$CLAUDE_PROJECT_DIR/scripts/gds/session-cost.js"'), 'npx bongos exec scripts/gds/session-cost.js');
55
55
  });
56
56
 
57
57
  t('bongosify: the run-node.sh resolver form (task 1003548) collapses to the same bongos form', () => {
58
58
  const wrap = 'sh "$CLAUDE_PROJECT_DIR/.claude/hooks/run-node.sh"';
59
- assert.equal(m.bongosify(`${wrap} "$CLAUDE_PROJECT_DIR/scripts/gds/session-cost.js"`), 'bongos exec scripts/gds/session-cost.js');
60
- assert.equal(m.bongosify(`${wrap} "$CLAUDE_PROJECT_DIR/scripts/gds/deploy-divergence.js" --line --timeout-ms 2500`), 'bongos exec scripts/gds/deploy-divergence.js --line --timeout-ms 2500');
61
- assert.equal(m.bongosify(`${wrap} "$CLAUDE_PROJECT_DIR/scripts/gds/ship.js" 1885`), 'bongos ship 1885');
59
+ assert.equal(m.bongosify(`${wrap} "$CLAUDE_PROJECT_DIR/scripts/gds/session-cost.js"`), 'npx bongos exec scripts/gds/session-cost.js');
60
+ assert.equal(m.bongosify(`${wrap} "$CLAUDE_PROJECT_DIR/scripts/gds/deploy-divergence.js" --line --timeout-ms 2500`), 'npx bongos exec scripts/gds/deploy-divergence.js --line --timeout-ms 2500');
61
+ assert.equal(m.bongosify(`${wrap} "$CLAUDE_PROJECT_DIR/scripts/gds/ship.js" 1885`), 'npx bongos ship 1885');
62
62
  // …and a resolver call whose target is a .claude/hooks/*.js stays instance-local, like the bare-node form.
63
63
  const hook = `${wrap} "$CLAUDE_PROJECT_DIR/.claude/hooks/secret-gate.js"`;
64
64
  assert.equal(m.bongosify(hook), hook);
@@ -100,7 +100,7 @@ t('transformSettings: rewrites scripts/gds hook commands, keeps .claude/hooks/ o
100
100
  const out = JSON.parse(m.transformSettings(p));
101
101
  const cmds = out.hooks.Stop[0].hooks.map((h) => h.command);
102
102
  assert.deepEqual(cmds, [
103
- 'bongos exec scripts/gds/session-cost.js',
103
+ 'npx bongos exec scripts/gds/session-cost.js',
104
104
  'node "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-sound.js"',
105
105
  ]);
106
106
  rmSync(dir, { recursive: true, force: true });
@@ -121,12 +121,12 @@ t('transformSettings: permission rules are rewritten to the form the instance\'s
121
121
  },
122
122
  }, null, 2));
123
123
  const out = JSON.parse(m.transformSettings(p)).permissions;
124
- assert.deepEqual(out.allow, ['Bash(bongos ship:*)', 'Bash(bongos exec scripts/gds/me.js:*)', 'Bash(git status:*)'],
124
+ assert.deepEqual(out.allow, ['Bash(npx bongos ship:*)', 'Bash(npx bongos exec scripts/gds/me.js:*)', 'Bash(git status:*)'],
125
125
  'allow takes the ONE spelling the skills use, and two spellings of a command collapse to one rule');
126
126
  // An instance can reach a script two ways — `bongos release` and `bongos exec scripts/gds/release.js` —
127
127
  // so a restrictive rule that named only one would still LOOK like a guard while guarding half.
128
- assert.deepEqual(out.deny, ['Bash(bongos release:*)', 'Bash(bongos exec scripts/gds/release.js:*)', 'Bash(bongos exec scripts/gds/me.js:*)', 'Read(./.env)']);
129
- assert.deepEqual(out.ask, ['Bash(bongos claim:*)', 'Bash(bongos exec scripts/gds/claim.js:*)']);
128
+ assert.deepEqual(out.deny, ['Bash(npx bongos release:*)', 'Bash(npx bongos exec scripts/gds/release.js:*)', 'Bash(npx bongos exec scripts/gds/me.js:*)', 'Read(./.env)']);
129
+ assert.deepEqual(out.ask, ['Bash(npx bongos claim:*)', 'Bash(npx bongos exec scripts/gds/claim.js:*)']);
130
130
  assert.equal(out.defaultMode, 'acceptEdits', 'a non-list permission key passes through untouched');
131
131
  rmSync(dir, { recursive: true, force: true });
132
132
  });
@@ -222,7 +222,7 @@ t('copyTreeTransformed: overwrite:false LEAVES a pre-existing dest file (reports
222
222
  const res = m.copyTreeTransformed(src, dest, { overwrite: false });
223
223
  assert.equal(res.files, 1, 'only the absent file was written');
224
224
  assert.deepEqual(res.skipped, ['b.md']);
225
- assert.equal(readFileSync(join(dest, 'a.md'), 'utf8'), 'bongos ship 1', 'absent file materialized + rewritten');
225
+ assert.equal(readFileSync(join(dest, 'a.md'), 'utf8'), 'npx bongos ship 1', 'absent file materialized + rewritten');
226
226
  assert.equal(readFileSync(join(dest, 'b.md'), 'utf8'), ownerB, 'pre-existing file left byte-untouched');
227
227
  rmSync(src, { recursive: true, force: true });
228
228
  rmSync(dest, { recursive: true, force: true });
@@ -236,7 +236,7 @@ t('copyTreeTransformed: overwrite defaults true → greenfield still clobbers (r
236
236
  const res = m.copyTreeTransformed(src, dest); // default overwrite:true
237
237
  assert.equal(res.files, 1);
238
238
  assert.deepEqual(res.skipped, []);
239
- assert.equal(readFileSync(join(dest, 'a.md'), 'utf8'), 'bongos start', 'greenfield overwrites as before');
239
+ assert.equal(readFileSync(join(dest, 'a.md'), 'utf8'), 'npx bongos start', 'greenfield overwrites as before');
240
240
  rmSync(src, { recursive: true, force: true });
241
241
  rmSync(dest, { recursive: true, force: true });
242
242
  });
@@ -772,8 +772,8 @@ t('every allow-rule names a verb the CLI actually registers', () => {
772
772
  const checkedByTool = {};
773
773
  for (const rule of m.STARTER_ALLOW) {
774
774
  const [, tool, inner] = rule.match(/^(\w+)\((.*?)(?::\*)?\)$/); // any shell tool (task 1004014)
775
- if (!inner.startsWith('bongos ')) continue; // the git/gh reads (task 1003992) name no verb
776
- const verb = inner.split(' ')[1];
775
+ if (!inner.startsWith('npx bongos ')) continue; // the git/gh reads (task 1003992) name no verb
776
+ const verb = inner.split(' ')[2];
777
777
  assert.ok(cliVerbs[verb], `allow-rule "${rule}" names "${verb}", which is not a registered bongos verb`);
778
778
  checkedByTool[tool] = (checkedByTool[tool] || 0) + 1;
779
779
  }
@@ -784,7 +784,7 @@ t('every allow-rule names a verb the CLI actually registers', () => {
784
784
  t('the escape hatch is NOT blanket-allowed', () => {
785
785
  // `bongos exec` runs any script inside the core by path. Allow-listing it bare
786
786
  // would be a blanket grant wearing a narrow rule's clothes.
787
- assert.deepEqual(m.STARTER_ALLOW.filter((r) => /^\w+\(bongos exec(:\*| \*)?\)$/.test(r)), [],
787
+ assert.deepEqual(m.STARTER_ALLOW.filter((r) => /^\w+\((?:npx )?bongos exec(:\*| \*)?\)$/.test(r)), [],
788
788
  'bongos exec must never be allowed bare — under any shell tool');
789
789
  const execRules = m.STARTER_ALLOW.filter((r) => r.includes('bongos exec'));
790
790
  for (const r of execRules) {
@@ -1065,7 +1065,7 @@ t('a checkout-materialized instance gets allow-rules in the bongos form, end to
1065
1065
  const res = m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
1066
1066
  assert.equal(res.settings, true);
1067
1067
  const allow = JSON.parse(readFileSync(join(inst, '.claude', 'settings.json'), 'utf8')).permissions.allow;
1068
- assert.deepEqual(allow, ['Bash(bongos claim:*)', 'Bash(bongos exec scripts/gds/api.js:*)', 'Bash(gh pr list:*)']);
1068
+ assert.deepEqual(allow, ['Bash(npx bongos claim:*)', 'Bash(npx bongos exec scripts/gds/api.js:*)', 'Bash(gh pr list:*)']);
1069
1069
  for (const rule of allow) assert.ok(m.STARTER_ALLOW.includes(rule), `${rule} is the starter's spelling too — one form, two sources`);
1070
1070
  rmSync(core, { recursive: true, force: true });
1071
1071
  rmSync(inst, { recursive: true, force: true });
@@ -1154,5 +1154,182 @@ t('materializeClaude: a broken core settings.json reaches the instance unchanged
1154
1154
  rmSync(inst, { recursive: true, force: true });
1155
1155
  });
1156
1156
 
1157
+ // ---- task 1004621: a project runs the CLI as `npx bongos`, never a bare `bongos` ---------------
1158
+ // The bin ships at node_modules/.bin/bongos, which is not on PATH, so every bare `bongos …` a
1159
+ // project's assistant read was a guaranteed `command not found` on the session's first call.
1160
+
1161
+ // A bare `bongos <verb>` (not preceded by `npx `) anywhere in text an assistant reads.
1162
+ const BARE_VERB = new RegExp(String.raw`(?<![\w@/.\-])(?<!npx )bongos (${Object.keys(cliVerbs).join('|')})(?![\w-])`);
1163
+
1164
+ t('bongosify: a hand-written bare `bongos <verb>` takes the npx spelling too', () => {
1165
+ assert.equal(m.bongosify('paste `bongos paste-token <token>` back'), 'paste `npx bongos paste-token <token>` back');
1166
+ assert.equal(m.bongosify('run bongos upgrade --to 1.2.3'), 'run npx bongos upgrade --to 1.2.3');
1167
+ assert.equal(m.bongosify('`bongos exec scripts/gds/api.js GET /x`'), '`npx bongos exec scripts/gds/api.js GET /x`');
1168
+ });
1169
+
1170
+ t('bongosify: never double-prefixes, and is idempotent over its own output', () => {
1171
+ for (const s of ['npx bongos ship 1', 'node scripts/gds/ship.js 1', 'bongos ship 1', '`bongos dev` then `node scripts/gds/start.js`']) {
1172
+ const once = m.bongosify(s);
1173
+ assert.ok(!once.includes('npx npx'), `double prefix from: ${s}`);
1174
+ assert.equal(m.bongosify(once), once, `a second run changed: ${s}`);
1175
+ assert.ok(!BARE_VERB.test(once), `a bare bongos verb survived: ${once}`);
1176
+ }
1177
+ });
1178
+
1179
+ t('bongosify: names and prose that merely contain "bongos" are left alone', () => {
1180
+ for (const s of ['`@bongos/core`', '/bongos-feedback', '`bongos: command not found`', 'the Bongos status page',
1181
+ 'the bongos CLI', 'the bongos database', 'src/bongos/routes/x.js', 'GET /api/bongos/me', 'bongos-ship-card']) {
1182
+ assert.equal(m.bongosify(s), s, s);
1183
+ }
1184
+ });
1185
+
1186
+ t('every core skill + routine, as a project receives it, carries no bare `bongos <verb>`', () => {
1187
+ const offenders = [];
1188
+ const walk = (dir) => {
1189
+ if (!existsSync(dir)) return;
1190
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
1191
+ const p = join(dir, e.name);
1192
+ if (e.isDirectory()) { walk(p); continue; }
1193
+ if (!/\.(md|txt|sh)$/.test(e.name)) continue;
1194
+ const out = m.bongosify(readFileSync(p, 'utf8'));
1195
+ if (out.includes('npx npx')) offenders.push(`${relative(ROOT, p)}: npx npx`);
1196
+ const hit = out.match(BARE_VERB);
1197
+ if (hit) offenders.push(`${relative(ROOT, p)}: ${hit[0]}`);
1198
+ }
1199
+ };
1200
+ walk(join(ROOT, '.claude', 'skills'));
1201
+ walk(join(ROOT, '.claude', 'scheduled-tasks'));
1202
+ assert.deepEqual(offenders, []);
1203
+ });
1204
+
1205
+ t('the core checkout\'s OWN skills are not rewritten: they still run node scripts/gds/*.js', () => {
1206
+ const ship = readFileSync(join(ROOT, '.claude', 'skills', 'builder-ship', 'SKILL.md'), 'utf8');
1207
+ assert.match(ship, /node scripts\/gds\/ship\.js/, 'the source skill keeps the checkout spelling');
1208
+ assert.ok(!ship.includes('npx bongos ship'), 'npx only appears on the way INTO a project');
1209
+ });
1210
+
1211
+ t('the starter allow-list is the npx spelling: no rule for a bare `bongos` nothing runs', () => {
1212
+ const bongosRules = m.STARTER_ALLOW.filter((r) => /bongos /.test(r));
1213
+ assert.ok(bongosRules.length > 10, 'the blessed commands are there');
1214
+ for (const r of bongosRules) assert.match(r, /^\w+\(npx bongos [a-z]/, `not the npx spelling: ${r}`);
1215
+ assert.ok(m.STARTER_ALLOW.includes('Bash(npx bongos ship:*)') && m.STARTER_ALLOW.includes('PowerShell(npx bongos ship:*)'));
1216
+ });
1217
+
1218
+ t('the starter CLAUDE.md names only `npx bongos …` commands and keeps the npm install first-run note', () => {
1219
+ const md = tmpl.renderInstanceClaudeMd({ identity: { productName: 'Acme' } });
1220
+ assert.equal(md.match(BARE_VERB), null, 'a bare bongos command is a command-not-found in a project');
1221
+ assert.ok(md.includes('npx bongos start') && md.includes('npx bongos ship') && md.includes('npx bongos doctor'));
1222
+ assert.ok(md.indexOf('npm install') < md.indexOf('npx bongos start'), 'npm install still comes first');
1223
+ assert.match(md, /not on your PATH/, 'says WHY it runs through npx');
1224
+ });
1225
+
1226
+ // The pre-1004621 starter, rebuilt from the template by undoing each refresh pair. If the template
1227
+ // changes a CLI sentence without adding its pair, this round trip stops being exact and fails.
1228
+ const legacyStarter = (branding) => {
1229
+ let md = tmpl.renderInstanceClaudeMd(branding);
1230
+ for (const [legacy, current] of tmpl.LEGACY_CLI_LINES) md = md.split(current).join(legacy);
1231
+ return md;
1232
+ };
1233
+
1234
+ t('refreshStarterCliLines: an unedited old starter comes back byte-identical to today\'s', () => {
1235
+ const b = { identity: { productName: 'Acme' }, domains: { buildersOrigin: 'https://b.acme.example.org' } };
1236
+ const old = legacyStarter(b);
1237
+ assert.ok(BARE_VERB.test(old), 'the fixture really is the old bare-bongos starter');
1238
+ const r = tmpl.refreshStarterCliLines(old);
1239
+ assert.equal(r.text, tmpl.renderInstanceClaudeMd(b));
1240
+ assert.equal(r.changed, tmpl.LEGACY_CLI_LINES.length);
1241
+ assert.deepEqual(tmpl.refreshStarterCliLines(r.text), { text: r.text, changed: 0 }, 'idempotent');
1242
+ });
1243
+
1244
+ t('refreshStarterCliLines: an owner-reworded sentence and the rest of the file are left alone; CRLF works', () => {
1245
+ const old = legacyStarter({ identity: { productName: 'Acme' } })
1246
+ .replace('- **`bongos claim <id>`** locks a task to your session.', '- We claim with `bongos claim <id>` (our wording).')
1247
+ + '\n## Our own notes\n\nKeep `bongos start` here verbatim.\n';
1248
+ const r = tmpl.refreshStarterCliLines(old);
1249
+ assert.ok(r.text.includes('- We claim with `bongos claim <id>` (our wording).'), 'an edited line is the owner\'s');
1250
+ assert.ok(r.text.endsWith('\n## Our own notes\n\nKeep `bongos start` here verbatim.\n'), 'owner prose untouched');
1251
+ assert.equal(r.changed, tmpl.LEGACY_CLI_LINES.length - 1);
1252
+ const crlf = tmpl.refreshStarterCliLines(legacyStarter({ identity: { productName: 'Acme' } }).replace(/\n/g, '\r\n'));
1253
+ assert.equal(crlf.changed, tmpl.LEGACY_CLI_LINES.length, 'a Windows (autocrlf) checkout is matched too');
1254
+ assert.ok(!/(?<!\r)\n/.test(crlf.text), 'and keeps its CRLF endings');
1255
+ });
1256
+
1257
+ // The upgrade path. `bongos upgrade` calls materializeClaude({ coreRoot, instanceDir, log }) —
1258
+ // default overwrite — after moving the pin (scripts/gds/upgrade.js), so an EXISTING project picks
1259
+ // all of this up on its next upgrade. This drives exactly that call against a project scaffolded
1260
+ // before task 1004621: old CLAUDE.md, the bare-`bongos` starter settings.json, old skills.
1261
+ function oldProjectFixture() {
1262
+ const core = mkdtempSync(join(tmpdir(), 'npx-core-'));
1263
+ const inst = mkdtempSync(join(tmpdir(), 'npx-inst-'));
1264
+ mkdirSync(join(core, '.claude', 'skills', 'alpha'), { recursive: true }); // package-only: no settings.json at the core root
1265
+ writeFileSync(join(core, '.claude', 'skills', 'alpha', 'SKILL.md'), '---\nname: alpha\n---\nrun `node scripts/gds/ship.js 1`, or paste `bongos paste-token <token>`\n');
1266
+ mkdirSync(join(inst, '.claude', 'skills', 'alpha'), { recursive: true });
1267
+ writeFileSync(join(inst, '.claude', 'skills', 'alpha', 'SKILL.md'), '---\nname: alpha\n---\nrun `bongos ship 1`\n'); // what the old materialize wrote
1268
+ writeFileSync(join(inst, 'CLAUDE.md'), legacyStarter({ identity: { productName: 'Acme' } }));
1269
+ const oldAllow = m.STARTER_ALLOW.map((r) => r.replace('(npx bongos ', '(bongos '))
1270
+ .filter((r) => r !== 'Bash(bongos recall:*)' && r !== 'PowerShell(bongos recall:*)'); // the owner removed one
1271
+ writeFileSync(join(inst, '.claude', 'settings.json'), JSON.stringify({ permissions: { allow: oldAllow, deny: [] } }, null, 2));
1272
+ const settings = () => JSON.parse(readFileSync(join(inst, '.claude', 'settings.json'), 'utf8'));
1273
+ return { core, inst, oldAllow, settings, cleanup: () => { rmSync(core, { recursive: true, force: true }); rmSync(inst, { recursive: true, force: true }); } };
1274
+ }
1275
+
1276
+ t('upgrade path: an existing project\'s skills, CLAUDE.md and allow-list all move to `npx bongos`', () => {
1277
+ const f = oldProjectFixture();
1278
+ const res = m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: false, log: () => {} });
1279
+ const skill = readFileSync(join(f.inst, '.claude', 'skills', 'alpha', 'SKILL.md'), 'utf8');
1280
+ assert.match(skill, /`npx bongos ship 1`/);
1281
+ assert.match(skill, /`npx bongos paste-token <token>`/);
1282
+ assert.equal(skill.match(BARE_VERB), null);
1283
+ const md = readFileSync(join(f.inst, 'CLAUDE.md'), 'utf8');
1284
+ assert.equal(md, tmpl.renderInstanceClaudeMd({ identity: { productName: 'Acme' } }), 'the unedited starter sentences were refreshed');
1285
+ assert.equal(res.claudeMdCliLines, tmpl.LEGACY_CLI_LINES.length);
1286
+ assert.ok(res.writtenPaths.includes('CLAUDE.md'), 'reported, so the pin commit carries it');
1287
+ const allow = f.settings().permissions.allow;
1288
+ for (const r of f.oldAllow) assert.ok(allow.includes(r), `the owner's own rule is kept: ${r}`);
1289
+ assert.ok(allow.includes('Bash(npx bongos ship:*)') && allow.includes('PowerShell(npx bongos ship:*)'), 'each bare rule gained its npx twin');
1290
+ assert.ok(!allow.includes('Bash(npx bongos recall:*)'), 'a rule the owner removed is not brought back by its twin');
1291
+ assert.equal(res.settingsAllowTwinsAdded.length, f.oldAllow.filter((r) => /\(bongos /.test(r)).length);
1292
+ f.cleanup();
1293
+ });
1294
+
1295
+ t('upgrade path: a second upgrade adds nothing more (no npx npx, no duplicate rule)', () => {
1296
+ const f = oldProjectFixture();
1297
+ m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: false, log: () => {} });
1298
+ const after1 = readFileSync(join(f.inst, '.claude', 'settings.json'), 'utf8');
1299
+ const md1 = readFileSync(join(f.inst, 'CLAUDE.md'), 'utf8');
1300
+ const res2 = m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: false, log: () => {} });
1301
+ assert.equal(readFileSync(join(f.inst, '.claude', 'settings.json'), 'utf8'), after1);
1302
+ assert.equal(readFileSync(join(f.inst, 'CLAUDE.md'), 'utf8'), md1);
1303
+ assert.equal(res2.settingsAllowTwinsAdded, undefined);
1304
+ assert.ok(!after1.includes('npx npx') && !md1.includes('npx npx'));
1305
+ f.cleanup();
1306
+ });
1307
+
1308
+ t('upgrade path: a dry run reports the changes and writes nothing', () => {
1309
+ const f = oldProjectFixture();
1310
+ const before = readFileSync(join(f.inst, '.claude', 'settings.json'), 'utf8');
1311
+ const mdBefore = readFileSync(join(f.inst, 'CLAUDE.md'), 'utf8');
1312
+ const res = m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: true, log: () => {} });
1313
+ assert.ok(res.settingsAllowTwinsAdded.length > 0 && res.claudeMdCliLines > 0);
1314
+ assert.equal(readFileSync(join(f.inst, '.claude', 'settings.json'), 'utf8'), before);
1315
+ assert.equal(readFileSync(join(f.inst, 'CLAUDE.md'), 'utf8'), mdBefore);
1316
+ assert.ok(!res.writtenPaths.includes('CLAUDE.md'), 'a content-preserving rewrite is not waived for the pre-flight');
1317
+ f.cleanup();
1318
+ });
1319
+
1320
+ t('adopt mode (overwrite:false) never touches the owner\'s CLAUDE.md', () => {
1321
+ const f = oldProjectFixture();
1322
+ const mdBefore = readFileSync(join(f.inst, 'CLAUDE.md'), 'utf8');
1323
+ m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: false, overwrite: false, log: () => {} });
1324
+ assert.equal(readFileSync(join(f.inst, 'CLAUDE.md'), 'utf8'), mdBefore);
1325
+ f.cleanup();
1326
+ });
1327
+
1328
+ t('topUpNpxAllowTwins: an unreadable shape is reported, never rewritten', () => {
1329
+ assert.match(m.topUpNpxAllowTwins('{nope').reason, /not valid JSON/);
1330
+ assert.match(m.topUpNpxAllowTwins('{"permissions":{"allow":"x"}}').reason, /permissions\.allow/);
1331
+ assert.deepEqual(m.topUpNpxAllowTwins('{"permissions":{"allow":["Bash(git status:*)"]}}'), { text: null, added: [], reason: null });
1332
+ });
1333
+
1157
1334
  console.log(`\nclaude_materialize: ${passed} passed, ${failed} failed`);
1158
1335
  process.exit(failed ? 1 : 0);