@bongos/core 1.19.724 → 1.19.725

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.724",
6
- "core_contract": "1.19.724",
7
- "source_commit": "26fe0e32ab8d71694d4a83ebd8aec4d442ce8da0",
5
+ "core_version": "1.19.725",
6
+ "core_contract": "1.19.725",
7
+ "source_commit": "5a98310e9967d2430aba88122213a7a722ae07ad",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-13T13:15:36.801Z",
9
+ "built_at": "2026-09-13T13:36:04.522Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 478,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2162,
14
+ "functional_verbatim": 2163,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2664,
20
- "tree_sha256": "444d762aa2ed1f4ebcc9269d0542680f83ed9da1a928a98ac6a36ebe09c691da",
19
+ "file_count": 2665,
20
+ "tree_sha256": "41c25536b149d9641dc2e413a0da01a4e49c306b619ec412c2f2ea7b552944c6",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2807,7 +2807,7 @@
2807
2807
  {
2808
2808
  "path": "docs/module-api-changelog.md",
2809
2809
  "mode": "0000644",
2810
- "sha256": "690b1a1f85cb9eb8e3b9e326b39cd56724bd0e3287d96539a7e84c6267231507"
2810
+ "sha256": "793f097cac5faadbbf7d9aaec4c2b5b413a535b913b2e3258ef17361982f7ef9"
2811
2811
  },
