@bongos/core 1.21.148 → 1.21.150

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,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.21.148",
6
- "core_contract": "1.21.148",
7
- "source_commit": "96de3db7b01db9d1d1ea6de0020e2863549ace48",
5
+ "core_version": "1.21.150",
6
+ "core_contract": "1.21.150",
7
+ "source_commit": "0c869afc46eb5a0841a9406c43fa41f8031c01ad",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-10-19T16:38:35.780Z",
9
+ "built_at": "2026-10-19T22:01:23.493Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 608,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 3805,
20
- "tree_sha256": "9aa795e140a79d60c77adddcf814eb815324f8778aa3e839f2eed0d221aba834",
20
+ "tree_sha256": "e7a8a5514591ba5b313a0cc54239367bcfb3f92d0dce344582653a125ecb5d21",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -42,7 +42,7 @@
42
42
  {
43
43
  "path": ".claude/skills/bongos-feedback/SKILL.md",
44
44
  "mode": "0000644",
45
- "sha256": "14b448ca3b42daa4b97d7d699099536047163d50f778f079daa6616cd9b0dd51"
45
+ "sha256": "8b295d76d7cc4c9e0f2549db0f1ac857aa8e76a92b3e00898f95820a32c855ba"
46
46
  },
47
47
  {
48
48
  "path": ".claude/skills/bug-triage/SKILL.md",
@@ -2927,7 +2927,7 @@
2927
2927
  {
2928
2928
  "path": "docs/module-api-changelog.md",
2929
2929
  "mode": "0000644",
2930
- "sha256": "8f019f26dca4f3a5b624cbabd6d28976deef91faab61279479708ab9585b3fc9"
2930
+ "sha256": "42b00f22670c0c8aa194a2a7c3f22f3bcc9e4a444ff54c398c1254c06e6d5718"
2931
2931
  },
2932
2932
  {
2933
2933
  "path": "docs/modules-contract.md",
@@ -7737,7 +7737,7 @@
7737
7737
  {
7738
7738
  "path": "modules/lifecycle/ship-assurance.js",
7739
7739
  "mode": "0000644",
7740
- "sha256": "690171d9e4e7d82537cf1d030d98aa37ee0ac72bbcdce6a04429d3d49ecbe46a"
7740
+ "sha256": "c4dd044485e664528c01caea0500dd6fe9d8ee9d2319385000d1cee26e32102e"
7741
7741
  },
7742
7742
  {
7743
7743
  "path": "modules/lifecycle/ship-card.js",
@@ -10552,12 +10552,12 @@
10552
10552
  {
10553
10553
  "path": "package-lock.json",
10554
10554
  "mode": "0000644",
10555
- "sha256": "c5456eee2c0d7c56f4a86b537ad4a056d7ee25178017ce8b2c4a6d584a700b91"
10555
+ "sha256": "d0e2fcb3573e72ebc1d3ba608eeac029763e7fb2f8fad0f459cca42759bb6c5b"
10556
10556
  },
10557
10557
  {
10558
10558
  "path": "package.json",
10559
10559
  "mode": "0000644",
10560
- "sha256": "eeee21b5c79606fce2ec960818bbca2bda46e8487e821e086db727725dd34483"
10560
+ "sha256": "5348abba02d8b98849a7a6cf83a44f7d3138f00a806edb89ae2616618b15a559"
10561
10561
  },
10562
10562
  {
10563
10563
  "path": "public-docs/index.html",
@@ -10577,7 +10577,7 @@
10577
10577
  {
10578
10578
  "path": "release-notes.json",
10579
10579
  "mode": "0000644",
10580
- "sha256": "e75ce28a56ae19d8e908bca8b07e539fcb4066b72fb7e6f282e8dff5803f22b4"
10580
+ "sha256": "eaf55ec677f00c2212a9fb00f680130dc60974d8e2a199a97337048c567d2706"
10581
10581
  },
10582
10582
  {
10583
10583
  "path": "scripts/cron/style_guide_audit.sh",
@@ -11102,7 +11102,7 @@
11102
11102
  {
11103
11103
  "path": "scripts/gds/feedback-send.js",
11104
11104
  "mode": "0000644",
11105
- "sha256": "2ea43986744a893b0f44954926447a4c817fae8af8ef4956d149b31ea51e96c2"
11105
+ "sha256": "e3c82d95c9412126d0c064d40267c7ae7befbc3cebdba99c496c884dceb4a750"
11106
11106
  },
11107
11107
  {
11108
11108
  "path": "scripts/gds/fetch-art-key.js",
@@ -12957,7 +12957,7 @@
12957
12957
  {
12958
12958
  "path": "src/module-api.js",
12959
12959
  "mode": "0000644",
12960
- "sha256": "2b61d02ebd3b94b723ccec869d7d6432ce9c035c5135259cf6e709759622f0af"
12960
+ "sha256": "6ff0eaf959df5d39f838ae2b19a960f7f37552e9eedcb91b183b09b38ab6c840"
12961
12961
  },
12962
12962
  {
12963
12963
  "path": "src/module-loader/catalog.js",
@@ -16097,7 +16097,7 @@
16097
16097
  {
16098
16098
  "path": "tests/instance_feedback.mjs",
16099
16099
  "mode": "0000644",
16100
- "sha256": "704a506419ff49032b9604dc039113eaa63db68ac7a57fa09e09162ce7acfae5"
16100
+ "sha256": "a0a558b9d9e0698fa4b6078a2f2e4e2d9679e517149876809c1478743db0f6ff"
16101
16101
  },
16102
16102
  {
16103
16103
  "path": "tests/instance_manifest.mjs",
@@ -18102,7 +18102,7 @@
18102
18102
  {
18103
18103
  "path": "tests/ship_assurance.mjs",
18104
18104
  "mode": "0000644",
18105
- "sha256": "ca6e0100049ad284c8467fe4775ed9db4578f11c0704091043dda5e2c25654cf"
18105
+ "sha256": "be68b8dbf514937f581dfa3a892958fd4f16e20dd319d0ed70bf08811a3451e3"
18106
18106
  },
18107
18107
  {
18108
18108
  "path": "tests/ship_autolinkify.mjs",
@@ -14,7 +14,7 @@ Sends a note about Bongos — the tool — straight to its maintainers (tasks 10
14
14
 
15
15
  **When.** File when the builder (a) labels it — "Bongos feedback: …", "tell the Bongos team" — that **is** the go; (b) wishes — "Bongos should…" (**Suggestions count**: send with `--idea`, even if you also fix it in their project); or (c) vents that Bongos is broken — **File it without being asked**, don't argue them out of it. Only about **Bongos itself** (a `bongos`/`/builder-*` command, claim, ship, grade, deploy, upgrade, hall, Settings, sign-in, credits, how it organises work). Not their own project's code, Claude, their laptop or GitHub, nor a rule working as designed. **One filing per distinct problem per session.**
16
16
 
17
- **Write it yourself — Never paste the rant.** First line = what went wrong, in at most twelve words (it becomes the title); then what they did, what they expected, what happened, the exact error line. A wish (`--idea`) asks the maintainers to decide something, so it opens with the card `node scripts/gds/ask.js check <file>` prints, and the rest follows as detail (agents ask like humans, task 1004704). No code, file contents, secrets or personal data.
17
+ **Write it yourself — Never paste the rant.** First line = what went wrong, in at most twelve words (it becomes the title); then what they did, what they expected, what happened, the exact error line. A wish (`--idea`) asks the maintainers to decide something, so open it as an ask (task 1004704): one question of at most twelve words, two or three answers with what each leads to, your pick and why; the rest follows as detail. Write that shape yourself — run no helper first: a project on an older core may not have one, and a failed command before the send is noise (task 1004759). No code, file contents, secrets or personal data.
18
18
 
19
19
  **Send** (stdin, so nothing in the text is mangled by the shell):
20
20
 
@@ -24,7 +24,7 @@ bongos feedback --message-file - <<'EOF'
24
24
  EOF
25
25
  ```
26
26
 
27
- Add `--idea` for a wish. The command prints one line — relay it, then carry on. Proactive filing: one short sentence, e.g. *"I've sent that to the Bongos team."* If it says the project runs only on this computer, tell the builder in one line; never retry.
27
+ Add `--idea` for a wish. The command prints one line — relay it, then carry on. Proactive filing: one short sentence, e.g. *"I've sent that to the Bongos team."* If it says "Not sent", relay that in one line and never retry: "runs only on your computer" and "older Bongos" both mean this project cannot send yet, and the second names the file the note is saved in — send it with `--message-file <that file>` after the project is upgraded. Don't send the builder to GitHub or "the Bongos team" elsewhere instead, and don't call it sent.
28
28
 
29
29
  ## Files this skill touches
30
30
 
@@ -3133,5 +3133,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
3133
3133
  landed since 1.21.146 with no explicit bump. run 39274828902. (task 1002620)
3134
3134
  1.21.148 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3135
3135
  landed since 1.21.147 with no explicit bump. run 39286989989. (task 1002620)
3136
+ 1.21.149 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3137
+ landed since 1.21.148 with no explicit bump. run 39329687123. (task 1002620)
3138
+ 1.21.150 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3139
+ landed since 1.21.149 with no explicit bump. run 39331860470. (task 1002620)
3136
3140
  ---------------------------------------------------------------------------
3137
3141
  ```
@@ -67,7 +67,13 @@ function normalizeShipAssurance(a) {
67
67
  //
68
68
  // `deps` is injected so this is unit-testable with no network and no DB.
69
69
  async function deriveShipAssurance({ taskId, body = {}, db, githubPush, onProbeError }) {
70
- const report = (e) => { if (onProbeError) onProbeError(e); };
70
+ // Every probe miss is kept, not just logged: when the ONLY reason there is no
71
+ // proof is that this server cannot read GitHub at all, the refusal must say so
72
+ // (task 1004760). The toast instance merged real work and was told only that the
73
+ // land was "not proven" — its GitHub App existed but was never installed on the
74
+ // repo, which is a one-click owner fix nobody was told about.
75
+ const probeErrors = [];
76
+ const report = (e) => { probeErrors.push(e); if (onProbeError) onProbeError(e); };
71
77
  // The tip is the durable proof (task 1547); the branch/PR status is only a
72
78
  // WITNESS for the out-of-band fallback (task 1002615). A claim with no branch
73
79
  // makes getPublishStatus throw BAD_BRANCH — that used to abort the whole probe
@@ -117,6 +123,26 @@ async function deriveShipAssurance({ taskId, body = {}, db, githubPush, onProbeE
117
123
  return { assurance: { kind: 'no_artifact', reason } };
118
124
  }
119
125
 
126
+ const noAccess = githubAccessCause(probeErrors);
127
+ if (noAccess) {
128
+ return {
129
+ refusal: {
130
+ code: 'ship_land_not_proven',
131
+ status: 409,
132
+ message: noAccess.message,
133
+ details: {
134
+ cause: noAccess.cause,
135
+ install_url: noAccess.install_url || null,
136
+ what_this_means: noAccess.message,
137
+ your_options: [
138
+ noAccess.fix,
139
+ 'Nothing is lost: once the server can read GitHub, the reconciler proves the merge and ships the task on its own, with its credits.',
140
+ ],
141
+ },
142
+ },
143
+ };
144
+ }
145
+
120
146
  return {
121
147
  refusal: {
122
148
  code: 'ship_land_not_proven',
@@ -133,6 +159,37 @@ async function deriveShipAssurance({ taskId, body = {}, db, githubPush, onProbeE
133
159
  }
134
160
 
135
161
 
162
+ // Was the land unprovable only because this server has no way to read GitHub?
163
+ // PURE over the probe errors deriveShipAssurance collected. Returns null when the
164
+ // probes could see GitHub (a genuine "not on main" answer stays the generic
165
+ // refusal), else { cause, message, fix, install_url? } (task 1004760).
166
+ // APP_NOT_INSTALLED the App exists but nobody installed it on the repo —
167
+ // the owner's one remaining setup step.
168
+ // PUSH_UNCONFIGURED / no GitHub credential at all, and no git checkout of
169
+ // LOCAL_PROOF_UNAVAILABLE the project to ask instead (a hosted instance).
170
+ function githubAccessCause(errors) {
171
+ const codes = (errors || []).map((e) => e && e.code);
172
+ const notInstalled = (errors || []).find((e) => e && e.code === 'APP_NOT_INSTALLED');
173
+ if (notInstalled) {
174
+ const repo = notInstalled.owner && notInstalled.repo ? `${notInstalled.owner}/${notInstalled.repo}` : 'the project repository';
175
+ const where = notInstalled.install_url ? `at ${notInstalled.install_url}` : "from the App's page in GitHub settings (Install App)";
176
+ return {
177
+ cause: 'github_app_not_installed',
178
+ install_url: notInstalled.install_url || null,
179
+ message: `The merge may well be on main, but this server cannot check: the project's GitHub App is not installed on ${repo}, so the server cannot read the repository.`,
180
+ fix: `The project owner installs the GitHub App on ${repo} ${where} — one click, no other setup.`,
181
+ };
182
+ }
183
+ if (codes.includes('PUSH_UNCONFIGURED') || codes.includes('LOCAL_PROOF_UNAVAILABLE')) {
184
+ return {
185
+ cause: 'no_github_access',
186
+ message: 'The merge may well be on main, but this server cannot check: it has no GitHub access (no GitHub App or token) and no copy of the repository to look in.',
187
+ fix: "The project owner finishes the project's GitHub setup (Settings → GitHub) so the server can read the repository.",
188
+ };
189
+ }
190
+ return null;
191
+ }
192
+
136
193
  // Record WHY a ship was allowed, not merely that it happened. POST-COMMIT and
137
194
  // best-effort by design: insertAuditLog uses the module pool rather than the
138
195
  // ship's transaction client, and an audit miss must never roll back a landed ship
@@ -172,13 +229,14 @@ async function gateManualShip(res, opts) {
172
229
  onProbeError: (e) => log && log.warn(`POST /tasks/${taskId}/ship: land probe failed (${e && e.message}) — no proof`),
173
230
  });
174
231
  if (derived.refusal) {
175
- res.fail(derived.refusal.code, derived.refusal.status, derived.refusal.details);
232
+ const { code, status, message, details } = derived.refusal;
233
+ res.fail(code, { status, message, details });
176
234
  return null;
177
235
  }
178
236
  return derived.assurance;
179
237
  }
180
238
 
181
239
  module.exports = {
182
- normalizeShipAssurance, deriveShipAssurance, recordShipAssurance,
240
+ normalizeShipAssurance, deriveShipAssurance, recordShipAssurance, githubAccessCause,
183
241
  assertShipAssurance, gateManualShip, SHIP_BODY_SCHEMA, SHIP_ASSURANCE_KINDS: KINDS, MAX_REASON_CHARS,
184
242
  };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.148",
3
+ "version": "1.21.150",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.148",
9
+ "version": "1.21.150",
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.21.148",
3
+ "version": "1.21.150",
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",
@@ -9902,5 +9902,17 @@
9902
9902
  "id": "1004732",
9903
9903
  "text": "When the hub upgrades a project's core, your laptop now notices at the next session start or ship, says so in one line, and on a clean main catches up and reinstalls the new core by itself."
9904
9904
  }
9905
+ ],
9906
+ "1.21.149": [
9907
+ {
9908
+ "id": "1004759",
9909
+ "text": "When a project's site is too old to send Bongos feedback, the command now says 'Not sent' and keeps the note, instead of claiming it was sent."
9910
+ }
9911
+ ],
9912
+ "1.21.150": [
9913
+ {
9914
+ "id": "1004760",
9915
+ "text": "When a project's GitHub App isn't installed, a refused ship now says so and links the one-click install, instead of a bare 'not proven'."
9916
+ }
9905
9917
  ]
9906
9918
  }
@@ -14,8 +14,12 @@
14
14
  // signed-in builder does nothing extra.
15
15
  //
16
16
  // Fast and quiet by design (the owner, 2026-10-06): the command prints one line
17
- // and returns at once; a detached background process does the network. No task
18
- // number comes back. Only the typed text (secret-scrubbed) and version stamps travel.
17
+ // and returns at once; a detached background process sends to the hub. The one
18
+ // call made in the foreground is the receipt from the builder's own project, so
19
+ // "Sent" is never printed for a report the project could not vouch for (task
20
+ // 1004759: a project on an older core has no receipt door, and the background
21
+ // 404 was invisible). No task number comes back. Only the typed text
22
+ // (secret-scrubbed) and version stamps travel.
19
23
 
20
24
  'use strict';
21
25
 
@@ -47,6 +51,8 @@ const REFUSALS = {
47
51
  too_long: 'That is too long to send — keep it under 4,000 characters, or split it into two.',
48
52
  local_only: 'This project runs only on your computer, so Cloud Bongos cannot confirm who sent the report. Nothing was sent.',
49
53
  no_scrubber: 'Feedback was not sent: the secret filter that checks every report is missing from this install.',
54
+ project_too_old: "Not sent: this project's live site runs an older Bongos that cannot send feedback yet. It will work once the project is upgraded.",
55
+ receipt_failed: "Not sent: this project's live site did not confirm who sent it",
50
56
  };
51
57
 
52
58
  function stateDir() { return path.join(os.homedir(), '.config', ic.FALLBACK_DIR || 'cloudbongos'); }
@@ -98,12 +104,15 @@ function repeatKey(message) {
98
104
  const first = String(message).split('\n').map((l) => l.trim().toLowerCase()).find(Boolean) || '';
99
105
  return crypto.createHash('sha256').update(first).digest('hex').slice(0, 16);
100
106
  }
101
- function checkAndRecordRepeat(message, { file = path.join(stateDir(), 'feedback-sent.json'), now = Date.now() } = {}) {
107
+ // `record: false` only looks: the CLI records a report once its project has
108
+ // vouched for it, so a report that never left is not "already sent" (task 1004759).
109
+ function checkAndRecordRepeat(message, { file = path.join(stateDir(), 'feedback-sent.json'), now = Date.now(), record = true } = {}) {
102
110
  let seen = {};
103
111
  try { seen = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch { /* first report */ }
104
112
  for (const [k, t] of Object.entries(seen)) if (!(now - t < REPEAT_WINDOW_MS)) delete seen[k];
105
113
  const key = repeatKey(message);
106
114
  if (Object.prototype.hasOwnProperty.call(seen, key)) return true;
115
+ if (!record) return false;
107
116
  seen[key] = now;
108
117
  try {
109
118
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -112,22 +121,29 @@ function checkAndRecordRepeat(message, { file = path.join(stateDir(), 'feedback-
112
121
  return false;
113
122
  }
114
123
 
115
- // deliver — the network half: receipt from the project, then the report to the
116
- // hub. Returns an outcome object; never prints. Runs in the background child.
117
- async function deliver({ message, kind }, deps = {}) {
124
+ // requestReceipt — the project vouches for this exact text. Foreground: one call
125
+ // to the builder's own project. A 404 means the project's core predates the
126
+ // receipt door (task 1004591), which no retry fixes until it is upgraded.
127
+ async function requestReceipt({ message, kind }, deps = {}) {
118
128
  const api = deps.api || await cliClient();
119
129
  const base = deps.base || ic.resolveApiBase({ session: loadSessionSync() });
120
- const doFetch = deps.fetchImpl || fetch;
121
130
  const sha256 = crypto.createHash('sha256').update(message, 'utf8').digest('hex');
122
131
  const r = await api.request('POST', '/feedback/receipt', { body: { sha256, kind } });
123
- if (!r.ok) return { ok: false, step: 'receipt', code: errCode(r.data) || `http_${r.status}` };
132
+ if (!r.ok) return { ok: false, step: 'receipt', code: r.status === 404 ? 'project_too_old' : (errCode(r.data) || `http_${r.status}`) };
124
133
  const origin = new URL(base).origin;
125
134
  const hub = String((r.data && r.data.hub) || origin).replace(/\/+$/, '');
135
+ return { ok: true, receipt: r.data.receipt, origin, hub };
136
+ }
137
+
138
+ // sendToHub — the vouched report straight to the hub. Returns an outcome
139
+ // object; never prints. Runs in the background child.
140
+ async function sendToHub({ message, kind, receipt, origin, hub }, deps = {}) {
141
+ const doFetch = deps.fetchImpl || fetch;
126
142
  const res = await doFetch(`${hub}/api/bongos/sso/feedback/direct`, {
127
143
  method: 'POST',
128
144
  headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
129
145
  body: JSON.stringify({
130
- origin, receipt: r.data.receipt, message, kind,
146
+ origin, receipt, message, kind,
131
147
  context: { cli_version: cliVersion(), os: `${os.platform()} ${os.release()}` },
132
148
  }),
133
149
  signal: AbortSignal.timeout(15000),
@@ -138,13 +154,31 @@ async function deliver({ message, kind }, deps = {}) {
138
154
  return { ok: false, step: 'hub', code: errCode(body) || `http_${res.status}` };
139
155
  }
140
156
 
157
+ // deliver — both halves in one go.
158
+ async function deliver(job, deps = {}) {
159
+ const r = await requestReceipt(job, deps);
160
+ if (!r.ok) return r;
161
+ return sendToHub({ ...job, receipt: r.receipt, origin: r.origin, hub: r.hub }, deps);
162
+ }
163
+
164
+ // A report the project could not vouch for is kept, so it can be sent after the
165
+ // upgrade (`--message-file <it>`) instead of being written again.
166
+ function keepUnsent(message, { dir = stateDir(), now = new Date() } = {}) {
167
+ try {
168
+ fs.mkdirSync(dir, { recursive: true });
169
+ const file = path.join(dir, `feedback-unsent-${now.toISOString().replace(/[:.]/g, '-')}.md`);
170
+ fs.writeFileSync(file, message, { mode: 0o600 });
171
+ return file;
172
+ } catch { return null; }
173
+ }
174
+
141
175
  // The background child: read the queued report, deliver it, record the outcome
142
176
  // where a curious builder (or a maintainer helping them) can look.
143
177
  async function runDeliverChild(file) {
144
178
  let job;
145
179
  try { job = JSON.parse(fs.readFileSync(file, 'utf8')); } finally { fs.rmSync(path.dirname(file), { recursive: true, force: true }); }
146
180
  let outcome;
147
- try { outcome = await deliver(job); } catch (e) { outcome = { ok: false, step: 'network', code: String(e && e.message || e).slice(0, 200) }; }
181
+ try { outcome = job.receipt ? await sendToHub(job) : await deliver(job); } catch (e) { outcome = { ok: false, step: 'network', code: String(e && e.message || e).slice(0, 200) }; }
148
182
  try {
149
183
  fs.writeFileSync(path.join(stateDir(), 'feedback-last.json'), JSON.stringify({ at: new Date().toISOString(), ...outcome }), { mode: 0o600 });
150
184
  } catch { /* nowhere to record — the report itself is what mattered */ }
@@ -177,16 +211,27 @@ async function main(argv = process.argv.slice(2), deps = {}) {
177
211
  if (isLocalBase(base)) { console.error(REFUSALS.local_only); return 1; }
178
212
 
179
213
  const job = { message: scrubbed.text, kind };
180
- if ((deps.checkRepeat || checkAndRecordRepeat)(job.message)) {
214
+ const checkRepeat = deps.checkRepeat || checkAndRecordRepeat;
215
+ if (checkRepeat(job.message, { record: false })) {
181
216
  console.log('Already sent — the Bongos team has this one.');
182
217
  return 0;
183
218
  }
219
+ // The receipt in the foreground: "Sent" only once the project has vouched.
220
+ const r = await (deps.requestReceipt || requestReceipt)(job, { base });
221
+ if (!r.ok) {
222
+ const kept = (deps.keepUnsent || keepUnsent)(job.message);
223
+ const why = r.code === 'project_too_old' ? REFUSALS.project_too_old : `${REFUSALS.receipt_failed} (${r.code}).`;
224
+ console.error(kept ? `${why} Your note is saved in ${kept}` : why);
225
+ return 1;
226
+ }
227
+ checkRepeat(job.message);
228
+ const vouched = { ...job, receipt: r.receipt, origin: r.origin, hub: r.hub };
184
229
  if (hasFlag('--wait', argv)) {
185
- const outcome = await (deps.deliver || deliver)(job);
230
+ const outcome = await (deps.sendToHub || sendToHub)(vouched);
186
231
  console.log(outcome.ok ? SENT_LINE : `Not sent (${outcome.step}: ${outcome.code}).`);
187
232
  return outcome.ok ? 0 : 1;
188
233
  }
189
- (deps.spawnDelivery || spawnDelivery)(job);
234
+ (deps.spawnDelivery || spawnDelivery)(vouched);
190
235
  console.log(SENT_LINE);
191
236
  return 0;
192
237
  }
@@ -201,4 +246,4 @@ if (require.main === module) {
201
246
  });
202
247
  }
203
248
 
204
- module.exports = { main, deliver, readMessage, REFUSALS, scrubReport, isLocalBase, repeatKey, checkAndRecordRepeat, SENT_LINE };
249
+ module.exports = { main, deliver, requestReceipt, sendToHub, keepUnsent, readMessage, REFUSALS, scrubReport, isLocalBase, repeatKey, checkAndRecordRepeat, SENT_LINE };
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.148'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.150'; // 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');
@@ -326,6 +326,8 @@ async function runCli(argv, extra = {}) {
326
326
  requireSession: async () => ({ token: 't' }),
327
327
  base: 'https://mercury.cloudbongos.com',
328
328
  checkRepeat: () => false,
329
+ requestReceipt: async () => ({ ok: true, receipt: 'R'.repeat(24), origin: 'https://mercury.cloudbongos.com', hub: 'https://cloudbongos.com' }),
330
+ keepUnsent: () => null,
329
331
  spawnDelivery: (job) => spawned.push(job),
330
332
  ...extra,
331
333
  });
@@ -337,7 +339,49 @@ test('the CLI prints one line and hands the network to the background', async ()
337
339
  const r = await runCli(['claim', 'hung']);
338
340
  assert.equal(r.code, 0);
339
341
  assert.deepEqual(r.out, ['Sent to the Bongos team.']);
340
- assert.deepEqual(r.spawned, [{ message: 'claim hung', kind: 'bug' }]);
342
+ assert.deepEqual(r.spawned, [{ message: 'claim hung', kind: 'bug', receipt: 'R'.repeat(24), origin: 'https://mercury.cloudbongos.com', hub: 'https://cloudbongos.com' }]);
343
+ });
344
+
345
+ // Task 1004759 (toast, core 1.21.20): the project's live site had no receipt
346
+ // door, the background child got a 404, and the builder had been told "Sent".
347
+ test('a project too old to vouch: "Not sent" in the foreground, the note kept, nothing recorded as sent', async () => {
348
+ const kept = []; const recorded = [];
349
+ const r = await runCli(['--idea', 'one', 'skill', 'for', 'project', 'purpose'], {
350
+ requestReceipt: async () => ({ ok: false, step: 'receipt', code: 'project_too_old' }),
351
+ keepUnsent: (m) => { kept.push(m); return '/home/x/.config/cloudbongos/feedback-unsent-1.md'; },
352
+ checkRepeat: (m, o = {}) => { if (o.record !== false) recorded.push(m); return false; },
353
+ });
354
+ assert.equal(r.code, 1);
355
+ assert.deepEqual(r.out, [], 'never says Sent');
356
+ assert.equal(r.spawned.length, 0);
357
+ assert.match(r.err.join('\n'), /older Bongos/);
358
+ assert.match(r.err.join('\n'), /feedback-unsent-1\.md/, 'says where the note is');
359
+ assert.deepEqual(kept, ['one skill for project purpose']);
360
+ assert.deepEqual(recorded, [], 'a report that never left is not "already sent"');
361
+ const other = await runCli(['x'], { requestReceipt: async () => ({ ok: false, step: 'receipt', code: 'not_signed_in' }) });
362
+ assert.equal(other.code, 1);
363
+ assert.match(other.err.join('\n'), /did not confirm.*not_signed_in/);
364
+ });
365
+
366
+ test('the repeat guard records only after the project vouched', async () => {
367
+ const calls = [];
368
+ await runCli(['x'], { checkRepeat: (m, o = {}) => { calls.push(o.record === false ? 'look' : 'record'); return false; } });
369
+ assert.deepEqual(calls, ['look', 'record']);
370
+ });
371
+
372
+ test('requestReceipt: a 404 from the project reads as project_too_old', async () => {
373
+ const { requestReceipt } = require_(path.join(ROOT, 'scripts/gds/feedback-send.js'));
374
+ const api = { request: async () => ({ ok: false, status: 404, data: { error: 'Not found' } }) };
375
+ const r = await requestReceipt({ message: 'x', kind: 'idea' }, { api, base: 'https://toast.cloudbongos.com' });
376
+ assert.deepEqual(r, { ok: false, step: 'receipt', code: 'project_too_old' });
377
+ });
378
+
379
+ test('keepUnsent writes the note where the builder can send it again', () => {
380
+ const { keepUnsent } = require_(path.join(ROOT, 'scripts/gds/feedback-send.js'));
381
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fb-unsent-'));
382
+ const file = keepUnsent('the note', { dir, now: new Date('2026-10-19T10:00:00Z') });
383
+ assert.equal(fs.readFileSync(file, 'utf8'), 'the note');
384
+ assert.match(path.basename(file), /^feedback-unsent-2026-10-19T10-00-00-000Z\.md$/);
341
385
  });
342
386
 
343
387
  test('the CLI refuses a project only this computer can reach, and sends nothing', async () => {
@@ -321,5 +321,69 @@ await t('no tip + no PR for the task → the 409 refusal, and getPublishStatus i
321
321
  assert.equal(statusCalls, 0);
322
322
  });
323
323
 
324
+ // task 1004760 — the toast instance merged real work and was refused with a bare
325
+ // "not proven": its GitHub App was never installed on the repo, so every probe threw
326
+ // APP_NOT_INSTALLED and the server could not look. The refusal must NAME that cause
327
+ // and the one-click fix, not leave the builder guessing.
328
+ console.log('\nthe refusal names a server that cannot read GitHub (task 1004760)');
329
+ const notInstalled = () => {
330
+ const e = new Error('APP_NOT_INSTALLED');
331
+ e.code = 'APP_NOT_INSTALLED';
332
+ e.owner = 'acme';
333
+ e.repo = 'widget';
334
+ e.install_url = 'https://github.com/apps/widget-app/installations/new';
335
+ return e;
336
+ };
337
+ await t('App not installed → the refusal says so, with the install link', async () => {
338
+ const r = await deriveShipAssurance({
339
+ taskId: 1,
340
+ body: {},
341
+ db: { getClaimPublishedSha: async () => TIP },
342
+ githubPush: {
343
+ findTaskPullRequest: async () => { throw notInstalled(); },
344
+ getPublishStatus: async () => null,
345
+ landProvenForTask: async () => { throw notInstalled(); },
346
+ },
347
+ });
348
+ assert.equal(r.refusal.code, 'ship_land_not_proven');
349
+ assert.equal(r.refusal.details.cause, 'github_app_not_installed');
350
+ assert.match(r.refusal.message, /not installed on acme\/widget/);
351
+ assert.equal(r.refusal.details.install_url, 'https://github.com/apps/widget-app/installations/new');
352
+ assert.ok(r.refusal.details.your_options.some((o) => o.includes('installations/new')));
353
+ });
354
+ await t('no credential and no checkout → the refusal says the server has no GitHub access', async () => {
355
+ for (const code of ['PUSH_UNCONFIGURED', 'LOCAL_PROOF_UNAVAILABLE']) {
356
+ const r = await deriveShipAssurance({
357
+ taskId: 1,
358
+ body: {},
359
+ db: { getClaimPublishedSha: async () => TIP },
360
+ githubPush: {
361
+ findTaskPullRequest: async () => null,
362
+ landProvenForTask: async () => { const e = new Error(code); e.code = code; throw e; },
363
+ },
364
+ });
365
+ assert.equal(r.refusal.details.cause, 'no_github_access', code);
366
+ assert.match(r.refusal.message, /no GitHub access/);
367
+ }
368
+ });
369
+ await t('a server that CAN read GitHub keeps the plain refusal (the tip really is not on main)', async () => {
370
+ const r = await deriveShipAssurance({
371
+ taskId: 1,
372
+ body: {},
373
+ db: { getClaimPublishedSha: async () => TIP },
374
+ githubPush: {
375
+ findTaskPullRequest: async () => null,
376
+ landProvenForTask: async () => ({ proven: false, reason: 'not in main' }),
377
+ },
378
+ });
379
+ assert.equal(r.refusal.code, 'ship_land_not_proven');
380
+ assert.equal(r.refusal.details.cause, undefined);
381
+ assert.equal(r.refusal.message, undefined);
382
+ });
383
+ await t('the gate sends the cause as the error MESSAGE, which is what the CLI prints', () => {
384
+ const src = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/ship-assurance.js'), 'utf8');
385
+ assert.match(src, /res\.fail\(code, \{ status, message, details \}\)/);
386
+ });
387
+
324
388
  console.log(`\n${passed} passed, ${failed} failed`);
325
389
  process.exit(failed ? 1 : 0);