@bongos/core 1.19.817 → 1.19.819

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.
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/bug-attachment-audit.js — do the images our task descriptions
3
+ // promise actually exist? (task 1003918)
4
+ //
5
+ // WHY THIS EXISTS. A re-hosted #bugs screenshot is the one artefact in this
6
+ // system that cannot be regenerated: the Discord CDN URL it was copied from is
7
+ // signed and expires in about a day, so our copy IS the image. They live on disk
8
+ // under var/bug-attachments — outside Postgres, outside git — and nothing
9
+ // noticed when all five this instance had ever stored disappeared in the
10
+ // 2026-07-05 droplet move. The loss surfaced on 2026-09-14, two months later,
11
+ // because somebody happened to open an old bug task and saw a broken image.
12
+ //
13
+ // So: read the descriptions, extract every attachment link they promise, and ask
14
+ // the disk whether it can keep that promise. A count of broken promises is the
15
+ // thing that should have been non-zero and visible in July.
16
+ //
17
+ // Usage:
18
+ // node scripts/gds/bug-attachment-audit.js # human report; exit 1 if any are missing
19
+ // node scripts/gds/bug-attachment-audit.js --json # { described, present, missing: [...] }
20
+ // node scripts/gds/bug-attachment-audit.js --line # one line, or nothing; always exit 0
21
+ //
22
+ // Read-only. It never writes, never deletes, and never touches Discord.
23
+
24
+ 'use strict';
25
+
26
+ const fs = require('node:fs');
27
+ const path = require('node:path');
28
+
29
+ // The link shape the bot embeds in a description. Deliberately matched against
30
+ // the SERVE ROUTE's path rather than any filename found on disk: what matters is
31
+ // what a reader will click, and a file nothing links to is not a broken promise.
32
+ const LINK_RE = /\/api\/(?:gds|bongos)\/bug-attachments\/(bug-[a-z0-9]{1,40}-[0-9a-f]{16}\.(?:png|jpg|gif|webp))/gi;
33
+
34
+ // PURE — every attachment name a body of text promises, deduped, in first-seen
35
+ // order. Exported so the extraction is testable without a database or a disk.
36
+ function describedAttachments(text) {
37
+ const out = [];
38
+ const seen = new Set();
39
+ for (const m of String(text || '').matchAll(LINK_RE)) {
40
+ const name = m[1];
41
+ if (!seen.has(name)) { seen.add(name); out.push(name); }
42
+ }
43
+ return out;
44
+ }
45
+
46
+ // PURE — split the promised names into kept and broken, given an existence test.
47
+ // `rows` are { id, title, description }; the result names the TASK for each
48
+ // missing file, because "three files are gone" is not actionable and "the image
49
+ // in task N is gone" is.
50
+ function auditRows(rows, exists) {
51
+ const described = [];
52
+ const missing = [];
53
+ for (const row of rows || []) {
54
+ for (const name of describedAttachments(row && row.description)) {
55
+ described.push({ name, taskId: row.id });
56
+ if (!exists(name)) missing.push({ name, taskId: row.id, title: row.title || '' });
57
+ }
58
+ }
59
+ return { described, missing, present: described.length - missing.length };
60
+ }
61
+
62
+ // EVERY status, explicitly, and paged. Two ways to get a comfortable zero here,
63
+ // both of which I hit while building this:
64
+ //
65
+ // - GET /tasks omits abandoned rows unless you ask for them. The five files this
66
+ // audit exists for are described in tasks that are now abandoned or shipped, so
67
+ // the obvious query returned '0 linked, 0 missing' — indistinguishable from
68
+ // 'nothing is broken'.
69
+ // - the route clamps `limit`, so one page is not the table.
70
+ //
71
+ // A probe whose failure mode is a clean bill of health is worse than no probe, so
72
+ // the status list is spelled out rather than defaulted.
73
+ // 'claimed' is not one — claimTask sets status='active' (db-claims.js). Listing it
74
+ // only bought a no-op query, and a comment promising EVERY status should be right.
75
+ const TASK_STATUSES = ['backlog', 'ready', 'active', 'completed', 'confirmed', 'shipped', 'abandoned'];
76
+
77
+ async function fetchRows() {
78
+ const { cliClient } = require('./cli-lib');
79
+ const { TASK_LIST_MAX_LIMIT, buildTaskPageQuery } = require('../../modules/lifecycle/task-list-limits.js');
80
+ const api = await cliClient();
81
+ const byId = new Map();
82
+ for (const status of TASK_STATUSES) {
83
+ let offset = 0;
84
+ for (let page = 0; page < 100; page++) {
85
+ const q = buildTaskPageQuery({ kind: 'bug', status, limit: TASK_LIST_MAX_LIMIT, offset });
86
+ const r = await api.tasks.getTasks({ query: q });
87
+ if (!r || !r.ok) throw new Error(`could not list ${status} tasks (${r && r.status})`);
88
+ const rows = (r.data && r.data.tasks) || [];
89
+ for (const t of rows) byId.set(String(t.id), t);
90
+ // A short page is the end. Compare against the limit the route HONOURED.
91
+ if (rows.length < q.limit) break;
92
+ offset += q.limit;
93
+ }
94
+ }
95
+ return [...byId.values()];
96
+ }
97
+
98
+ async function main() {
99
+ const argv = process.argv.slice(2);
100
+ const dir = require('../../modules/discord/bug-attachments.js').attachmentsDir();
101
+ let rows;
102
+ try {
103
+ rows = await fetchRows();
104
+ } catch (e) {
105
+ if (argv.includes('--line')) process.exit(0); // a probe never blocks a session
106
+ console.error(`bug-attachment-audit: undetermined — ${e.message || e}`);
107
+ process.exit(2);
108
+ }
109
+ // Through the serve route's OWN resolver, not a bare path.join. The name pattern
110
+ // already excludes slashes and dots, and a test pins that '../../etc/passwd' is
111
+ // never extracted — but the route that actually opens these files defends itself
112
+ // with a resolved-path prefix check, and a probe that reads the same store should
113
+ // not hold itself to a weaker rule than the thing it audits.
114
+ const { resolveSafe } = require('../../modules/discord/bug-attachments.js');
115
+ const r = auditRows(rows, (name) => {
116
+ const p = resolveSafe(name);
117
+ return Boolean(p) && fs.existsSync(p);
118
+ });
119
+
120
+ if (argv.includes('--json')) {
121
+ console.log(JSON.stringify({ dir, described: r.described.length, present: r.present, missing: r.missing }, null, 2));
122
+ return;
123
+ }
124
+ if (argv.includes('--line')) {
125
+ if (r.missing.length) {
126
+ console.log(`Bug attachments: ${r.missing.length} of ${r.described.length} linked screenshot(s) are MISSING from ${dir} `
127
+ + '— every task describing one renders a broken image. These cannot be re-fetched (the Discord URL has expired).');
128
+ }
129
+ return;
130
+ }
131
+ console.log(` Bug attachments: ${r.described.length} linked, ${r.present} present, ${r.missing.length} missing`);
132
+ console.log(` Store: ${dir}`);
133
+ for (const m of r.missing) console.log(` missing ${m.name} (task ${m.taskId})`);
134
+ if (r.missing.length) {
135
+ console.log(' These cannot be re-fetched — the Discord CDN URL they were copied from has expired.');
136
+ console.log(' Restore from a nightly attachments archive: /var/backups/gds/<db>-attachments-*.tar.gz');
137
+ process.exit(1);
138
+ }
139
+ }
140
+
141
+ if (require.main === module) main();
142
+
143
+ module.exports = { describedAttachments, auditRows, LINK_RE };
@@ -282,6 +282,25 @@ async function main() {
282
282
  const noPrompt = hasFlag('--no-prompt', args);
283
283
  const goalArg = arg('--goal', args);
284
284
 
285
+ // A BAD --kind used to reach the server and come back a 400 (task 1003694).
286
+ // That is not merely late, it is invisible where it matters: a SCHEDULED routine
287
+ // filing its findings gets the 400 into a log nobody reads, the audit work still
288
+ // runs, and only the step that turns a finding into a durable row fails — so no
289
+ // queue accumulates to make the absence noticeable. dep-audit-nightly filed with
290
+ // `--kind security`, which is not a kind, and had therefore NEVER filed anything.
291
+ //
292
+ // Refusing here makes the same typo surface at authoring time, on the machine of
293
+ // whoever wrote the command, with the valid list in the message.
294
+ if (kindFromArg !== null) {
295
+ const { VALID_KINDS } = require('../../modules/ideas/inbox.js');
296
+ if (!VALID_KINDS.includes(kindFromArg)) {
297
+ console.error(`--kind "${kindFromArg}" is not a valid kind. The server would refuse this with a 400.`);
298
+ console.error(`Valid kinds: ${VALID_KINDS.join(', ')}`);
299
+ console.error('Adding a new kind is a schema change (the idea_inbox CHECK constraint) and needs its own migration + ADR.');
300
+ process.exit(2);
301
+ }
302
+ }
303
+
285
304
  if (parseGoalArg(goalArg).kind === 'invalid') {
286
305
  console.error(`--goal must be a positive goal id, or the literal "none" to file homeless (got "${goalArg}").`);
287
306
  process.exit(2);
@@ -170,6 +170,42 @@ if [ "$BYTES" -gt "$WARN_BYTES" ]; then
170
170
  echo "[$(date -u +%FT%TZ)] gds-db-backup: WARN latest dump is ${BYTES} bytes (> ${WARN_BYTES} threshold) — review what grew; is it a regenerable table excludable like search_chunks?" >&2
171
171
  fi
172
172
 
173
+ # ---- bug attachments (task 1003918) -----------------------------------------
174
+
175
+ # The DB dump above is not the whole state. Re-hosted #bugs screenshots live on
176
+ # disk under var/bug-attachments, outside Postgres and outside git, and they are
177
+ # the one artefact here that CANNOT be regenerated: the Discord CDN URL they were
178
+ # copied from is signed and expires in about a day, so our copy IS the image.
179
+ #
180
+ # Nothing backed them up. All five this instance ever stored are gone — the
181
+ # 2026-07-05 droplet move carried the database and not var/ — and every task
182
+ # description embedding one now renders a broken image in the hall.
183
+ #
184
+ # Tarred beside the dump, same timestamp, same retention, so a restore drill that
185
+ # picks a date gets the database and the images that go with it. Best-effort by
186
+ # design: a missing or empty attachments dir is the ordinary case on a fresh
187
+ # instance and must never fail a night of database backup.
188
+ ATTACH_DIR="${BUG_ATTACHMENTS_DIR:-$REPO_ROOT/var/bug-attachments}"
189
+ ATTACH_OUT="${BACKUP_DIR}/${DB_NAME}-attachments-${TS}.tar.gz"
190
+ ATTACH_GLOB="${DB_NAME}-attachments-${D}${D}${D}${D}-${D}${D}-${D}${D}T${D}${D}${D}${D}${D}${D}Z.tar.gz"
191
+
192
+ if [ -d "$ATTACH_DIR" ]; then
193
+ ATTACH_COUNT=$(find "$ATTACH_DIR" -maxdepth 1 -type f | wc -l)
194
+ if [ "$ATTACH_COUNT" -gt 0 ]; then
195
+ if tar -czf "$ATTACH_OUT.partial" -C "$ATTACH_DIR" . 2>/dev/null; then
196
+ mv "$ATTACH_OUT.partial" "$ATTACH_OUT"
197
+ echo "[$(date -u +%FT%TZ)] gds-db-backup: wrote ${ATTACH_OUT} (${ATTACH_COUNT} attachment(s))"
198
+ else
199
+ rm -f "$ATTACH_OUT.partial"
200
+ echo "[$(date -u +%FT%TZ)] gds-db-backup: WARN could not archive ${ATTACH_DIR} — database dump is unaffected" >&2
201
+ fi
202
+ else
203
+ echo "[$(date -u +%FT%TZ)] gds-db-backup: no bug attachments to archive"
204
+ fi
205
+ else
206
+ echo "[$(date -u +%FT%TZ)] gds-db-backup: no bug-attachment dir at ${ATTACH_DIR} — nothing to archive"
207
+ fi
208
+
173
209
  # ---- retention prune --------------------------------------------------------
174
210
 
175
211
  # Delete dumps older than RETENTION_DAYS. -mtime +N means strictly older
@@ -177,7 +213,7 @@ fi
177
213
  # Done AFTER the new dump lands so a streak of failed runs can't erase
178
214
  # everything: we never delete on a day we didn't successfully dump.
179
215
  PRUNED=$(find "$BACKUP_DIR" -maxdepth 1 -type f \
180
- -name "$DUMP_GLOB" \
216
+ \( -name "$DUMP_GLOB" -o -name "$ATTACH_GLOB" \) \
181
217
  -mtime +"${RETENTION_DAYS}" \
182
218
  -print -delete | wc -l)
183
219
 
@@ -115,6 +115,14 @@ const { API_BASE } = require('./cli-lib');
115
115
  // the root CLAUDE.md/README.md plus all of docs/** (session-logs included). A
116
116
  // "GENERATED FILE" marker means the bare refs reappear on the next regen, so
117
117
  // the generator — not its output — is where those get fixed; skip them here.
118
+ // PATHS COME BACK POSIX-STYLE, on every platform (task 1003935). path.join gives
119
+ // OS-native separators, so on Windows this used to return 'docs\\adr\\0001-….md'.
120
+ // Three things read these paths and only one of them tolerates that: fs accepts
121
+ // either separator on Windows, but the --check report PRINTS them (so CI output
122
+ // differed by platform for identical content) and the test that pins recursion
123
+ // into docs/ compares against 'docs/' — which is how a permanently-red case hid
124
+ // for months in a file no lane ran. Normalising here, at the one boundary that
125
+ // produces them, is cheaper than teaching every consumer to be separator-aware.
118
126
  function discoverDocs() {
119
127
  const out = [];
120
128
  const walk = (rel) => {
@@ -123,7 +131,7 @@ function discoverDocs() {
123
131
  if (fs.statSync(abs).isDirectory()) {
124
132
  for (const name of fs.readdirSync(abs).sort()) walk(path.join(rel, name));
125
133
  } else if (rel.endsWith('.md')) {
126
- out.push(rel);
134
+ out.push(rel.split(path.sep).join('/'));
127
135
  }
128
136
  };
129
137
  for (const root of ['CLAUDE.md', 'README.md', 'docs']) walk(root);
@@ -131,7 +131,14 @@ const INTEGRATION = new Set([
131
131
  // the --integration lane never saw them either, because it runs only this set. A
132
132
  // test that cannot fail in either lane is a file that looks like a gate.
133
133
  'handle_machinery', 'hub_profile_leak', 'profile_fields',
134
- 'linkify_refs', 'memory_isolation',
134
+ // task 1003935: 'linkify_refs' was swept in with the three above without the
135
+ // no-DB check they were being fixed for. It touches no Postgres at all — pure
136
+ // string work plus a filesystem walk — so the DB-free lane skipped it and the
137
+ // --integration lane, which runs only this set, never had a reason to. It ran in
138
+ // NEITHER lane, which is the exact failure the comment above describes, and it
139
+ // is why one permanently-red Windows case sat unnoticed. It is now in the unit
140
+ // gate, where it is the only coverage linkifyText has.
141
+ 'memory_isolation',
135
142
  'memory_pull', 'needs_rebase', 'parallel-load', 'profile_rollup_consent',
136
143
  // task 1003145: whether the PLANNER uses the migration 225 index for the recall
137
144
  // freshness probe. The DB-free sibling (recall_corpus_health) pins the query
@@ -120,13 +120,36 @@ const CAPABILITIES = {
120
120
  },
121
121
  };
122
122
 
123
+ // EVERY read of a SKILL.md goes through here (task 1003754).
124
+ //
125
+ // These files are CRLF in a Windows checkout — .gitattributes pins *.js, *.json,
126
+ // *.sh and *.sql, but Markdown falls through `* text=auto`. Every regex below is
127
+ // anchored on \n, starting with the frontmatter fence /^---\n/, so on Windows the
128
+ // frontmatter simply did not match and parseRequires returned `declared: false`
129
+ // for EVERY skill.
130
+ //
131
+ // That is worse than one red test. Fitness check G03 compares what a skill's fenced
132
+ // commands imply against what it declares — and with nothing parsed as declared, the
133
+ // check reported '53 skill(s) scanned; 0 declare a machine contract' and had nothing
134
+ // to compare. The guard against a skill's ops commands outrunning its declaration
135
+ // was inert on any Windows box.
136
+ //
137
+ // Normalising once at the boundary is deliberately not 'make each regex CRLF-aware':
138
+ // there are six of them and the next one added would forget.
139
+ function readSkillMd(p) {
140
+ return String(fs.readFileSync(p, 'utf8')).replace(/\r\n/g, '\n');
141
+ }
142
+
123
143
  // ---- the declaration ------------------------------------------------------
124
144
  // Read `requires:` out of a skill's YAML frontmatter. Accepts the inline form
125
145
  // (`requires: [gh, main-checkout]`) and the dash-list form. Returns
126
146
  // { requires, declared, error }: `declared` distinguishes "declared nothing"
127
147
  // from "declared an empty list", which the fitness check needs to tell apart.
128
148
  function parseRequires(md) {
129
- const m = /^---\n([\s\S]*?)\n---/.exec(String(md || ''));
149
+ // Normalised here too, not only in readSkillMd: this is exported and a caller may
150
+ // hand it raw file text (the fitness check's injected `read` does exactly that).
151
+ const src = String(md || '').replace(/\r\n/g, '\n');
152
+ const m = /^---\n([\s\S]*?)\n---/.exec(src);
130
153
  if (!m) return { requires: [], declared: false, error: null };
131
154
  const fm = m[1];
132
155
  const inline = /^requires:[ \t]*\[(.*?)\][ \t]*$/m.exec(fm);
@@ -176,7 +199,8 @@ function fencedCode(md) {
176
199
  }
177
200
 
178
201
  function inferRequired(md) {
179
- const code = fencedCode(md);
202
+ // Same reason as parseRequires: the SIGNALS below are ^-anchored per line.
203
+ const code = fencedCode(String(md || '').replace(/\r\n/g, '\n'));
180
204
  const out = new Set();
181
205
  for (const [rx, cap] of SIGNALS) if (rx.test(code)) out.add(cap);
182
206
  return [...out].sort();
@@ -224,7 +248,7 @@ function preflightSkill(name, caps = CAPABILITIES) {
224
248
  return { ok: false, requires: [], missing: [{ name: 'skill', why: `"${name}" is not a skill name — it must be a single directory name under .claude/skills/.` }], unknown: [], checked: [] };
225
249
  }
226
250
  let md;
227
- try { md = fs.readFileSync(p, 'utf8'); } catch {
251
+ try { md = readSkillMd(p); } catch {
228
252
  return { ok: false, missing: [{ name: 'skill', why: `no skill named "${name}" at .claude/skills/${name}/SKILL.md` }], unknown: [], checked: [] };
229
253
  }
230
254
  const { requires, error } = parseRequires(md);
@@ -263,7 +287,7 @@ function checkOpsSkillsDeclareMachine({ dir = SKILLS_DIR, read = null, names: in
263
287
  };
264
288
  }
265
289
  const readOne = read || ((n) => {
266
- try { return fs.readFileSync(path.join(dir, n, 'SKILL.md'), 'utf8'); } catch { return null; }
290
+ try { return readSkillMd(path.join(dir, n, 'SKILL.md')); } catch { return null; }
267
291
  });
268
292
 
269
293
  let declaring = 0;
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.817'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.819'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,122 @@
1
+ // tests/bug_attachment_audit.mjs — the described-but-missing probe (task 1003918).
2
+ //
3
+ // WHY THIS FILE EXISTS. Five re-hosted #bugs screenshots — every one this instance
4
+ // ever stored — disappeared in the 2026-07-05 droplet move, and nobody noticed for
5
+ // two months. They cannot be re-fetched: the Discord CDN URL they were copied from
6
+ // is signed and expires in about a day, so our copy IS the image. The probe is what
7
+ // should have said so in July.
8
+ //
9
+ // A probe like this fails by finding nothing, and a clean bill of health looks
10
+ // identical to working — which is exactly what happened while building it: the
11
+ // first cut queried GET /tasks without naming a status, the route omits abandoned
12
+ // rows by default, and every task describing these files is now abandoned or
13
+ // shipped. It printed "0 linked, 0 missing". So the cases below lean on the ways
14
+ // the extraction can go quietly empty.
15
+ //
16
+ // Run: node tests/bug_attachment_audit.mjs
17
+
18
+ import { test } from 'node:test';
19
+ import assert from 'node:assert/strict';
20
+ import { createRequire } from 'node:module';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const { describedAttachments, auditRows } = require('../scripts/gds/bug-attachment-audit.js');
24
+ const { attachmentsDir } = require('../modules/discord/bug-attachments.js');
25
+ const path = require('node:path');
26
+
27
+ // The real link shape, taken verbatim from task 1003718's description.
28
+ const REAL = '/api/gds/bug-attachments/bug-1523688670492627115-8bb16de1f5b4c20e.png';
29
+
30
+ test('extracts the attachment name from a real embedded link', () => {
31
+ const got = describedAttachments(`![screenshot](https://cloudbongos.com${REAL})`);
32
+ assert.deepEqual(got, ['bug-1523688670492627115-8bb16de1f5b4c20e.png']);
33
+ });
34
+
35
+ test('the 19-digit Discord message id is not too long for the name pattern', () => {
36
+ // The id in every real filename here is 19 characters. A pattern sized for
37
+ // something shorter would match nothing and report a clean board.
38
+ assert.equal(describedAttachments(REAL).length, 1);
39
+ });
40
+
41
+ test('both /gds/ and /bongos/ spellings of the route are recognised', () => {
42
+ const a = describedAttachments('/api/gds/bug-attachments/bug-1-0123456789abcdef.png');
43
+ const b = describedAttachments('/api/bongos/bug-attachments/bug-1-0123456789abcdef.png');
44
+ assert.deepEqual(a, b, 'the route was renamed; a description can carry either');
45
+ assert.equal(a.length, 1);
46
+ });
47
+
48
+ test('the same file linked twice in one description is counted once', () => {
49
+ const text = `![a](${REAL}) and again ![a](${REAL})`;
50
+ assert.equal(describedAttachments(text).length, 1);
51
+ });
52
+
53
+ test('a description with no attachment yields nothing — and that is not an error', () => {
54
+ assert.deepEqual(describedAttachments('a bug about the hall sidebar'), []);
55
+ assert.deepEqual(describedAttachments(null), []);
56
+ assert.deepEqual(describedAttachments(undefined), []);
57
+ });
58
+
59
+ test('a malformed name is NOT extracted — the pattern is the traversal defence too', () => {
60
+ assert.deepEqual(describedAttachments('/api/gds/bug-attachments/../../etc/passwd'), []);
61
+ assert.deepEqual(describedAttachments('/api/gds/bug-attachments/bug-1-short.png'), []);
62
+ assert.deepEqual(describedAttachments('/api/gds/bug-attachments/bug-1-0123456789abcdef.exe'), []);
63
+ });
64
+
65
+ test('auditRows names the TASK for each missing file, not just the file', () => {
66
+ const rows = [
67
+ { id: '1003718', title: 'a re-filed bug', description: `![x](${REAL})` },
68
+ { id: '1003719', title: 'no images here', description: 'plain prose' },
69
+ ];
70
+ const r = auditRows(rows, () => false);
71
+ assert.equal(r.described.length, 1);
72
+ assert.equal(r.missing.length, 1);
73
+ assert.equal(r.missing[0].taskId, '1003718', '"a file is gone" is not actionable; "the image in task N is gone" is');
74
+ assert.equal(r.present, 0);
75
+ });
76
+
77
+ test('a present file is not reported missing', () => {
78
+ const rows = [{ id: '1', title: 't', description: `![x](${REAL})` }];
79
+ const r = auditRows(rows, () => true);
80
+ assert.deepEqual(r.missing, []);
81
+ assert.equal(r.present, 1);
82
+ });
83
+
84
+ test('the same file described by two tasks is reported for BOTH', () => {
85
+ // task 1002076 was re-filed as 1003718 and both descriptions carry the links;
86
+ // each renders a broken image, so each is worth naming.
87
+ const rows = [
88
+ { id: '1002076', title: 'original', description: `![x](${REAL})` },
89
+ { id: '1003718', title: 're-file', description: `![x](${REAL})` },
90
+ ];
91
+ const r = auditRows(rows, () => false);
92
+ assert.equal(r.missing.length, 2);
93
+ });
94
+
95
+ test('the store resolves against the INSTANCE root, not the core package (task 1003918)', () => {
96
+ // The old default was __dirname/../../var, which on a vendored core is
97
+ // node_modules/@bongos/core/var — wiped by every reinstall, and these files
98
+ // cannot be regenerated.
99
+ const prev = process.env.BUG_ATTACHMENTS_DIR;
100
+ delete process.env.BUG_ATTACHMENTS_DIR;
101
+ try {
102
+ const dir = attachmentsDir();
103
+ assert.ok(!dir.includes('node_modules'), `the store must not live inside an installed package: ${dir}`);
104
+ assert.equal(path.basename(dir), 'bug-attachments');
105
+ assert.equal(path.basename(path.dirname(dir)), 'var');
106
+ } finally {
107
+ if (prev === undefined) delete process.env.BUG_ATTACHMENTS_DIR;
108
+ else process.env.BUG_ATTACHMENTS_DIR = prev;
109
+ }
110
+ });
111
+
112
+ test('BUG_ATTACHMENTS_DIR still wins outright', () => {
113
+ const prev = process.env.BUG_ATTACHMENTS_DIR;
114
+ process.env.BUG_ATTACHMENTS_DIR = '/srv/persistent/bug-attachments';
115
+ try {
116
+ assert.equal(attachmentsDir(), '/srv/persistent/bug-attachments',
117
+ 'on a box whose instance root is impermanent, the override is the answer');
118
+ } finally {
119
+ if (prev === undefined) delete process.env.BUG_ATTACHMENTS_DIR;
120
+ else process.env.BUG_ATTACHMENTS_DIR = prev;
121
+ }
122
+ });
@@ -169,18 +169,47 @@ function runGuidance(fn, env) {
169
169
  const code =
170
170
  `const l=require(${JSON.stringify(path.join(REPO_ROOT, 'scripts', 'gds', 'cli-lib.js'))});` +
171
171
  `Promise.resolve(l[${JSON.stringify(fn)}]()).catch(()=>{});`;
172
+ // HOME ALONE IS NOT ISOLATION ON WINDOWS (task 1003941). cli-lib resolves the
173
+ // session through os.homedir(), which on win32 reads USERPROFILE and ignores
174
+ // HOME entirely — so the child found the DEVELOPER'S REAL session, requireSession
175
+ // succeeded, and the guidance these tests exist to read was never printed. That
176
+ // failed on any Windows box whose builder happened to be signed in, and went
177
+ // green again once their CLI session expired, which made it read as a flake.
178
+ //
179
+ // HOMEDRIVE/HOMEPATH are os.homedir()'s second win32 source, so all four go.
180
+ const HOMELESS = path.join(REPO_ROOT, 'no-such-home-for-tests');
172
181
  const r = spawnSync(process.execPath, ['-e', code], {
173
182
  encoding: 'utf8',
174
- env: { ...process.env, ...env, HOME: path.join(REPO_ROOT, 'no-such-home-for-tests') },
183
+ env: {
184
+ ...process.env,
185
+ ...env,
186
+ HOME: HOMELESS,
187
+ USERPROFILE: HOMELESS,
188
+ HOMEDRIVE: '',
189
+ HOMEPATH: HOMELESS,
190
+ },
175
191
  });
176
192
  return `${r.stdout || ''}${r.stderr || ''}`;
177
193
  }
178
194
 
195
+ // The isolation above either worked or it did not, and the difference is visible:
196
+ // a child with no session PRINTS guidance, and a child that found one prints
197
+ // nothing. Every assertion below that looks for something ABSENT is satisfied for
198
+ // free by that silence, so the silence is checked for first (task 1003941).
199
+ function guidanceOutput(fn, env) {
200
+ const out = runGuidance(fn, env);
201
+ assert.ok(out.trim().length > 0,
202
+ `${fn} printed NOTHING. That means the child found a real CLI session instead of `
203
+ + 'the blanked home, so this test is reading an unisolated environment and every '
204
+ + '"must not contain" assertion here is vacuous (task 1003941).');
205
+ return out;
206
+ }
207
+
179
208
  const GUIDANCE = ['printUnauthGuidanceAndExit', 'printBoxScopeGuidanceAndExit', 'requireSession'];
180
209
 
181
210
  test('no guidance path speaks slash commands to a terminal reader', () => {
182
211
  for (const fn of GUIDANCE) {
183
- const out = runGuidance(fn, { BONGOS_SURFACE: 'terminal' });
212
+ const out = guidanceOutput(fn, { BONGOS_SURFACE: 'terminal' });
184
213
  const leaks = [...out.matchAll(/\/builder-[a-z-]+/g)].map((m) => m[0]);
185
214
  assert.deepEqual(leaks, [], `${fn} leaked to a terminal reader: ${leaks.join(', ')}\n${out}`);
186
215
  assert.ok(!/node scripts\/gds\//.test(out), `${fn} told a terminal reader to run a repo script:\n${out}`);
@@ -290,6 +290,23 @@ t('a known idea link is never unwrapped by the past-mislink pass', () => {
290
290
  assert.equal(runIds(s, ids), s);
291
291
  });
292
292
 
293
+ // task 1003935: the separator contract, stated rather than assumed.
294
+ //
295
+ // discoverDocs builds with path.join, so on Windows it returned
296
+ // 'docs\\adr\\0001-….md' and the recursion assertion below — which compares against
297
+ // 'docs/' — was permanently red. Nobody saw it: this file was in run-unit-tests'
298
+ // INTEGRATION exclusion set while touching no database, so it ran in neither lane
299
+ // (fixed in the same task). The assertion was right and the walker was wrong to
300
+ // hand it OS-native paths, so the fix is here at the boundary; this test exists so
301
+ // the next reader does not have to rediscover which of the two was at fault.
302
+ t('discoverDocs returns POSIX-style paths on every platform (task 1003935)', () => {
303
+ const docs = discoverDocs();
304
+ const native = docs.filter((d) => d.includes('\\'));
305
+ assert.equal(native.length, 0,
306
+ `paths must not carry OS-native separators; got ${JSON.stringify(native.slice(0, 3))}`);
307
+ assert.ok(docs.some((d) => d.includes('/')), 'and nested paths must still be nested');
308
+ });
309
+
293
310
  t('discoverDocs finds CLAUDE.md, recurses docs/ subdirs (only .md), skips the generated ideas file', () => {
294
311
  const docs = discoverDocs();
295
312
  assert.ok(docs.includes('CLAUDE.md'), 'includes CLAUDE.md');
@@ -35,8 +35,13 @@ function loadedAfter(spec, read = null) {
35
35
  const ROOT = ${JSON.stringify(ROOT)};
36
36
  const m = require(path.join(ROOT, ${JSON.stringify(spec)}));
37
37
  ${read ? `void m[${JSON.stringify(read)}];` : ''}
38
+ // POSIX-normalised before filtering (task 1003941). path.relative returns
39
+ // backslashes on win32, so 'src/bongos/' matched NOTHING and every list came
40
+ // back empty — which made the two laziness assertions below pass VACUOUSLY on
41
+ // Windows: they assert that a filtered-empty list is empty. An eager require
42
+ // reintroduced at the doorway would have shipped green.
38
43
  const files = Object.keys(require.cache)
39
- .map((f) => path.relative(ROOT, f))
44
+ .map((f) => path.relative(ROOT, f).split(path.sep).join('/'))
40
45
  .filter((f) => f.startsWith('src/bongos/'));
41
46
  process.stdout.write(JSON.stringify(files));
42
47
  `;
@@ -50,6 +55,32 @@ function loadedAfter(spec, read = null) {
50
55
  return JSON.parse(out);
51
56
  }
52
57
 
58
+ // CALIBRATION — can the instrument see anything at all? (task 1003941)
59
+ //
60
+ // Every other assertion in this file has the shape "nothing heavy is in this
61
+ // list", which an EMPTY list satisfies for free. So an empty list is not evidence
62
+ // of laziness; it is evidence of nothing, and the two tests that guard the ADR
63
+ // 0083 doorway invariant cannot tell the difference.
64
+ //
65
+ // That is not hypothetical. loadedAfter filtered on a 'src/bongos/' prefix after
66
+ // path.relative, which returns BACKSLASHES on win32 — so on Windows the filter
67
+ // matched nothing, every list was empty, and both doorway tests passed vacuously.
68
+ // An eager require reintroduced at the doorway would have shipped green.
69
+ //
70
+ // The fix normalises separators, but a normalisation can break again silently, and
71
+ // asserting the doorway's own list is non-empty would be wrong — loading NOTHING
72
+ // from src/bongos/ is precisely what the doorway is supposed to do. So the probe
73
+ // is calibrated against a module that certainly does load one. If this test fails,
74
+ // every emptiness assertion below is meaningless and should be read as untested.
75
+ test('CALIBRATION: the probe can see a src/bongos/ require at all', () => {
76
+ const files = loadedAfter('src/bongos/pool.js');
77
+ assert.ok(files.includes('src/bongos/pool.js'),
78
+ 'requiring src/bongos/pool.js must show up in the probe\'s own list. It does not, so '
79
+ + 'the list is empty for reasons unrelated to laziness — check the path normalisation '
80
+ + 'in loadedAfter (task 1003941). Until this passes, every "loads nothing" assertion '
81
+ + `in this file is vacuous. Saw: ${JSON.stringify(files.slice(0, 5))}`);
82
+ });
83
+
53
84
  test('importing the doorway loads no server: no pool, no auth, no routes', () => {
54
85
  const files = loadedAfter('src/module-api.js');
55
86
  const heavy = files.filter((f) => HEAVY.test(f));