2812
2812
  {
2813
2813
  "path": "docs/modules-contract.md",
@@ -6137,7 +6137,7 @@
6137
6137
  {
6138
6138
  "path": "modules/onboarding/approval-broadcast.js",
6139
6139
  "mode": "0000644",
6140
- "sha256": "5d88c9b42e5f76b0959a543d097456a00c3ec1f4ef1a75ca8de6334b826ba983"
6140
+ "sha256": "2225b1a2196f6195bdf1b24f38da5295e0f44cbd95395d09c6109d8680fc5b01"
6141
6141
  },
6142
6142
  {
6143
6143
  "path": "modules/onboarding/db.js",
@@ -7837,12 +7837,12 @@
7837
7837
  {
7838
7838
  "path": "package-lock.json",
7839
7839
  "mode": "0000644",
7840
- "sha256": "10ce0347095f2fef439846e19eee1535447edac0fd659fb0ef8703f50df6e60f"
7840
+ "sha256": "82be9f1fd6d0b97fdc7b5ac3d36e9e7537c2b42818af2bb643be5ecf6610d22a"
7841
7841
  },
7842
7842
  {
7843
7843
  "path": "package.json",
7844
7844
  "mode": "0000644",
7845
- "sha256": "ddfcaa576b63f9169be34c591a1c0dccd629ca0399b0e27bf514a4a54fc21c6d"
7845
+ "sha256": "f1796793605e11cb891209283c562e08772ee65f956bbe5039ee799e1969a013"
7846
7846
  },
7847
7847
  {
7848
7848
  "path": "public-docs/index.html",
@@ -7907,7 +7907,7 @@
7907
7907
  {
7908
7908
  "path": "scripts/gds/adopt-preflight.js",
7909
7909
  "mode": "0000644",
7910
- "sha256": "07ff338beac12979eba50e4a6fe9c2f6dbfd842fa01abe1c412b19888d10c4a0"
7910
+ "sha256": "1e2c8a70aec49eaa0d95a9cbf37145fa1c851b46e2ec27f72f773d4198356ab9"
7911
7911
  },
7912
7912
  {
7913
7913
  "path": "scripts/gds/adr-namespace.js",
@@ -8067,7 +8067,7 @@
8067
8067
  {
8068
8068
  "path": "scripts/gds/build-cli-package.js",
8069
8069
  "mode": "0000644",
8070
- "sha256": "bd7509b408b56ded44c07f3cbad8cf361211fd8349e5139c75c49d31fcaa07a9"
8070
+ "sha256": "314004ef2af628fca5f572daca5e2404d1c2129b3ad650d715294cc26cc2fe13"
8071
8071
  },
8072
8072
  {
8073
8073
  "path": "scripts/gds/bump-version.js",
@@ -8117,7 +8117,7 @@
8117
8117
  {
8118
8118
  "path": "scripts/gds/claude-md-template.js",
8119
8119
  "mode": "0000644",
8120
- "sha256": "68f2c65ac9297328f44927ccb9a40a0fa536b91d9056aaa980a92149a64ce47c"
8120
+ "sha256": "afd825409590fa0c81cdc072bb925555e70bd543f819b8c68110760919ca67e1"
8121
8121
  },
8122
8122
  {
8123
8123
  "path": "scripts/gds/cleanup-orphans.js",
@@ -8219,6 +8219,11 @@
8219
8219
  "mode": "0000644",
8220
8220
  "sha256": "79effdf07519d26153f6fe846fd052cd4a37fa1e25b6b1a278146c9d8bcf6766"
8221
8221
  },
8222
+ {
8223
+ "path": "scripts/gds/device-flow-help.js",
8224
+ "mode": "0000644",
8225
+ "sha256": "802b8875e2aa7e51b1b95800aae461c5d289d7684ef57e6da95bebc458aa0f5e"
8226
+ },
8222
8227
  {
8223
8228
  "path": "scripts/gds/diagnose-achievements.js",
8224
8229
  "mode": "0000644",
@@ -8452,7 +8457,7 @@
8452
8457
  {
8453
8458
  "path": "scripts/gds/init.js",
8454
8459
  "mode": "0000644",
8455
- "sha256": "b0971625852fca93ca474d2f9e31e3601397f1b9efbef3241a0b0c47f17d925f"
8460
+ "sha256": "e81ab71bcd7d2f06b5aaf462dd099776c966f9840a109846a9753f12482b5281"
8456
8461
  },
8457
8462
  {
8458
8463
  "path": "scripts/gds/install-git-hooks.js",
@@ -8522,7 +8527,7 @@
8522
8527
  {
8523
8528
  "path": "scripts/gds/login.js",
8524
8529
  "mode": "0000644",
8525
- "sha256": "bdc7fcc0876a1185fb15339740793944c755d716d998bb68533ab3a721d39ab2"
8530
+ "sha256": "b5e00007cf68a5fa719d2603fdd7405dbc2e474a41721acde274e43bcec59ab0"
8526
8531
  },
8527
8532
  {
8528
8533
  "path": "scripts/gds/main-audit.js",
@@ -8972,7 +8977,7 @@
8972
8977
  {
8973
8978
  "path": "scripts/gds/setup.js",
8974
8979
  "mode": "0000644",
8975
- "sha256": "15243de5b4bde57313c601bf96bd2a10bb86dedceb18cb0a31495b998d1ca835"
8980
+ "sha256": "bd78df6f5f296588721afd9ebf2e477b5d463ca3d12cbee8894e115a0a6779f7"
8976
8981
  },
8977
8982
  {
8978
8983
  "path": "scripts/gds/shell.js",
@@ -9492,7 +9497,7 @@
9492
9497
  {
9493
9498
  "path": "src/bongos/repo-info.js",
9494
9499
  "mode": "0000644",
9495
- "sha256": "ae6cd8950ec8e23935febcc14c4ba65ff39f045c01202a1237d1ad79a39153f2"
9500
+ "sha256": "38c98f7cdb9dbc9d73dc53d0ad29466bc2fc8f762cd8db3961ab40df44dae11c"
9496
9501
  },
9497
9502
  {
9498
9503
  "path": "src/bongos/route-rank-check.js",
@@ -9632,7 +9637,7 @@
9632
9637
  {
9633
9638
  "path": "src/module-api.js",
9634
9639
  "mode": "0000644",
9635
- "sha256": "e4a9cdca849f840e277b0632217f9a8aec42d570d96373548f47d04bd1fce0bd"
9640
+ "sha256": "5aa7b8b121af462b9feba77bbd2a87b997e6dad22d3c8e6bf467ca122016d3d0"
9636
9641
  },
9637
9642
  {
9638
9643
  "path": "src/module-loader/catalog.js",
@@ -9727,7 +9732,7 @@
9727
9732
  {
9728
9733
  "path": "tests/adopt_preflight.mjs",
9729
9734
  "mode": "0000644",
9730
- "sha256": "125edcec8c7d7536365b9675daa1fceb9fe072a30e2a4a68ee2911cd258a8c10"
9735
+ "sha256": "2489a19e7c36f53c4046a4a6fab54a55dbdfe8da6daa3452316f9e0abea01742"
9731
9736
  },
9732
9737
  {
9733
9738
  "path": "tests/adr_renumber_integrity.mjs",
@@ -10052,7 +10057,7 @@
10052
10057
  {
10053
10058
  "path": "tests/bongos_login.mjs",
10054
10059
  "mode": "0000644",
10055
- "sha256": "f273c511d9c42093da170572a0db55f78cada57787e7631aefdd622228c2fad9"
10060
+ "sha256": "abad290fe23b159a6c42a70fbc6cec68bfc0406c90776a96b57513e9b4b8ef04"
10056
10061
  },
10057
10062
  {
10058
10063
  "path": "tests/box_access.mjs",
@@ -10272,7 +10277,7 @@
10272
10277
  {
10273
10278
  "path": "tests/claude_materialize.mjs",
10274
10279
  "mode": "0000644",
10275
- "sha256": "9ae022d9051937e80b0d3a4baf3840d57560fa9d441b93beff746cfa11871d35"
10280
+ "sha256": "8292d8c7a788e7839599d38f6c78a5dbb68f6ae50a58771a66e6481a562f0ace"
10276
10281
  },
10277
10282
  {
10278
10283
  "path": "tests/clawback_confirmed_backlog.mjs",
@@ -10572,7 +10577,7 @@
10572
10577
  {
10573
10578
  "path": "tests/discord_approvals.mjs",
10574
10579
  "mode": "0000644",
10575
- "sha256": "f5acf8bac78844a0015ecec64d3f85c0546bb3e186eabb19311cb9e33cd6dc64"
10580
+ "sha256": "1b4df0a8cd7dc48f393cd2c3ca7ec91759270c7df87a50397fb3d18d3b6a29fe"
10576
10581
  },
10577
10582
  {
10578
10583
  "path": "tests/discord_autojoin.mjs",
@@ -11557,7 +11562,7 @@
11557
11562
  {
11558
11563
  "path": "tests/init.mjs",
11559
11564
  "mode": "0000644",
11560
- "sha256": "4a79313db6d6393415ace3e24f985be397156c10845b6543fdfbda3944ddeec0"
11565
+ "sha256": "5b0eaf9cee1a51a4ff41197c4af844a0ba89e5a44f23d0f00de09e5ca8900f08"
11561
11566
  },
11562
11567
  {
11563
11568
  "path": "tests/instance_config.mjs",
@@ -11842,7 +11847,7 @@
11842
11847
  {
11843
11848
  "path": "tests/module_api.mjs",
11844
11849
  "mode": "0000644",
11845
- "sha256": "ac7d0fd4eb478b79cf9e72994f133e5d6bcca9caef3efd3a388779a18c58292d"
11850
+ "sha256": "53aa3732865dfce17a3216390b057dae825a6a7a8056309f3f31654c06636bee"
11846
11851
  },
11847
11852
  {
11848
11853
  "path": "tests/module_api_lazy.mjs",
@@ -12477,7 +12482,7 @@
12477
12482
  {
12478
12483
  "path": "tests/repo_info_binding.mjs",
12479
12484
  "mode": "0000644",
12480
- "sha256": "f6a95140fc8e99a7045cf8d3ccc41606fe911138fd915f40061437f2b2cc283a"
12485
+ "sha256": "384a494fab8da8cab77e1241ce8ebd954b658bcc56c578065c6f80bc0fd74f7d"
12481
12486
  },
12482
12487
  {
12483
12488
  "path": "tests/repo_map.mjs",
@@ -12742,7 +12747,7 @@
12742
12747
  {
12743
12748
  "path": "tests/setup_device_flow_fallback.mjs",
12744
12749
  "mode": "0000644",
12745
- "sha256": "f047b209ec3b0ea2fcffbee7c3b36d8cf01bb18e19cfcfa9d32267db672d834d"
12750
+ "sha256": "f59a1381d0f71f5eb25c8e691cda2347fc34ae2569db191ae074fe2f07c87f1a"
12746
12751
  },
12747
12752
  {
12748
12753
  "path": "tests/setup_smoke.mjs",
@@ -1922,5 +1922,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1922
1922
  landed since 1.19.722 with no explicit bump. run 34758720786. (task 1002620)
1923
1923
  1.19.724 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1924
1924
  landed since 1.19.723 with no explicit bump. run 34759302425. (task 1002620)
1925
+ 1.19.725 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1926
+ landed since 1.19.724 with no explicit bump. run 34760272743. (task 1002620)
1925
1927
  ---------------------------------------------------------------------------
1926
1928
  ```
@@ -48,12 +48,39 @@ async function postLine(channel, line, deps = {}) {
48
48
  }
49
49
  }
50
50
 
51
+ // Is the half that CLOSES this loop actually loaded? The line we post invites a 👍,
52
+ // and that reaction is only ever applied by modules/discord/discord-approvals.js —
53
+ // which lives in the `discord` module. onboarding is default-ON and discord is
54
+ // default-OFF, and nothing gated one on the other, so a default instance posted an
55
+ // approve/decline control with nothing listening: an Archon reacts, watches the
56
+ // reaction land, and believes they admitted someone. Nobody is told otherwise.
57
+ //
58
+ // A control that cannot work is worse than no message, so the outbound half now
59
+ // refuses to post without its inbound half. The hall queue is the source of truth
60
+ // either way, so nothing is lost — only a false affordance is withheld.
61
+ // Reached through the published doorway (the one core file a module may require,
62
+ // ADR 0083), never a direct require of src/modules.js. Fails CLOSED: if enablement
63
+ // can't be resolved we do not post, because that is the state we cannot vouch for.
64
+ function discordHandlerLoaded(deps = {}) {
65
+ try {
66
+ const isEnabled = deps.isModuleEnabled || require('../../src/module-api').isModuleEnabled;
67
+ return isEnabled('discord') === true;
68
+ } catch (_) {
69
+ return false;
70
+ }
71
+ }
72
+
51
73
  async function postMemberApprovalRequest(request, deps = {}) {
52
74
  if (!request || request.id == null) return { posted: false, skipped: 'no_request' };
75
+ if (!discordHandlerLoaded(deps)) {
76
+ (deps.log || console.log)('[gds] approval broadcast: skipped — the `discord` module is off, so no reaction handler would apply a 👍. Approve from the hall queue.');
77
+ return { posted: false, skipped: 'discord_module_off' };
78
+ }
53
79
  return postLine('member_approvals', memberApprovalLine(request), deps);
54
80
  }
55
81
 
56
82
  module.exports = {
57
83
  memberApprovalLine,
58
84
  postMemberApprovalRequest,
85
+ discordHandlerLoaded, // exported for unit tests — the gate is the whole fix
59
86
  };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.724",
3
+ "version": "1.19.725",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.724",
9
+ "version": "1.19.725",
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.724",
3
+ "version": "1.19.725",
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",
@@ -11,11 +11,20 @@
11
11
  // floor (ADR 0043, via gate-review.js's canonical matchesHardFloor).
12
12
  // • CI collisions — the repo's workflows vs core's expected unit/secret-scan/
13
13
  // gate-review checks.
14
+ // • IGNORED incoming files — a pre-existing .gitignore rule that would swallow what
15
+ // adopt is about to commit (task 1003400). This is the check that was missing when
16
+ // Cloud Bongos was adopted onto a repo whose .gitignore already carried `.claude/`:
17
+ // the skills, hooks and settings.json were materialized onto disk and then dropped
18
+ // by `git add`, so the adopt commit landed 8 files with no methodology surface at
19
+ // all. Nothing failed, nothing was logged, and every clone of that repo has been
20
+ // missing its /builder-* commands since. BLOCKING, because the result is a
21
+ // non-functional instance that looks fine.
14
22
  // READ-ONLY: it computes findings; the accept-gate that decides whether to proceed
15
23
  // lives in init.js runAdopt. Pure check functions + a thin fs-reading orchestrator.
16
24
 
17
25
  const nodeFs = require('node:fs');
18
26
  const nodePath = require('node:path');
27
+ const { execFileSync: nodeExecFileSync } = require('node:child_process');
19
28
  const { filenameViolation, INSTANCE_NS } = require('./migration-namespace');
20
29
  const { matchesHardFloor } = require('./gate-review');
21
30
 
@@ -79,10 +88,66 @@ function checkCiCollisions(workflows = []) {
79
88
  return [{ category: 'ci', severity: SEV.INFO, message: `${workflows.length} existing CI workflow(s); core will ADD its own unit/secret-scan/gate-review checks alongside them — confirm no required-check conflict.` }];
80
89
  }
81
90
 
91
+ // PURE — [{ path, rule }] of incoming files a .gitignore rule would swallow → findings.
92
+ // BLOCKING: an ignored incoming file is not a style problem, it is silent data loss at
93
+ // `git add` time, and the instance that results is broken in a way nothing reports.
94
+ function checkIgnoredIncoming(ignored = [], { checked = true } = {}) {
95
+ if (!checked) {
96
+ return [{ category: 'ignored-incoming', severity: SEV.INFO, message: 'could not run `git check-ignore` here (no git, or not a repo yet) — cannot confirm the incoming files are committable. Re-check after `git init` if adopt lands nothing under .claude/.' }];
97
+ }
98
+ if (!ignored.length) {
99
+ return [{ category: 'ignored-incoming', severity: SEV.INFO, message: 'no existing ignore rule swallows the files adopt writes — they will all reach the commit.' }];
100
+ }
101
+ const lines = ignored.map((i) => (i.rule ? `${i.path} (ignored by ${i.rule})` : i.path));
102
+ return [{
103
+ category: 'ignored-incoming',
104
+ severity: SEV.BLOCK,
105
+ message: `${ignored.length} file(s) adopt writes are IGNORED by this repo and would be silently dropped from the commit: ${lines.join('; ')}. `
106
+ + 'Adopt would appear to succeed and produce an instance missing that surface — a repo ignoring `.claude/` gets no /builder-* commands on any clone. '
107
+ + 'Fix the rule (delete it, or add a negation such as `!.claude/` after it), then re-run.',
108
+ }];
109
+ }
110
+
111
+ // Which of `incoming` does this repo's ignore rules match? Uses `git check-ignore -v`,
112
+ // which reports the SOURCE RULE for each match, so the finding can name the exact line
113
+ // the owner has to change rather than leaving them to hunt for it.
114
+ //
115
+ // Returns { ignored:[{path, rule}], checked:boolean }. `checked:false` means the probe
116
+ // could not run at all (no git, not a repo) — deliberately distinct from "nothing is
117
+ // ignored", because the two warrant different advice. execFileSync is injectable for
118
+ // tests; git exits 1 when nothing matches, which is a normal answer and not an error.
119
+ function probeIgnoredIncoming({ dir, incoming = INCOMING_PATHS, execFileSync = nodeExecFileSync } = {}) {
120
+ // Trailing slashes are ours (they mark dirs in the list); git wants plain pathnames.
121
+ const paths = incoming.map((p) => String(p).replace(/\/+$/, ''));
122
+ let out = '';
123
+ try {
124
+ out = execFileSync('git', ['check-ignore', '-v', '--stdin'], {
125
+ cwd: dir, input: paths.join('\n'), encoding: 'utf8', timeout: 5000,
126
+ stdio: ['pipe', 'pipe', 'ignore'],
127
+ }) || '';
128
+ } catch (err) {
129
+ // Exit 1 = "no path is ignored" (a real answer, stdout empty). Anything else means
130
+ // the probe itself could not run.
131
+ if (err && err.status === 1) return { ignored: [], checked: true };
132
+ return { ignored: [], checked: false };
133
+ }
134
+ const ignored = [];
135
+ for (const line of out.split('\n')) {
136
+ if (!line.trim()) continue;
137
+ // `<source>:<line>:<pattern>\t<pathname>`
138
+ const tab = line.lastIndexOf('\t');
139
+ if (tab === -1) continue;
140
+ const rule = line.slice(0, tab).trim();
141
+ const pathname = line.slice(tab + 1).trim();
142
+ ignored.push({ path: pathname, rule });
143
+ }
144
+ return { ignored, checked: true };
145
+ }
146
+
82
147
  // Read the repo (migrations, src/bongos, workflows) and run every check. `stack` is the
83
148
  // adopt-detect summary (task 2043); we re-read only what the checks need. Returns
84
149
  // { findings, counts:{blocking,warning,info}, hasBlocking }.
85
- function preflightAdopt({ dir, stack = {}, fs = nodeFs, path = nodePath } = {}) {
150
+ function preflightAdopt({ dir, stack = {}, fs = nodeFs, path = nodePath, execFileSync = nodeExecFileSync } = {}) {
86
151
  const listFiles = (rel) => { try { return fs.readdirSync(path.join(dir, rel)).filter((f) => { try { return fs.statSync(path.join(dir, rel, f)).isFile(); } catch { return false; } }); } catch { return []; } };
87
152
  const exists = (rel) => { try { return fs.existsSync(path.join(dir, rel)); } catch { return false; } };
88
153
 
@@ -105,6 +170,7 @@ function preflightAdopt({ dir, stack = {}, fs = nodeFs, path = nodePath } = {})
105
170
  ...checkFitnessBoundary({ topLevelDirs: stack.topLevelDirs || [], hasSrcBongos: exists('src/bongos') }),
106
171
  ...checkProtectedPaths(INCOMING_PATHS),
107
172
  ...checkCiCollisions(workflows),
173
+ ...(() => { const { ignored, checked } = probeIgnoredIncoming({ dir, execFileSync }); return checkIgnoredIncoming(ignored, { checked }); })(),
108
174
  ];
109
175
  const counts = { blocking: 0, warning: 0, info: 0 };
110
176
  for (const f of findings) counts[f.severity === SEV.BLOCK ? 'blocking' : f.severity === SEV.WARN ? 'warning' : 'info']++;
@@ -118,4 +184,4 @@ function formatPreflight(report) {
118
184
  return report.findings.map((f) => ` ${mark[f.severity] || '·'} [${f.category}] ${f.message}`);
119
185
  }
120
186
 
121
- module.exports = { preflightAdopt, formatPreflight, checkMigrationNamespace, checkFitnessBoundary, checkProtectedPaths, checkCiCollisions, INCOMING_PATHS, CORE_EXPECTED_CHECKS, SEV };
187
+ module.exports = { preflightAdopt, formatPreflight, checkMigrationNamespace, checkFitnessBoundary, checkProtectedPaths, checkCiCollisions, checkIgnoredIncoming, probeIgnoredIncoming, INCOMING_PATHS, CORE_EXPECTED_CHECKS, SEV };
@@ -51,6 +51,7 @@ const FILES = [
51
51
  'scripts/gds/login.js',
52
52
  'scripts/gds/reauth.js',
53
53
  'scripts/gds/setup.js',
54
+ 'scripts/gds/device-flow-help.js', // shared leaf: login.js + setup.js both require it (task 1003881)
54
55
  'scripts/gds/onboarding-config.js', // lazy dep of setup.js
55
56
  'scripts/gds/install-git-hooks.js', // lazy dep of setup.js
56
57
  'scripts/gds/preflight.js',
@@ -12,6 +12,14 @@
12
12
  // of a mature instance's CLAUDE.md — the instance grows its own. Coordinates with
13
13
  // task 1963 (neutralizing the core's own CLAUDE.md into the portable template).
14
14
  //
15
+ // FIRST-RUN SECTION. The template used to open by instructing the agent to run
16
+ // `bongos start` without ever saying the CLI ships inside an uninstalled dependency,
17
+ // so an agent opening a fresh clone followed its first instruction straight into
18
+ // `command not found`. The root README carries the same signpost for humans — but only
19
+ // when the repo had no README to begin with, which an ADOPTED repo always does. This
20
+ // file lands on BOTH paths and loads in full every session, so it is the one place the
21
+ // bootstrap is guaranteed to be read.
22
+ //
15
23
  // PURE (branding in → markdown out), so it unit-tests standalone and carries no
16
24
  // hardcoded host identity (everything comes from the branding object — R76-safe).
17
25
 
@@ -48,6 +56,16 @@ function renderInstanceClaudeMd(branding = {}) {
48
56
 
49
57
  Read this at the start of every session. Keep it lean — it loads in full every time.
50
58
 
59
+ ## First run — before any \`bongos\` command
60
+
61
+ **If \`node_modules/@bongos/core\` is missing, run \`npm install\` first.** The \`bongos\` CLI ships *inside* that dependency, so until the install completes there is no \`bongos\` command and every instruction below fails with \`command not found\`:
62
+
63
+ \`\`\`bash
64
+ npm install
65
+ \`\`\`
66
+
67
+ Once per machine, per clone — it is not committed (\`node_modules/\` is ignored on purpose), so a fresh \`git clone\` always needs it. If a \`bongos\` command then still misbehaves, \`bongos doctor\` checks the toolchain and session and prints the fix.
68
+
51
69
  ## What this is
52
70
 
53
71
  ${ident}
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // scripts/gds/device-flow-help.js — the one explanation of a Device-Flow refusal, shared
4
+ // by `bongos setup` and `bongos login` (task 1003400).
5
+ //
6
+ // WHY IT EXISTS AT ALL. GitHub creates an App from a manifest with Device Flow OFF and
7
+ // exposes no API to switch it on — only a checkbox in the App's settings. So on every
8
+ // wizard-born instance the documented terminal sign-in is hard-broken from birth, for
9
+ // every builder, until the owner ticks that box. Nothing in the product can do it for
10
+ // them; the least we can do is say so precisely, and name a route that works meanwhile.
11
+ //
12
+ // WHY IT IS ITS OWN FILE. setup.js grew the detector + the guidance first; login.js threw
13
+ // a bare "could not start sign-in (HTTP 424)" — a dead end sitting directly downstream of
14
+ // the CLI's OWN wrong-instance advice to run `bongos login`. setup.js already requires
15
+ // login.js for runDeviceFlow, so having login.js require setup.js back would close a
16
+ // require cycle. Both now depend on this leaf instead: one source of truth, no cycle.
17
+ //
18
+ // PURE — no fs, no network, no process state; both functions unit-test standalone.
19
+
20
+ // Does this /auth/device/start response carry GitHub's device_flow_disabled refusal?
21
+ //
22
+ // Matches on TEXT, not status, deliberately: the CLI talks to many core versions, and the
23
+ // server envelope moved from 502 to 424 (a 502 body was being eaten by the CDN). Both
24
+ // register. Must NOT match auth_not_configured or any other upstream failure — those have
25
+ // different cures, and a wrong cure is worse than a generic error.
26
+ function isDeviceFlowDisabled(start) {
27
+ if (!start || start.ok) return false;
28
+ const err = start.data && start.data.error;
29
+ const text = [
30
+ typeof err === 'string' ? err : '',
31
+ err && typeof err === 'object' ? `${err.code || ''} ${err.message || ''}` : '',
32
+ start.data && start.data.message ? String(start.data.message) : '',
33
+ ].join(' ');
34
+ return /device_flow_disabled/i.test(text);
35
+ }
36
+
37
+ // The plain-language guidance: what happened, the web route that works RIGHT NOW, and the
38
+ // owner's one-checkbox repair. Returns an array of lines (the callers join them).
39
+ //
40
+ // `finish` names the caller, because only the closing move differs: setup prompts for the
41
+ // token inline ("paste it below"), login has no such prompt and must tell you to run the
42
+ // one-liner instead. Everything above that step is genuinely shared and stays shared.
43
+ function deviceFlowFallbackLines(base, { finish = 'setup' } = {}) {
44
+ const settings = `${base}/builders/settings`;
45
+ const close = finish === 'login'
46
+ ? [
47
+ ' 3. Copy the one-liner it shows and run it in this shell — that writes',
48
+ ' the same session `bongos login` would have.',
49
+ ]
50
+ : [
51
+ ' 3. Copy the token it shows and paste it below (or run the printed',
52
+ ' one-liner in another shell, then re-run setup).',
53
+ ];
54
+ return [
55
+ '',
56
+ "This project's GitHub App has Device Flow switched off, so terminal sign-in",
57
+ 'cannot start. Apps created by the one-click setup begin this way — GitHub',
58
+ 'only offers the switch as a checkbox in the App settings; no setup flow can',
59
+ 'tick it automatically.',
60
+ '',
61
+ 'Sign in through the web instead (works right now):',
62
+ ` 1. Open ${settings} and sign in with GitHub.`,
63
+ ' 2. Under "CLI Access", click "Re-issue CLI token".',
64
+ ...close,
65
+ '',
66
+ 'To repair terminal sign-in for everyone (the project owner, once):',
67
+ ' GitHub → Settings → Developer settings → GitHub Apps → your app →',
68
+ ' check "Enable Device Flow" → Save. Web sign-in is unaffected either way.',
69
+ ];
70
+ }
71
+
72
+ module.exports = { isDeviceFlowDisabled, deviceFlowFallbackLines };
@@ -525,6 +525,62 @@ function buildReadme(branding = {}) {
525
525
  ].join('\n');
526
526
  }
527
527
 
528
+ // The START-HERE content as an APPENDABLE section, for a repo that already has a
529
+ // README of its own. Fenced by a stable marker so re-running init is idempotent and so
530
+ // an owner can find (and delete) the block we added.
531
+ //
532
+ // WHY this is not just buildReadme: an ADOPTED repo always has a README, and the
533
+ // write-if-absent rule below therefore skipped the first-run guide on every single adopt
534
+ // — silently, logging "already present — left untouched" as though nothing were owed.
535
+ // That removed the one human-facing instruction to run `npm install`, without which the
536
+ // repo has no `bongos` command at all. Appending mirrors how the adopt path already
537
+ // treats .gitignore: keep what the owner wrote, add only what the instance needs.
538
+ const README_MARKER = '<!-- cloud-bongos:start-here -->';
539
+
540
+ function buildReadmeSection(branding = {}) {
541
+ const guide = buildReadme(branding);
542
+ // Reuse the canonical guide, demoted one heading level so it nests under the owner's
543
+ // own README, and drop its H1 title (their README already has one).
544
+ const body = guide
545
+ .split('\n')
546
+ .slice(1) // drop "# <product>"
547
+ .join('\n')
548
+ .replace(/^## /gm, '### ')
549
+ .trim();
550
+ return `${README_MARKER}\n\n## Working on this project (Cloud Bongos)\n\n${body}\n`;
551
+ }
552
+
553
+ // Append the START-HERE section to an existing README unless it is already there.
554
+ // PURE (text in → text out) so the idempotence is unit-testable without fs.
555
+ // Returns { text, action } where action is 'appended' | 'present'.
556
+ function appendReadmeSection(existing, section) {
557
+ const prev = String(existing == null ? '' : existing);
558
+ if (prev.includes(README_MARKER)) return { text: prev, action: 'present' };
559
+ return { text: `${prev.replace(/\n*$/, '\n')}\n---\n\n${section}`, action: 'appended' };
560
+ }
561
+
562
+ // Put the first-run guide in the repo's README whichever state it is in: mint the whole
563
+ // guide when there is no README, else append the marked section. Shared by the greenfield
564
+ // and adopt paths so neither can silently owe it. Returns the action taken.
565
+ function layerReadme(branding, { dir, dryRun, log = console.log } = {}) {
566
+ const readmePath = path.join(dir, 'README.md');
567
+ if (!fs.existsSync(readmePath)) {
568
+ if (dryRun) { log(` [dry-run] would write README.md (first-run START-HERE)`); return 'created'; }
569
+ fs.writeFileSync(readmePath, buildReadme(branding));
570
+ log(` ✓ wrote README.md (first-run START-HERE)`);
571
+ return 'created';
572
+ }
573
+ const { text, action } = appendReadmeSection(fs.readFileSync(readmePath, 'utf8'), buildReadmeSection(branding));
574
+ if (action === 'present') {
575
+ log(' • README.md already carries the Cloud Bongos start-here section — left untouched.');
576
+ return 'present';
577
+ }
578
+ if (dryRun) { log(' [dry-run] would append the Cloud Bongos start-here section to the existing README.md'); return 'appended'; }
579
+ fs.writeFileSync(readmePath, text);
580
+ log(' ✓ appended the Cloud Bongos start-here section to README.md (your own content is untouched)');
581
+ return 'appended';
582
+ }
583
+
528
584
  function writeConfigs(spec, { dir, force, dryRun, coreDep, log = console.log }) {
529
585
  const configDir = path.join(dir, 'config');
530
586
  const brandingPath = path.join(configDir, 'branding.json');
@@ -572,14 +628,9 @@ function writeConfigs(spec, { dir, force, dryRun, coreDep, log = console.log })
572
628
  log(` ✓ wrote ${path.relative(dir, modulesPath)}`);
573
629
  log(` ✓ wrote ${path.relative(dir, hierarchyPath)}`);
574
630
  log(` ✓ wrote ${path.relative(dir, pkgPath)} (pins ${CORE_PKG} → ${pinNote})`);
575
- // README START-HERE (task 1002404): write-if-absent so a --force re-init or an
576
- // owner's own README is never clobbered.
577
- if (!fs.existsSync(readmePath)) {
578
- fs.writeFileSync(readmePath, buildReadme(branding));
579
- log(` ✓ wrote ${path.relative(dir, readmePath)} (first-run START-HERE)`);
580
- } else {
581
- log(` • ${path.relative(dir, readmePath)} already present — left untouched`);
582
- }
631
+ // README START-HERE (task 1002404): mint it when absent, else APPEND the marked
632
+ // section. Never a blind overwrite — an owner's own README content always survives.
633
+ layerReadme(branding, { dir, dryRun: false, log });
583
634
  return { brandingPath, modulesPath, hierarchyPath, pkgPath, readmePath, wrote: true };
584
635
  }
585
636
 
@@ -816,6 +867,10 @@ async function runAdopt(spec, { dir, dryRun, noSeed = false, accept = false, cor
816
867
 
817
868
  const cfg = layerConfigs(spec, { dir, dryRun, log });
818
869
  const pkg = layerPackageJson(spec, { dir, dryRun, log, ...(coreDep ? { coreRange: coreDep } : {}) });
870
+ // The first-run guide, which adopt used to skip entirely: an adopted repo always has a
871
+ // README, so the greenfield write-if-absent rule never fired and the one instruction to
872
+ // run `npm install` was silently owed on every adopt.
873
+ layerReadme(buildBrandingConfig(spec), { dir, dryRun, log });
819
874
 
820
875
  // Vendor the pinned core tarball + generate the lockfile, mirroring the greenfield
821
876
  // --vendor-core steps, so the adopted repo is `npm ci --omit=dev`-installable (ADR 0108).
@@ -1061,6 +1116,7 @@ module.exports = {
1061
1116
  provisioningSlug, provisioningRequest, productionTopologyKickoff,
1062
1117
  // --adopt (brownfield layering, ADR 0121 / task 2042)
1063
1118
  mergeCoreDependency, coreDepRange, layerConfigs, layerPackageJson, runAdopt,
1119
+ buildReadmeSection, appendReadmeSection, layerReadme, README_MARKER,
1064
1120
  KNOWN_MODULES, HOSTING_SHAPES, CORE_PKG,
1065
1121
  // greenfield installability (ADR 0108 gap 1/4, task 2053)
1066
1122
  buildInstancePackageJson, coreVersionSafe, INSTANCE_START_SCRIPT, INSTANCE_MIGRATE_SCRIPT,
@@ -23,6 +23,10 @@
23
23
  const readline = require('node:readline');
24
24
  const { spawnSync } = require('node:child_process');
25
25
  const { saveSession, loadStoredSession, listStoredSessions } = require('./cli-lib');
26
+ // The device-flow refusal detector + its plain-language cure (task 1003400). A leaf
27
+ // module, NOT setup.js: setup requires this file for runDeviceFlow, so requiring it
28
+ // back would close a require cycle. Both callers depend on the leaf instead.
29
+ const { isDeviceFlowDisabled, deviceFlowFallbackLines } = require('./device-flow-help');
26
30
  const { branding } = require('../../src/branding');
27
31
 
28
32
  // --- pure helpers (unit-tested) ---------------------------------------------
@@ -102,6 +106,20 @@ async function runDeviceFlow(base, { log, idp = null }) {
102
106
  if (start.status === 503 || (start.data && (start.data.error === 'auth_not_configured' || (start.data.error && start.data.error.code === 'idp_signing_unconfigured')))) {
103
107
  throw new Error('that instance has no sign-in configured yet — ask its Archon to set it up.');
104
108
  }
109
+ // Device Flow is OFF on every GitHub App born from a manifest, and GitHub exposes no
110
+ // API to enable it — so terminal sign-in is broken from birth on a fresh instance,
111
+ // for everyone, until its owner ticks one checkbox. This threw a bare
112
+ // "could not start sign-in (HTTP 424)", which is where the CLI's OWN wrong-instance
113
+ // refusal sends every multi-project user: a dead end at the end of our own advice.
114
+ // In federated mode the device flow runs against the HUB, so the cure is on the hub's
115
+ // app — point the guidance at whichever origin actually refused.
116
+ if (isDeviceFlowDisabled(start)) {
117
+ const refusedBy = idp ? idp.origin : base;
118
+ throw new Error([
119
+ 'GitHub refused the device-code request for this project.',
120
+ ...deviceFlowFallbackLines(refusedBy, { finish: 'login' }),
121
+ ].join('\n'));
122
+ }
105
123
  if (!start.ok || !start.data || !start.data.device_code) {
106
124
  throw new Error(`could not start sign-in (HTTP ${start.status}).`);
107
125
  }
@@ -274,45 +274,11 @@ async function promptOnboardingConsent() {
274
274
  }
275
275
 
276
276
  // ── W1: the manifest-born App has Device Flow OFF (task 1003051) ─────────────
277
- // GitHub creates an App from a manifest with Device Flow unchecked and offers
278
- // no API to enable it (only a settings checkbox), so on a fresh wizard-born
279
- // instance the documented CLI sign-in is hard-broken until the owner ticks the
280
- // box. Detect the proxied refusal (POST /auth/device/start surfaces GitHub's
281
- // device_flow_disabled task 2126's envelope) and fall back to the hall's
282
- // CLI-token path so setup still completes. Pure; exported for tests.
283
- function isDeviceFlowDisabled(start) {
284
- if (!start || start.ok) return false;
285
- const err = start.data && start.data.error;
286
- const text = [
287
- typeof err === 'string' ? err : '',
288
- err && typeof err === 'object' ? `${err.code || ''} ${err.message || ''}` : '',
289
- start.data && start.data.message ? String(start.data.message) : '',
290
- ].join(' ');
291
- return /device_flow_disabled/i.test(text);
292
- }
293
-
294
- // The plain-language fallback guidance (task 1003051): what happened, the web
295
- // path that works right now, and the one-checkbox owner fix. Pure; exported.
296
- function deviceFlowFallbackLines(base) {
297
- const settings = `${base}/builders/settings`;
298
- return [
299
- '',
300
- "This project's GitHub App has Device Flow switched off, so terminal sign-in",
301
- 'cannot start. Apps created by the one-click setup begin this way — GitHub',
302
- 'only offers the switch as a checkbox in the App settings; no setup flow can',
303
- 'tick it automatically.',
304
- '',
305
- 'Sign in through the web instead (works right now):',
306
- ` 1. Open ${settings} and sign in with GitHub.`,
307
- ' 2. Under "CLI Access", click "Re-issue CLI token".',
308
- ' 3. Copy the token it shows and paste it below (or run the printed',
309
- ' one-liner in another shell, then re-run setup).',
310
- '',
311
- 'To repair terminal sign-in for everyone (the project owner, once):',
312
- ' GitHub → Settings → Developer settings → GitHub Apps → your app →',
313
- ' check "Enable Device Flow" → Save. Web sign-in is unaffected either way.',
314
- ];
315
- }
277
+ // The detector + the guidance moved to ./device-flow-help (task 1003400) so
278
+ // `bongos login` can use the same words without closing a require cycle back
279
+ // through this file (setup requires login for runDeviceFlow). Re-exported here
280
+ // because both are part of setup.js's tested public surface.
281
+ const { isDeviceFlowDisabled, deviceFlowFallbackLines } = require('./device-flow-help');
316
282
 
317
283
  // Verify a hall-minted CLI token against /me and save the session (the
318
284
  // paste-token.js contract: nothing is written unless the token verifies).
@@ -12,10 +12,11 @@
12
12
  // transfers, the deploy's git remote changes and the served links follow,
13
13
  // with zero code edits required.
14
14
  //
15
- // Cached on first call. Falls back to env vars (OTB_GITHUB_OWNER /
16
- // OTB_GITHUB_REPO / OTB_GITHUB_BRANCH) for environments where git isn't
17
- // available (e.g. a stripped-down container deploy). Returns { error } when
18
- // neither source resolves, so callers can degrade gracefully.
15
+ // Cached on first call. Three sources, in order: env vars (OTB_GITHUB_OWNER /
16
+ // OTB_GITHUB_REPO / OTB_GITHUB_BRANCH the operator's explicit override), the git
17
+ // remote when this package IS the repo, then the instance's committed branding pack
18
+ // (config/branding.json `repo`) — the source a standalone instance actually has.
19
+ // Returns { error } when none resolves, so callers can degrade gracefully.
19
20
 
20
21
  const { execSync } = require('node:child_process');
21
22
  const path = require('node:path');
@@ -44,6 +45,34 @@ function loadFromEnv() {
44
45
  return null;
45
46
  }
46
47
 
48
+ // The instance's OWN declared GitHub binding, read from the committed branding pack
49
+ // (`config/branding.json` -> `repo: { owner, name }` — a first-class key in the
50
+ // branding contract). `bongos init` writes it at scaffold time, so a standalone
51
+ // instance carries the correct answer on disk from birth.
52
+ //
53
+ // WHY it exists: an installed core (ADR 0140) lives in node_modules/@bongos/core, so
54
+ // loadFromGit's anchor rule correctly returns null — and nothing sets
55
+ // <PREFIX>_GITHUB_OWNER/REPO on a provisioned instance. EVERY standalone instance
56
+ // therefore resolved no_repo_info and served a 503 from /public/repo-info, with public
57
+ // build status and every repo-linked surface dead. The identity was one file away the
58
+ // whole time, simply never read.
59
+ //
60
+ // Placed LAST in the chain on purpose: the env pin stays the operator's explicit
61
+ // override and git stays authoritative for the core dev checkout, so this source can
62
+ // only ever turn an ERROR into an answer — it can never change one that already
63
+ // resolved. Required lazily so module load order never depends on branding, and
64
+ // fail-soft: a malformed or absent pack must degrade to no_repo_info, not crash boot.
65
+ function loadFromBranding(deps = {}) {
66
+ try {
67
+ const read = deps.branding || require('../branding').branding;
68
+ const pack = read() || {};
69
+ const owner = pack.repo && String(pack.repo.owner || '').trim();
70
+ const name = pack.repo && String(pack.repo.name || '').trim();
71
+ if (owner && name) return { owner, repo: name };
72
+ } catch (_) { /* fall through — an unreadable pack is not an answer */ }
73
+ return null;
74
+ }
75
+
47
76
  // The directory this file's package occupies: <root>/src/bongos -> <root>. In the core
48
77
  // dev checkout that is the repo root; installed, it is node_modules/@bongos/core.
49
78
  const PACKAGE_ROOT = path.resolve(__dirname, '..', '..');
@@ -92,12 +121,13 @@ function loadFromGit(deps = {}) {
92
121
  function loadRepoInfo(deps = {}) {
93
122
  if (cached) return cached;
94
123
  const branch = ic.resolveEnv('GITHUB_BRANCH') || 'main';
95
- const parsed = loadFromEnv() || loadFromGit(deps);
124
+ const parsed = loadFromEnv() || loadFromGit(deps) || loadFromBranding(deps);
96
125
  if (!parsed) {
97
126
  cached = {
98
127
  error: 'no_repo_info',
99
128
  detail:
100
- 'git remote origin not resolvable from this package (an installed core does not sit inside the repo it publishes to) and <PREFIX>_GITHUB_OWNER/REPO unset',
129
+ 'git remote origin not resolvable from this package (an installed core does not sit inside the repo it publishes to), '
130
+ + 'config/branding.json has no repo.owner/repo.name, and <PREFIX>_GITHUB_OWNER/REPO unset',
101
131
  };
102
132
  return cached;
103
133
  }
@@ -123,5 +153,6 @@ module.exports = {
123
153
  loadRepoInfo,
124
154
  parseRemote,
125
155
  loadFromGit, // exported for unit tests — the anchor rule is the whole fix
156
+ loadFromBranding, // exported for unit tests — the standalone-instance source
126
157
  _resetCacheForTests,
127
158
  };
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.724'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.725'; // 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');
@@ -427,6 +427,13 @@ module.exports = {
427
427
  // directly. Resolved lazily so requiring the doorway never forces a config
428
428
  // read at module-load (BV1.R81 / ADR 0093 §1).
429
429
  enabledDisciplines: (...a) => require('./modules').enabledDisciplines(...a),
430
+ // isModuleEnabled(name) — is THIS module on for this instance? A module needs
431
+ // it when its own behaviour depends on ANOTHER module being present: onboarding
432
+ // posts a Discord approve/decline control whose 👍 is only ever applied by the
433
+ // `discord` module's reaction handler, so with discord off the control is a lie
434
+ // and must not be posted at all. Throws on an unknown name (a typo is a bug, not
435
+ // a silent false). Lazy, like its sibling above.
436
+ isModuleEnabled: (...a) => require('./modules').isModuleEnabled(...a),
430
437
 
431
438
  // --- the seam registry — how modules cooperate without importing each other.
432
439
  // Three primitives: PORTS (one required provider), EVENTS (many optional
@@ -101,5 +101,77 @@ t('preflightAdopt: a clean repo → no blocking; every category reports', () =>
101
101
  } finally { rmSync(dir, { recursive: true, force: true }); }
102
102
  });
103
103
 
104
+ // ---- ignored-incoming: the check that was missing (task 1003400) ----------
105
+ //
106
+ // The failure it exists to stop: adopt onto a repo whose .gitignore already carries
107
+ // `.claude/`. The skills/hooks/settings are written to disk, `git add` drops them, the
108
+ // commit looks clean, and every clone of that repo is missing its /builder-* commands
109
+ // with nothing anywhere saying why.
110
+
111
+ t('checkIgnoredIncoming: an ignored incoming file BLOCKS and names the rule to change', () => {
112
+ const f = pf.checkIgnoredIncoming([{ path: '.claude/skills', rule: '.gitignore:7:.claude/' }]);
113
+ assert.equal(f.length, 1);
114
+ assert.equal(f[0].severity, pf.SEV.BLOCK, 'silent data loss at git add is not a warning');
115
+ assert.match(f[0].message, /\.claude\/skills/, 'names the swallowed path');
116
+ assert.match(f[0].message, /\.gitignore:7/, 'names the exact rule line so the owner need not hunt');
117
+ });
118
+
119
+ t('checkIgnoredIncoming: nothing ignored → INFO, not blocking', () => {
120
+ const f = pf.checkIgnoredIncoming([]);
121
+ assert.equal(f[0].severity, pf.SEV.INFO);
122
+ assert.equal(f.filter((x) => x.severity === pf.SEV.BLOCK).length, 0);
123
+ });
124
+
125
+ t('checkIgnoredIncoming: probe could not run → INFO that says so, never a false all-clear', () => {
126
+ const f = pf.checkIgnoredIncoming([], { checked: false });
127
+ assert.equal(f[0].severity, pf.SEV.INFO);
128
+ assert.match(f[0].message, /could not run/, 'unknown must not be reported as clean');
129
+ });
130
+
131
+ t('probeIgnoredIncoming: parses git check-ignore -v into path + rule', () => {
132
+ const execFileSync = () => '.gitignore:7:.claude/\t.claude/skills\n.gitignore:7:.claude/\t.claude/hooks\n';
133
+ const { ignored, checked } = pf.probeIgnoredIncoming({ dir: '/x', execFileSync });
134
+ assert.equal(checked, true);
135
+ assert.deepEqual(ignored, [
136
+ { path: '.claude/skills', rule: '.gitignore:7:.claude/' },
137
+ { path: '.claude/hooks', rule: '.gitignore:7:.claude/' },
138
+ ]);
139
+ });
140
+
141
+ t('probeIgnoredIncoming: git exit 1 means NOTHING ignored — a real answer, not a failure', () => {
142
+ const execFileSync = () => { const e = new Error('exit 1'); e.status = 1; throw e; };
143
+ assert.deepEqual(pf.probeIgnoredIncoming({ dir: '/x', execFileSync }), { ignored: [], checked: true });
144
+ });
145
+
146
+ t('probeIgnoredIncoming: no git / not a repo → checked:false, distinct from "clean"', () => {
147
+ const execFileSync = () => { const e = new Error('not a git repository'); e.status = 128; throw e; };
148
+ assert.deepEqual(pf.probeIgnoredIncoming({ dir: '/x', execFileSync }), { ignored: [], checked: false });
149
+ });
150
+
151
+ t('probeIgnoredIncoming: strips our trailing dir slashes before handing paths to git', () => {
152
+ let input = null;
153
+ const execFileSync = (_cmd, _args, opts) => { input = opts.input; return ''; };
154
+ pf.probeIgnoredIncoming({ dir: '/x', incoming: ['.claude/hooks/', 'package.json'], execFileSync });
155
+ assert.equal(input, '.claude/hooks\npackage.json', 'git check-ignore wants plain pathnames');
156
+ });
157
+
158
+ t('preflightAdopt (end to end): mercury\'s .gitignore would have BLOCKED the adopt', () => {
159
+ const dir = mkdtempSync(join(tmpdir(), 'pf-ignored-'));
160
+ try {
161
+ // The exact rule that swallowed the methodology surface on the real repo.
162
+ writeFileSync(join(dir, '.gitignore'), '.env\nvenv/\n\n#claude code\n.claude/\n');
163
+ const report = pf.preflightAdopt({
164
+ dir,
165
+ stack: { migrationsDirs: [], topLevelDirs: [], ci: [] },
166
+ // Stand in for git so the case is hermetic (no repo needed in tmp).
167
+ execFileSync: () => '.gitignore:5:.claude/\t.claude/settings.json\n.gitignore:5:.claude/\t.claude/skills\n',
168
+ });
169
+ assert.ok(report.hasBlocking, 'adopt must stop rather than produce a silently broken instance');
170
+ const found = cats(report.findings, 'ignored-incoming');
171
+ assert.equal(found.length, 1);
172
+ assert.equal(found[0].severity, pf.SEV.BLOCK);
173
+ } finally { rmSync(dir, { recursive: true, force: true }); }
174
+ });
175
+
104
176
  console.log(`\nadopt_preflight: ${passed} passed, ${failed} failed`);
105
177
  process.exit(failed ? 1 : 0);
@@ -1,11 +1,20 @@
1
- // tests/bongos_login.mjs — pure helpers behind `bongos login <instance>`
2
- // (V4.R83 / task 1250). DB-free + network-free. Picked up by run-unit-tests.js.
1
+ // tests/bongos_login.mjs — the helpers and the failure surface behind
2
+ // `bongos login <instance>` (V4.R83 / task 1250). DB-free; the one end-to-end case
3
+ // drives the real CLI against a 127.0.0.1 stub, so it needs no network either.
4
+ // Picked up by run-unit-tests.js.
3
5
  import assert from 'node:assert/strict';
4
6
  import { test } from 'node:test';
5
7
  import { createRequire } from 'node:module';
8
+ import { spawn } from 'node:child_process';
9
+ import http from 'node:http';
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
6
14
 
7
15
  const require = createRequire(import.meta.url);
8
16
  const { normalizeInstanceBase, pollDecision } = require('../scripts/gds/login.js');
17
+ const LOGIN_JS = path.join(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'), 'scripts', 'gds', 'login.js');
9
18
 
10
19
  test('normalizeInstanceBase adds https, strips trailing slashes + a pasted /api/gds', () => {
11
20
  assert.equal(normalizeInstanceBase('amazonprimea.com'), 'https://amazonprimea.com');
@@ -32,3 +41,61 @@ test('pollDecision maps the /access-requests/status body to a loop action', () =
32
41
  assert.equal(pollDecision(null), 'wait');
33
42
  assert.equal(pollDecision({}), 'wait');
34
43
  });
44
+
45
+ // ---- the Device-Flow dead end (task 1003400) -------------------------------
46
+ //
47
+ // Device Flow is OFF on every GitHub App created from a manifest and GitHub offers no
48
+ // API to switch it on, so terminal sign-in is broken from birth on a fresh instance —
49
+ // for every builder. `bongos login` used to answer that with a bare
50
+ // "could not start sign-in (HTTP 424)": no cause, no cure, and sitting directly
51
+ // downstream of the CLI's own wrong-instance advice to run `bongos login`.
52
+ //
53
+ // Drives the REAL CLI against a stub replaying the exact body a live instance returned.
54
+
55
+ test('login on a Device-Flow-disabled instance explains the cause and the cure', async () => {
56
+ const server = http.createServer((req, res) => {
57
+ const send = (code, body) => {
58
+ res.writeHead(code, { 'Content-Type': 'application/json' });
59
+ res.end(JSON.stringify(body));
60
+ };
61
+ if (req.url.endsWith('/api/gds/instance')) {
62
+ return send(200, { schema: 'instance-manifest/v1', name: 'Stub', admission: { policy: 'invite-only' } });
63
+ }
64
+ if (req.url.endsWith('/api/gds/auth/device/start')) {
65
+ // Byte-for-byte what a real instance returned when its App had the box unticked.
66
+ return send(424, { error: { code: 'device_start_failed', message: 'device/code failed (400): {"error":"device_flow_disabled","error_description":"Device Flow must be explicitly enabled for this App"}' } });
67
+ }
68
+ return send(404, { error: { code: 'not_found' } });
69
+ });
70
+ server.listen(0, '127.0.0.1');
71
+ await new Promise((r) => server.once('listening', r));
72
+ const base = `http://127.0.0.1:${server.address().port}`;
73
+ // A throwaway HOME so the run can never read or write the real session.
74
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'login-df-'));
75
+
76
+ // spawn, not execFileSync: the stub server lives in THIS process, so a synchronous
77
+ // child would block the event loop that has to answer it — a deadlock, not a failure.
78
+ let out = '';
79
+ try {
80
+ out = await new Promise((resolve, reject) => {
81
+ const child = spawn(process.execPath, [LOGIN_JS, base], {
82
+ stdio: ['ignore', 'pipe', 'pipe'],
83
+ env: { ...process.env, HOME: home, USERPROFILE: home },
84
+ });
85
+ let buf = '';
86
+ child.stdout.on('data', (d) => { buf += d; });
87
+ child.stderr.on('data', (d) => { buf += d; });
88
+ const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('login did not exit')); }, 30000);
89
+ child.on('close', () => { clearTimeout(timer); resolve(buf); });
90
+ child.on('error', (e) => { clearTimeout(timer); reject(e); });
91
+ });
92
+ } finally {
93
+ server.close();
94
+ fs.rmSync(home, { recursive: true, force: true });
95
+ }
96
+
97
+ assert.doesNotMatch(out, /could not start sign-in \(HTTP 424\)/, 'the bare dead end is gone');
98
+ assert.match(out, /Device Flow switched off/, 'names the actual cause');
99
+ assert.match(out, /Enable Device Flow/, 'names the owner\'s one-checkbox repair');
100
+ assert.match(out, /works right now/, 'names a route that works while the owner fixes it');
101
+ });
@@ -510,6 +510,15 @@ t('renderInstanceClaudeMd: brand-substituted — names the instance + its hall/c
510
510
  assert.ok(!md.toLowerCase().includes('amazonprimea') && !md.includes('~/.config/otb'), 'no founder literal in the starter');
511
511
  });
512
512
 
513
+ t('renderInstanceClaudeMd: the first-run bootstrap precedes every bongos instruction', () => {
514
+ const md = tmpl.renderInstanceClaudeMd({ identity: { productName: 'Acme' } });
515
+ const iNpm = md.indexOf('npm install');
516
+ const iStart = md.indexOf('bongos start');
517
+ assert.ok(iNpm > 0, 'names npm install at all — without it a fresh clone has no bongos command');
518
+ assert.ok(iStart > iNpm, 'npm install comes BEFORE the first bongos instruction, or the agent hits command not found');
519
+ assert.ok(/@bongos\/core/.test(md), 'explains WHY: the CLI ships inside the dependency');
520
+ });
521
+
513
522
  t('renderInstanceClaudeMd: degrades cleanly on a minimal branding (no world dup, defaults currency)', () => {
514
523
  const md = tmpl.renderInstanceClaudeMd({ identity: { productName: 'Solo', worldName: 'Solo' } });
515
524
  assert.ok(md.startsWith('# Solo — Project Memory'));
@@ -170,5 +170,50 @@ await t('a DB error returns {error}, never throws', async () => {
170
170
  assert.equal(r.outcome, 'error');
171
171
  });
172
172
 
173
+ // ---- the outbound half must not outlive the inbound half (task 1003400) ----
174
+ //
175
+ // The 👍 this line invites is applied ONLY by discord-approvals.js, which lives in the
176
+ // `discord` module. onboarding (which posts) is default-ON; discord is default-OFF; and
177
+ // nothing gated one on the other. A default instance therefore posted a working-looking
178
+ // approve/decline control into a channel where no handler was listening — an Archon
179
+ // reacts, sees it land, and believes they admitted a builder who is still locked out.
180
+
181
+ await t('discord module OFF → the control is NOT posted', async () => {
182
+ let posted = false;
183
+ const r = await bcast.postMemberApprovalRequest(
184
+ { id: 7, github_login: 'carol' },
185
+ { postWebhook: async () => { posted = true; }, log: () => {}, isModuleEnabled: () => false },
186
+ );
187
+ assert.equal(posted, false, 'a 👍 nobody applies must never be invited');
188
+ assert.equal(r.skipped, 'discord_module_off');
189
+ assert.equal(r.posted, false);
190
+ });
191
+
192
+ await t('discord module ON → the control posts exactly as before', async () => {
193
+ let line = null;
194
+ const r = await bcast.postMemberApprovalRequest(
195
+ { id: 7, github_login: 'carol' },
196
+ { postWebhook: async (_ch, l) => { line = l; }, log: () => {}, isModuleEnabled: () => true },
197
+ );
198
+ assert.equal(r.posted, true, 'the working path is untouched');
199
+ assert.deepEqual(ap.parseApprovalMarker(line), { kind: 'member', id: 7 }, 'and still round-trips');
200
+ });
201
+
202
+ await t('enablement unresolvable → fails CLOSED (no post)', async () => {
203
+ let posted = false;
204
+ const r = await bcast.postMemberApprovalRequest(
205
+ { id: 7, github_login: 'carol' },
206
+ { postWebhook: async () => { posted = true; }, log: () => {}, isModuleEnabled: () => { throw new Error('config unreadable'); } },
207
+ );
208
+ assert.equal(posted, false, 'a state we cannot vouch for is not a state we post into');
209
+ assert.equal(r.skipped, 'discord_module_off');
210
+ });
211
+
212
+ await t('the gate is reachable through the published doorway, not a direct core require', async () => {
213
+ const doorway = require('../src/module-api.js');
214
+ assert.equal(typeof doorway.isModuleEnabled, 'function', 'ADR 0083: modules read enablement here');
215
+ assert.throws(() => doorway.isModuleEnabled('no-such-module'), /unknown module/, 'a typo must be a bug, not a silent false');
216
+ });
217
+
173
218
  console.log(`\n${passed} passed, ${failed} failed`);
174
219
  process.exit(failed ? 1 : 0);
package/tests/init.mjs CHANGED
@@ -535,10 +535,60 @@ test('writeConfigs (greenfield): writes a root README START-HERE, never clobberi
535
535
  const md = readFileSync(res.readmePath, 'utf8');
536
536
  assert.ok(/## Start here/.test(md) && md.includes('npm install'), 'is the START-HERE guide');
537
537
 
538
- // A --force re-init must NOT clobber a README the owner has since edited.
538
+ // A --force re-init must NOT clobber a README the owner has since edited. It APPENDS
539
+ // the start-here section instead of skipping (the old behaviour silently owed the
540
+ // `npm install` instruction to every repo that already had a README).
539
541
  writeFileSync(res.readmePath, '# my own readme\n');
540
542
  init.writeConfigs(fullSpec(), { dir, force: true, dryRun: false, log: () => {} });
541
- assert.equal(readFileSync(res.readmePath, 'utf8'), '# my own readme\n', 'existing README left untouched on --force');
543
+ const after = readFileSync(res.readmePath, 'utf8');
544
+ assert.ok(after.startsWith('# my own readme\n'), 'the owner\'s own README content is never clobbered');
545
+ assert.ok(after.includes('npm install'), 'and the first-run instruction is still delivered');
546
+ } finally {
547
+ rmSync(dir, { recursive: true, force: true });
548
+ }
549
+ });
550
+
551
+ test('appendReadmeSection: appends once, then is idempotent', () => {
552
+ const section = init.buildReadmeSection({ identity: { productName: 'Mercury' } });
553
+ const own = '# hermes_inventory_management\nInventory Management\n';
554
+ const first = init.appendReadmeSection(own, section);
555
+ assert.equal(first.action, 'appended');
556
+ assert.ok(first.text.startsWith(own), 'the owner\'s content stays at the top, byte-identical');
557
+ assert.ok(first.text.includes('npm install'), 'delivers the first-run instruction');
558
+
559
+ const second = init.appendReadmeSection(first.text, section);
560
+ assert.equal(second.action, 'present', 're-running init must not stack duplicate sections');
561
+ assert.equal(second.text, first.text, 'and must not rewrite the file at all');
562
+ });
563
+
564
+ test('buildReadmeSection: nests under the owner\'s README — no second H1', () => {
565
+ const section = init.buildReadmeSection({ identity: { productName: 'Mercury' } });
566
+ assert.ok(!/^# /m.test(section), 'no H1 that would compete with the owner\'s title');
567
+ assert.ok(section.includes(init.README_MARKER), 'carries the marker that makes re-runs idempotent');
568
+ });
569
+
570
+ test('layerReadme (adopt): an existing README GETS the start-here section, not a skip', () => {
571
+ const dir = mkdtempSync(join(tmpdir(), 'adopt-readme-'));
572
+ try {
573
+ // The exact shape that broke: a brownfield repo whose own README predates adoption.
574
+ writeFileSync(join(dir, 'README.md'), '# hermes_inventory_management\nInventory Management\n');
575
+ const action = init.layerReadme({ identity: { productName: 'Mercury' } }, { dir, dryRun: false, log: () => {} });
576
+ assert.equal(action, 'appended');
577
+ const md = readFileSync(join(dir, 'README.md'), 'utf8');
578
+ assert.ok(md.startsWith('# hermes_inventory_management'), 'owner content preserved');
579
+ assert.ok(md.includes('npm install'), 'THE FIX: adopt no longer silently owes the first-run guide');
580
+ } finally {
581
+ rmSync(dir, { recursive: true, force: true });
582
+ }
583
+ });
584
+
585
+ test('layerReadme (dry-run): reports the append but writes nothing', () => {
586
+ const dir = mkdtempSync(join(tmpdir(), 'adopt-readme-dry-'));
587
+ try {
588
+ writeFileSync(join(dir, 'README.md'), '# mine\n');
589
+ const action = init.layerReadme({ identity: { productName: 'Mercury' } }, { dir, dryRun: true, log: () => {} });
590
+ assert.equal(action, 'appended');
591
+ assert.equal(readFileSync(join(dir, 'README.md'), 'utf8'), '# mine\n', 'dry-run writes nothing');
542
592
  } finally {
543
593
  rmSync(dir, { recursive: true, force: true });
544
594
  }
@@ -48,6 +48,7 @@ const PUBLISHED_SURFACE = [
48
48
  'artistGate', // added by task 1003575 (ADR 0241): the artist gate — off | advisory | strict, plus the pure verdict helpers over it. Two modules ask it two different questions ("file this review?" and "does an open one hold the deploy?") and both must resolve the knob through this one reader, never branding().project
49
49
  'readApplicantProfileFromHub', // added by task 1002972 (privacy spec D7): the reviewer queue's LIVE per-render read of one applicant's hub profile view. NARROW by design — the underlying call is authenticated with this instance's hub client secret, and loadIdpConfig stays OFF the doorway so no module ever holds the credential that speaks for the whole project
50
50
  'enabledDisciplines', // BV1.R81: instance offered-disciplines (1.9.0; onboarding restock backstop)
51
+ 'isModuleEnabled', // task 1003400: is ANOTHER module present? onboarding's Discord approve/decline control is only actionable when the `discord` module's reaction handler is loaded, so the posting half has to be able to ask
51
52
  'registerProvider', 'hasProvider', 'resolve', 'resolveOptional', 'listPorts', 'verifyPortsSatisfied',
52
53
  'on', 'emit', 'emitAsync', 'listEvents', 'seamSnapshot',
53
54
  'contribute', 'contributions', 'collectContributions', 'listContributionPoints',
@@ -208,6 +208,88 @@ test('a good bind still produces the blob/tree bases callers rely on', () => {
208
208
  }
209
209
  });
210
210
 
211
+ // ---- loadFromBranding: the source a standalone instance actually has -------
212
+ //
213
+ // An installed core sits in node_modules/@bongos/core, so loadFromGit correctly refuses
214
+ // and nothing sets the env pin — which meant EVERY standalone instance served a 503 from
215
+ // /public/repo-info while `config/branding.json` held the answer. These cases pin the new
216
+ // source AND, just as importantly, pin that it did not disturb the two above it.
217
+
218
+ const PACK = { repo: { owner: FIXTURE_OWNER, name: FIXTURE_REPO } };
219
+
220
+ test('a branding pack with owner + name binds (repo.name maps to repo)', () => {
221
+ assert.deepEqual(ri.loadFromBranding({ branding: () => PACK }), {
222
+ owner: FIXTURE_OWNER,
223
+ repo: FIXTURE_REPO,
224
+ });
225
+ });
226
+
227
+ test('the neutral pack (empty strings) is NOT an answer', () => {
228
+ assert.equal(ri.loadFromBranding({ branding: () => ({ repo: { owner: '', name: '' } }) }), null);
229
+ assert.equal(ri.loadFromBranding({ branding: () => ({ repo: { owner: ' ', name: FIXTURE_REPO } }) }), null);
230
+ });
231
+
232
+ test('a pack with no repo key at all → null', () => {
233
+ assert.equal(ri.loadFromBranding({ branding: () => ({ identity: { productName: 'x' } }) }), null);
234
+ assert.equal(ri.loadFromBranding({ branding: () => null }), null);
235
+ });
236
+
237
+ test('a THROWING branding loader degrades to null, never a crash', () => {
238
+ assert.equal(ri.loadFromBranding({ branding: () => { throw new Error('malformed pack'); } }), null);
239
+ });
240
+
241
+ test('THE FIX: packaged instance, no env, no git → branding answers instead of 503', () => {
242
+ ri._resetCacheForTests();
243
+ try {
244
+ const root = path.resolve('/srv/instance/node_modules/@bongos/core');
245
+ const info = ri.loadRepoInfo({
246
+ execSync: mkExec({ toplevel: path.resolve('/srv/instance') }), // git walks up → refused
247
+ packageRoot: root,
248
+ branding: () => PACK,
249
+ });
250
+ assert.equal(info.error, undefined, 'the instance declares its own repo — that is not an error case');
251
+ assert.equal(info.owner, FIXTURE_OWNER);
252
+ assert.equal(info.repo, FIXTURE_REPO);
253
+ assert.equal(info.blob_base, `https://github.com/${FIXTURE_OWNER}/${FIXTURE_REPO}/blob/main`);
254
+ } finally {
255
+ ri._resetCacheForTests();
256
+ }
257
+ });
258
+
259
+ test('git still WINS over branding (the core dev checkout is unchanged)', () => {
260
+ ri._resetCacheForTests();
261
+ try {
262
+ const root = path.resolve('/srv/cloud-bongos');
263
+ const info = ri.loadRepoInfo({
264
+ execSync: mkExec({ toplevel: root }), // the remote resolves → git answers
265
+ packageRoot: root,
266
+ branding: () => ({ repo: { owner: 'packowner', name: 'packrepo' } }),
267
+ });
268
+ assert.equal(info.owner, FIXTURE_OWNER, 'branding must never override a resolved git remote');
269
+ } finally {
270
+ ri._resetCacheForTests();
271
+ }
272
+ });
273
+
274
+ test('the env pin still WINS over branding (the operator override stays on top)', () => {
275
+ ri._resetCacheForTests();
276
+ process.env.OTB_GITHUB_OWNER = 'envowner';
277
+ process.env.OTB_GITHUB_REPO = 'envrepo';
278
+ try {
279
+ const root = path.resolve('/srv/instance/node_modules/@bongos/core');
280
+ const info = ri.loadRepoInfo({
281
+ execSync: mkExec({ toplevel: path.resolve('/srv/instance') }),
282
+ packageRoot: root,
283
+ branding: () => PACK,
284
+ });
285
+ assert.equal(info.owner, 'envowner');
286
+ } finally {
287
+ delete process.env.OTB_GITHUB_OWNER;
288
+ delete process.env.OTB_GITHUB_REPO;
289
+ ri._resetCacheForTests();
290
+ }
291
+ });
292
+
211
293
  // ---- end-to-end canary: the regression this must never cause ---------------
212
294
  //
213
295
  // The only case that touches real git. It earns its place — an anchor rule that is too
@@ -91,6 +91,22 @@ t('names the Settings page, CLI Access, and the owner checkbox repair', () => {
91
91
  assert.match(text, /Web sign-in is unaffected/i, 'says what still works');
92
92
  });
93
93
 
94
+ t('the closing step adapts to the caller — setup pastes, login runs a one-liner', () => {
95
+ const forSetup = setup.deviceFlowFallbackLines('https://spike.cloudbongos.com').join('\n');
96
+ const forLogin = setup.deviceFlowFallbackLines('https://spike.cloudbongos.com', { finish: 'login' }).join('\n');
97
+
98
+ // The default must be byte-stable — setup's own prompt still says "paste it below".
99
+ assert.match(forSetup, /paste it below/, 'setup keeps its inline-paste wording');
100
+ assert.doesNotMatch(forLogin, /paste it below/, '`bongos login` has no paste prompt to point at');
101
+ assert.match(forLogin, /run it in this shell/, 'login tells you what actually works there');
102
+
103
+ // Everything that is genuinely shared stays shared — one source of truth.
104
+ for (const shared of [/Enable Device Flow/, /CLI Access/, /builders\/settings/]) {
105
+ assert.match(forSetup, shared);
106
+ assert.match(forLogin, shared);
107
+ }
108
+ });
109
+
94
110
  // ── redeemCliToken in a hermetic child against a stubbed instance API ────────
95
111
  // A child process because API_BASE + the session path resolve at module load:
96
112
  // the child gets GDS_API_BASE → the stub and HOME/USERPROFILE → a throwaway