@bongos/core 1.19.1031 → 1.19.1033

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/.bongos-core.json +167 -57
  2. package/.claude/skills/grade-audit/SKILL.md +1 -1
  3. package/.claude/skills/grader-health/SKILL.md +1 -1
  4. package/docs/adr/0110-untrack-whole-file-generated-nav-docs.md +26 -0
  5. package/docs/adr/0175-the-board-room-full-idea-ratification.md +3 -2
  6. package/docs/adr/0178-the-copy-desk-flag-and-queue.md +3 -3
  7. package/docs/adr/0188-patrol-runs-on-ci-not-the-server.md +2 -2
  8. package/docs/adr/0189-baseline-floors-and-the-freeze-detector.md +1 -1
  9. package/docs/adr/0233-a-copy-proposal-is-a-task-carrying-a-patch.md +1 -1
  10. package/docs/adr/0241-the-artist-gate-is-a-per-project-deploy-gate-that-reads-a-state.md +11 -3
  11. package/docs/adr/0338-modules-travel-through-a-bongos-hosted-store.md +119 -0
  12. package/docs/adr/README.md +4 -3
  13. package/docs/architecture.md +1 -1
  14. package/docs/file-map.md +4 -3
  15. package/docs/module-api-changelog.md +4 -0
  16. package/docs/recipes/autobongos-windows-host.md +3 -1
  17. package/docs/recipes/packaging-the-core.md +2 -0
  18. package/docs/recipes/redteam-patrol.md +2 -2
  19. package/docs/recipes/upgrading-the-core.md +1 -1
  20. package/modules/autonomy/gauge.js +57 -9
  21. package/modules/hall-ui/records/builders-roster.md +10 -0
  22. package/modules/hall-ui/records/catalog.md +10 -0
  23. package/modules/hall-ui/records/collaboration.md +14 -0
  24. package/modules/hall-ui/records/goal-dependency-forest.md +11 -0
  25. package/modules/hall-ui/records/oversight-family.md +19 -0
  26. package/modules/hall-ui/records/panel-family.md +19 -0
  27. package/modules/hall-ui/records/reading-rooms.md +12 -0
  28. package/modules/hall-ui/records/record-family.md +13 -0
  29. package/modules/hall-ui/records/reference-room.md +9 -0
  30. package/modules/hall-ui/records/roster-and-ledger.md +9 -0
  31. package/modules/hall-ui/records/sky.md +23 -0
  32. package/modules/lifecycle/db-claim-reads.js +8 -2
  33. package/modules/lifecycle/dependency-advisory.js +2 -1
  34. package/modules/lifecycle/ship-preflight.js +91 -0
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/release-notes.json +7411 -0
  38. package/scripts/gds/address-park-sweep.sh +3 -1
  39. package/scripts/gds/autobongos-run.js +81 -1
  40. package/scripts/gds/baseline-staleness.js +1 -1
  41. package/scripts/gds/db-backup-nightly.sh +9 -4
  42. package/scripts/gds/db-backup-offsite.sh +4 -2
  43. package/scripts/gds/package-core.js +72 -1
  44. package/scripts/gds/provision-core-upgrade.js +128 -77
  45. package/scripts/gds/redteam-patrol.js +4 -4
  46. package/scripts/gds/release-notes.js +271 -0
  47. package/scripts/gds/seed-board-room-tasks.js +1 -1
  48. package/scripts/gds/ship-flow.js +5 -3
  49. package/scripts/gds/ship-land.js +86 -7
  50. package/scripts/gds/ship-preflight-steps.js +56 -3
  51. package/scripts/gds/stray-unit-sweep.sh +3 -1
  52. package/scripts/gds/upgrade-migrate.js +67 -0
  53. package/scripts/gds/upgrade.js +6 -4
  54. package/src/module-api.js +1 -1
  55. package/tests/adr_0162_citation.mjs +184 -0
  56. package/tests/autobongos_loop.mjs +114 -0
  57. package/tests/autonomy_gauge.mjs +56 -0
  58. package/tests/core_upgrade_runner.mjs +89 -1
  59. package/tests/db_backup_nightly.mjs +21 -3
  60. package/tests/db_backup_offsite.mjs +81 -0
  61. package/tests/grade_diff_hash.mjs +77 -2
  62. package/tests/hall_records.mjs +85 -0
  63. package/tests/helpers.mjs +25 -0
  64. package/tests/instance_db_migrated_on_code_move.mjs +8 -1
  65. package/tests/plain_cards.mjs +1 -1
  66. package/tests/redteam_patrol.mjs +1 -1
  67. package/tests/release_notes.mjs +327 -0
  68. package/tests/shell_stat_portability.mjs +49 -0
  69. package/tests/ship_land_conflict.mjs +181 -0
  70. package/tests/ship_preflight.mjs +160 -3
  71. package/tests/sweep_app_account.mjs +15 -2
  72. package/tests/upgrade_migrate.mjs +195 -0
@@ -8,7 +8,7 @@
8
8
  // pre-flight clean git tree + module-compat vs the TARGET core version + the artist gate (ADR 0241)
9
9
  // → bump pin vendor the new core .tgz, rewrite the package.json `file:` dependency
10
10
  // → npm install node_modules/@bongos/core becomes the new version
11
- // → migrate `npm run migrate` → the core's migrate.sh applies additive `core_*` migrations
11
+ // → migrate `npm run migrate` (no such script: the core's migrate.sh itself, upgrade-migrate.js) applies additive `core_*` migrations
12
12
  // → materialize refresh .claude/ skills+settings+hooks from the new core (claude-materialize / W3)
13
13
  // → restart restart the instance's service so the new core is live
14
14
  // → verify installed package version == target (+ optional /healthz + ledger row)
@@ -538,7 +538,8 @@ function preflightDbIdentity({ instanceDir, db }, run = spawnSync) {
538
538
  }
539
539
 
540
540
  function runMigrate(instanceDir, run = spawnSync) {
541
- const r = run('npm', ['run', 'migrate'], { cwd: instanceDir, stdio: 'inherit' });
541
+ const { cmd, args, env } = require('./upgrade-migrate').migratePlan(instanceDir); // its own migrate script, else the core's migrate.sh (task 1004274)
542
+ const r = run(cmd, args, { cwd: instanceDir, stdio: 'inherit', ...(env ? { env } : {}) });
542
543
  return !r || r.error ? { ok: false, reason: r && r.error ? r.error.message : 'spawn failed' } : { ok: r.status === 0, code: r.status };
543
544
  }
544
545
 
@@ -1142,6 +1143,7 @@ async function runUpgrade(opts, deps = {}) {
1142
1143
  if (!dbId.ok) return { ok: false, error: dbId.reason };
1143
1144
  if (dbId.skipped) log(` • db identity pre-check${db ? ` (${db})` : ''}: ${dbId.skipped}`);
1144
1145
  else log(` ✓ pre-flight: database${db ? ` "${db}"` : ''} reachable as the current user`);
1146
+ const { note: migrateNote } = require('./upgrade-migrate').migratePlan(instanceDir); if (migrateNote) log(` ✓ pre-flight: ${migrateNote}`);
1145
1147
  }
1146
1148
 
1147
1149
  // 1a. artist gate (ADR 0241, task 1004205): an open review holds the pin, --force too; a downgrade never waits.
@@ -1181,7 +1183,7 @@ async function runUpgrade(opts, deps = {}) {
1181
1183
  log(` ${dryRun ? '[dry-run] would pin' : '✓ pinned'} ${CORE_PKG} → file:${vendorRel}${prevPin ? ` (was ${prevPin})` : ''}`);
1182
1184
  }
1183
1185
 
1184
- if (dryRun) { log(' [dry-run] would: npm install → npm run migrate → materialize .claude → regen API docs+client → restart → verify'); return { ok: true, dryRun: true, fromVersion, toVersion: targetVersion }; }
1186
+ if (dryRun) { log(` [dry-run] would: npm install → ${opts.skipMigrate ? '(no migrate)' : require('./upgrade-migrate').migratePlan(instanceDir).label} → materialize .claude → regen API docs+client → restart → verify`); return { ok: true, dryRun: true, fromVersion, toVersion: targetVersion }; }
1185
1187
 
1186
1188
  // 3. npm install.
1187
1189
  const inst = npmInstall(instanceDir, run);
@@ -1455,7 +1457,7 @@ async function main(argv = process.argv.slice(2)) {
1455
1457
  ' --health-url <url> poll after restart (~30s) until 2xx; a still-failing check fails the bump (auto-rollback). REQUIRED for a bump that restarts the service, unless --no-health-check.',
1456
1458
  ' --version-url <url> read the RUNNING process\'s coreVersion back after restart and require it to equal --to (default: /version on the --health-url origin). A health check alone cannot tell a new core from an old one that never went down.',
1457
1459
  ' --no-health-check explicitly skip the post-restart health confirmation (default: the check is ON — a bump that restarts the service must confirm it serves); also skips the served-version read-back',
1458
- ' --skip-migrate do not run `npm run migrate`',
1460
+ ' --skip-migrate do not migrate (the default runs the instance\'s `migrate` script, else the core\'s scripts/migrate.sh)',
1459
1461
  ' --skip-materialize do not refresh .claude/',
1460
1462
  ' --skip-regen-docs do not regenerate the OpenAPI spec + typed client from the new core',
1461
1463
  ' --skip-pin-verify do not verify the integrity pin (tree_sha256 + tarball.sha256) after install',
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.1031'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1033'; // 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,184 @@
1
+ // tests/adr_0162_citation.mjs — "a verdict is advisory, never self-executing" is
2
+ // ADR 0158 §2's rule, not ADR 0162's; no surface may cite 0162 for it (task 1003573).
3
+ //
4
+ // WHAT WENT WRONG. ADR 0162 retired REVIEW gates on `main`: no human approval sits in
5
+ // the land path, and the compensating controls it keeps are automatic — three of its
6
+ // four fail CLOSED, the grader among them. Surfaces across the repo nonetheless cited it
7
+ // by a slogan, "verdicts are advisory, never self-executing". ADR 0241 caught that the
8
+ // slogan is false as a blanket claim about gates. This task found the slogan is not
9
+ // 0162's wording at all: it is, nearly verbatim, the heading of ADR 0158 §2 — a rule
10
+ // about what an audit FINDING authorizes (nothing, until a human acts on it). So every
11
+ // gloss of 0162 with that slogan cited the wrong ADR, and the worst of them turned it
12
+ // into a claim 0162 contradicts ("grader verdicts are advisory").
13
+ //
14
+ // WHAT THIS PINS. (1) The premise, read from the two ADRs themselves: the slogan's text
15
+ // is in 0158 and absent from 0162. If either ADR is amended, this goes red and the
16
+ // question is re-asked, instead of the citations quietly drifting back. (2) No tracked
17
+ // surface glosses 0162 with the verdict rule, in any of the shapes the sweep found.
18
+ //
19
+ // Run: node --test tests/adr_0162_citation.mjs
20
+
21
+ import { test } from 'node:test';
22
+ import assert from 'node:assert/strict';
23
+ import { readFileSync } from 'node:fs';
24
+ import { spawnSync } from 'node:child_process';
25
+ import path from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
29
+ const read = (rel) => readFileSync(path.join(ROOT, rel), 'utf8');
30
+
31
+ const ADR_0158 = 'docs/adr/0158-choke-point-verdict-taxonomy.md';
32
+ const ADR_0162 = 'docs/adr/0162-review-gates-retired-rank-consistent-ci-only.md';
33
+
34
+ // The shapes the 2026-09-25 sweep found, each named by the surface it came from.
35
+ // A match is a line that attributes the verdict rule to 0162.
36
+ const MISATTRIBUTIONS = [
37
+ // 0162 glossed with the rule: "0162 (verdicts advisory, never self-executing)",
38
+ // "0162 (grader verdicts are advisory…)", "[ADR 0162] (patrol findings are advisory…)",
39
+ // "ADR 0162 — verdicts never self-execute", "[ADR 0162]: verdicts advisory".
40
+ /\b0162\b[\])]?\s*(?:\(|—|–|:|=)\s*(?:[\w-]+\s+){0,2}(?:verdicts?|findings?)\b[^\n]{0,30}?\b(?:advisory|self-execut)/i,
41
+ // The rule, then 0162 as its source: "findings are advisory (ADR 0162)",
42
+ // "never self-executes (ADR 0162)", "advisory per ADR 0162", "(advisory, ADR 0162)".
43
+ /\b(?:advisory|self-execut\w*)\b\**\s*(?:\(ADR\s+0162\b|,\s*ADR 0162\b|per ADR 0162\b)/i,
44
+ // 0162's real content, then the rule tacked on as if it were 0162's too:
45
+ // "[ADR 0162] — review gates retired on `main`; a verdict is advisory and never …".
46
+ /\b0162\b[^\n]{0,70};\s*an? verdicts? (?:is|are) advisory/i,
47
+ // The rule's consequences cited to 0162: "filed for adjudication (ADR 0162)",
48
+ // "describe-only (ADR 0162)", "describes and queues only, never confirms (ADR 0162".
49
+ /\b(?:adjudicat\w*|describe-only|describes and queues only)\b[^\n]{0,45}?\(ADR 0162\b/i,
50
+ // The rule as the sentence that follows a 0162 citation:
51
+ // "(ADR 0162).** Verdicts are advisory", "The ADR 0162 discipline.** Verdicts are advisory".
52
+ /\b0162\)?(?: discipline)?\.?\**\s*Verdicts? (?:is|are) advisory/i,
53
+ // "the same family of error ADR 0162 rules out" — 0162 rules out no verdict-handling.
54
+ /\bADR 0162 rules out\b/i,
55
+ // "ADVISORY ONLY, same posture as ADR 0162" / "ADR 0162: advisory only".
56
+ /\badvisory only\b[^\n]{0,30}\bADR 0162\b|\bADR 0162\b[^\n]{0,30}\badvisory only\b/i,
57
+ // "it stays advisory in precisely 0162's sense" — the sense is 0158 §2's.
58
+ /\badvisory in (?:precisely|exactly) (?:ADR )?0162's sense\b/i,
59
+ // "ADR 0162's "advisory, never self-executing" holding" (a migration comment).
60
+ /\b0162's\s+["“]?(?:advisory|never self-execut)/i,
61
+ ];
62
+
63
+ // Deliberately NOT corrected, each with its reason. Shrink-only: when one of these is
64
+ // fixed, delete its row — the last test below fails until you do, so a stale exemption
65
+ // cannot outlive the thing it excused.
66
+ const KNOWN_DEFERRED = new Map([
67
+ // A comment on a HARD-FLOOR gate surface (scripts/gds/gate-review.js HARD_FLOOR_GLOBS
68
+ // lists `.github/`): even a comment-only edit there escalates to the owner, which a
69
+ // citation fix does not justify on its own. Correct it with the next real edit.
70
+ ['.github/workflows/redteam-patrol.yml', 'hard-floor gate surface'],
71
+ // An applied migration is never edited: its text is part of what was run.
72
+ ['modules/government/migrations/government_004_board_permissions.sql', 'applied migration'],
73
+ ]);
74
+
75
+ // A session log records what a session believed at the time; rewriting one would
76
+ // falsify history, which is worse than the stale citation it carries.
77
+ const HISTORICAL = /^docs\/session-logs\//;
78
+ // This file quotes every shape it hunts, as examples — so it would flag itself.
79
+ const SELF = 'tests/adr_0162_citation.mjs';
80
+ const TEXT = /\.(?:md|js|mjs|cjs|json|ya?ml|sql|sh|html|txt)$/;
81
+
82
+ // A line that NAMES the slogan as its subject is discussing it (ADR 0241's own
83
+ // analysis; the task-title list in a relevance audit), not citing by it.
84
+ const DISCUSSES_SLOGAN = /paraphrase|shorthand/i;
85
+
86
+ // Markdown link targets carry the ADR's filename ("0162-review-gates-…"), which would
87
+ // otherwise sit between a citation and its gloss.
88
+ const stripLinkTargets = (s) => s.replace(/\]\([^)\s]*\)/g, ']');
89
+ // The continuation of a hard-wrapped line, minus its comment/quote/list lead.
90
+ const continuation = (s) => (s || '').replace(/^\s*(?:\/\/+|#+|--|\*|>|-)?\s*/, '');
91
+
92
+ function offendersIn(rel, text) {
93
+ const lines = stripLinkTargets(text).split('\n');
94
+ const hits = [];
95
+ for (let i = 0; i < lines.length; i++) {
96
+ const here = lines[i];
97
+ if (DISCUSSES_SLOGAN.test(here)) continue;
98
+ // Join the next line so a citation wrapped mid-phrase ("(ADR\n0162)") still reads
99
+ // as one; only a match that STARTS on this line is this line's.
100
+ const window = `${here} ${continuation(lines[i + 1])}`;
101
+ if (!/0162/.test(window)) continue;
102
+ for (const re of MISATTRIBUTIONS) {
103
+ const m = re.exec(window);
104
+ if (m && m.index < here.length) { hits.push(`${rel}:${i + 1}: ${here.trim().slice(0, 140)}`); break; }
105
+ }
106
+ }
107
+ return hits;
108
+ }
109
+
110
+ function trackedTextFiles() {
111
+ const r = spawnSync('git', ['ls-files', '-z'], { cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
112
+ if (r.status !== 0) return null; // a packaged core is not a git checkout
113
+ return r.stdout.split('\0').filter((f) => f && TEXT.test(f));
114
+ }
115
+
116
+ test('the premise: the verdict rule is written in ADR 0158 §2, and ADR 0162 never states it', () => {
117
+ const adr0158 = read(ADR_0158);
118
+ assert.match(adr0158, /^### 2\. A verdict is advisory and never self-executing$/m,
119
+ 'ADR 0158 §2 is the rule\'s source — if it moved, re-point the citations this test guards');
120
+ const adr0162 = read(ADR_0162);
121
+ assert.doesNotMatch(adr0162, /self-execut/i, 'ADR 0162 does not say "self-executing"');
122
+ assert.doesNotMatch(adr0162, /advisory/i, 'ADR 0162 does not say "advisory"');
123
+ // What 0162 DOES say, so a citation of it has something true to point at.
124
+ assert.match(adr0162, /Review requirements on `main` are \*\*retired permanently\*\*/);
125
+ });
126
+
127
+ test('the detector recognises every shape the sweep corrected, and passes the true citations', () => {
128
+ const flag = (line) => offendersIn('probe.md', line).length > 0;
129
+ for (const bad of [
130
+ '- **Related:** 0157 (Archon is rank and identity only), 0162 (verdicts advisory, never self-executing), 0172',
131
+ '0016 (authority resolved server-side), 0162 (grader verdicts are advisory, never self-executing), 0175',
132
+ '- **Related:** [ADR 0162](0162-review-gates-retired-rank-consistent-ci-only.md) (patrol findings are advisory — verdicts never self-execute),',
133
+ '// doctrine (ADR 0162 — verdicts never self-execute): findings are FILED as',
134
+ 'deliberate. Findings are **advisory** (ADR 0162) — they are filed for',
135
+ 'description: `Weekly deterministic red-team patrol (task 1003128; advisory per ADR 0162 — adjudicate)`',
136
+ '// is that: history-wide, time-based, filed for adjudication (ADR 0162).',
137
+ '- **Read-only, describe-only (ADR 0162).** No confirms, no regrades run by this skill.',
138
+ '- **Never confirm, never flip, never re-grade (ADR 0162).** Verdicts are advisory and never self-executing here',
139
+ '**1. The ADR 0162 discipline.** Verdicts are advisory and never self-executing. A gate that',
140
+ 'judgement is finished because a hash moved — the same family of error ADR 0162 rules out for grader',
141
+ '// HARD CONSTRAINT, same as goal-advisory.js and ADR 0162: advisory only. This',
142
+ 'it stays advisory in precisely 0162\'s sense.',
143
+ '- **Reconciles:** [ADR 0162](0162-review-gates-retired-rank-consistent-ci-only.md) — review gates retired on `main`; a verdict is advisory and never self-executing',
144
+ '→ files security reports (advisory, ADR 0162). FIRED BY .github/workflows/redteam-patrol.yml',
145
+ '-- — which is also ADR 0162\'s "advisory, never self-executing" holding.',
146
+ 'decision 2026-08-18: AI review deferred), and findings are **advisory** (ADR\n0162): they are POSTed to',
147
+ ]) assert.ok(flag(bad), `should flag: ${bad}`);
148
+
149
+ for (const good of [
150
+ '- **Reconciles:** [ADR 0162](0162-review-gates-retired-rank-consistent-ci-only.md) — review gates retired on `main`, so no human approval sits in the land path',
151
+ '0016 (authority resolved server-side), 0158 §2 (a verdict is advisory, never self-executing), 0162 (review gates retired — no human approval in the land path), 0175',
152
+ 'deliberate. Findings are **advisory** (ADR 0158 §2) — they are filed for',
153
+ '- **Related:** ADR 0162 (no new human gates on the ship path)',
154
+ 'No review requirement — gates are CI checks, not human approvals (ADR 0162).',
155
+ 'It never holds a builder\'s ship, which is what keeps it compatible with ADR 0162.',
156
+ 'This matters, because the shorthand it is usually cited by — "a verdict is advisory" (ADR 0162) — is a paraphrase',
157
+ ]) assert.ok(!flag(good), `should pass: ${good}`);
158
+ });
159
+
160
+ test('no tracked surface cites ADR 0162 for the verdict rule', (t) => {
161
+ const files = trackedTextFiles();
162
+ if (!files) return t.skip('not a git checkout — nothing tracked to scan');
163
+ const offenders = [];
164
+ for (const rel of files) {
165
+ if (rel === SELF || HISTORICAL.test(rel) || KNOWN_DEFERRED.has(rel)) continue;
166
+ let text;
167
+ try { text = readFileSync(path.join(ROOT, rel), 'utf8'); } catch { continue; }
168
+ if (!text.includes('0162')) continue;
169
+ offenders.push(...offendersIn(rel, text));
170
+ }
171
+ assert.deepEqual(offenders, [],
172
+ 'these cite ADR 0162 for "a verdict is advisory, never self-executing" — that rule is ADR 0158 §2. '
173
+ + 'Cite 0158 §2 for the verdict rule, and 0162 only for what it retired (human approval in the land path).');
174
+ });
175
+
176
+ test('every deferred exemption still has something to excuse (shrink-only)', (t) => {
177
+ if (!trackedTextFiles()) return t.skip('not a git checkout');
178
+ for (const [rel, why] of KNOWN_DEFERRED) {
179
+ let text = '';
180
+ try { text = read(rel); } catch { /* deleted: the exemption is stale too */ }
181
+ assert.ok(offendersIn(rel, text).length > 0,
182
+ `${rel} (${why}) no longer misattributes the rule — delete its KNOWN_DEFERRED row`);
183
+ }
184
+ });
@@ -654,3 +654,117 @@ test('workerEnv passes Claude auth through, and still refuses the rest', () => {
654
654
  assert.equal(env[leak], undefined, `${leak} must never reach a bypassPermissions worker`);
655
655
  }
656
656
  });
657
+ // --- a derived gauge buys BOUNDED work (task 1004178) -----------------------
658
+ //
659
+ // `derived` retires a reading whose window has reset. That is sound for the
660
+ // five-hour window, which turns over during a night. The SEVEN-day one does
661
+ // not, so its stale reading is dropped and the weekly pace check — what the
662
+ // module's notes call the thing that actually binds a heavy week — does not run
663
+ // at all on a derived reading. The task cap is what stands in for it, so the
664
+ // cap has to work, and it has to survive a restart: the count comes from the
665
+ // run log, not from memory.
666
+
667
+ import { mkdtempSync, writeFileSync } from 'node:fs';
668
+ import { tmpdir } from 'node:os';
669
+ import { join } from 'node:path';
670
+
671
+ const derivedLog = () => {
672
+ const dir = mkdtempSync(join(tmpdir(), 'autobongos-cap-'));
673
+ return join(dir, 'runs.jsonl');
674
+ };
675
+
676
+ test('countWorkedSince counts only worked rows at or after the mark', () => {
677
+ const p = derivedLog();
678
+ process.env.AUTOBONGOS_LOG_FILE = p;
679
+ const at = (s) => new Date(s * 1000).toISOString();
680
+ writeFileSync(p, [
681
+ JSON.stringify({ at: at(1000), event: 'worked', task_id: '1' }), // before the mark
682
+ JSON.stringify({ at: at(3000), event: 'hold' }), // not a worked row
683
+ JSON.stringify({ at: at(3100), event: 'worked', task_id: '2' }),
684
+ '{ this line is torn', // costs one row, not the count
685
+ JSON.stringify({ at: at(3200), event: 'worked', task_id: '3' }),
686
+ ].join('\n'));
687
+ assert.equal(runner.countWorkedSince(2000), 2);
688
+ assert.equal(runner.countWorkedSince(0), 0, 'a zero/absent mark counts nothing rather than everything');
689
+ delete process.env.AUTOBONGOS_LOG_FILE;
690
+ });
691
+
692
+ test('countWorkedSince treats an unreadable log as zero, not as a refusal', () => {
693
+ process.env.AUTOBONGOS_LOG_FILE = join(tmpdir(), 'autobongos-cap-nope', 'missing.jsonl');
694
+ assert.equal(runner.countWorkedSince(1), 0,
695
+ 'the fence and the gauge are the gates; a bound that bricks the runner when its own log is gone is worse');
696
+ delete process.env.AUTOBONGOS_LOG_FILE;
697
+ });
698
+
699
+ test('a derived gauge HOLDS once the cap is reached, and the reason says why', async () => {
700
+ const p = derivedLog();
701
+ process.env.AUTOBONGOS_LOG_FILE = p;
702
+ process.env.AUTOBONGOS_DERIVED_TASK_CAP = '2';
703
+ const at = (s) => new Date(s * 1000).toISOString();
704
+ writeFileSync(p, [
705
+ JSON.stringify({ at: at(5100), event: 'worked', task_id: '1' }),
706
+ JSON.stringify({ at: at(5200), event: 'worked', task_id: '2' }),
707
+ ].join('\n'));
708
+
709
+ const events = [];
710
+ await runner.iteration({ goals: [], maxTasks: 1 }, {
711
+ readFence: async () => ({ raw: { enabled: true, goals: [{ goal_id: 7 }] }, graderBypassed: false }),
712
+ gauge: () => ({ decision: { go: true, unknown: false, derived: true, derivedSince: 5000, reason: 'derived' } }),
713
+ pickTask: async () => { throw new Error('CAP BREACHED: reached the picker'); },
714
+ record: (row) => { events.push(row); return row; },
715
+ run: async () => { throw new Error('CAP BREACHED: ran a command'); },
716
+ spawnWorker: async () => { throw new Error('CAP BREACHED: spawned a worker'); },
717
+ });
718
+ assert.equal(events[0].event, 'hold');
719
+ assert.equal(events[0].derived, true);
720
+ assert.equal(events[0].worked_since_derived, 2);
721
+ assert.match(events[0].reason, /cap is 2/);
722
+ delete process.env.AUTOBONGOS_LOG_FILE; delete process.env.AUTOBONGOS_DERIVED_TASK_CAP;
723
+ });
724
+
725
+ test('a derived gauge still WORKS while under the cap', async () => {
726
+ const p = derivedLog();
727
+ process.env.AUTOBONGOS_LOG_FILE = p;
728
+ process.env.AUTOBONGOS_DERIVED_TASK_CAP = '2';
729
+ writeFileSync(p, JSON.stringify({ at: new Date(5100 * 1000).toISOString(), event: 'worked', task_id: '1' }));
730
+ let reachedPicker = false;
731
+ const events = [];
732
+ await runner.iteration({ goals: [], maxTasks: 1 }, {
733
+ readFence: async () => ({ raw: { enabled: true, goals: [{ goal_id: 7 }] }, graderBypassed: false }),
734
+ gauge: () => ({ decision: { go: true, unknown: false, derived: true, derivedSince: 5000, reason: 'derived' } }),
735
+ pickTask: async () => { reachedPicker = true; return { none: true, skipped: [] }; },
736
+ record: (row) => { events.push(row); return row; },
737
+ });
738
+ assert.equal(reachedPicker, true, 'one task done against a cap of two must still proceed');
739
+ delete process.env.AUTOBONGOS_LOG_FILE; delete process.env.AUTOBONGOS_DERIVED_TASK_CAP;
740
+ });
741
+
742
+ test('countWorkedSince is bounded: a huge log does not mean a huge scan', () => {
743
+ const p = derivedLog();
744
+ process.env.AUTOBONGOS_LOG_FILE = p;
745
+ const at = (s) => new Date(s * 1000).toISOString();
746
+ // 40k old rows, then the two that matter. A whole-file parse would touch
747
+ // every one of them on EVERY iteration of a loop that never exits.
748
+ const old = [];
749
+ for (let i = 0; i < 40000; i++) old.push(JSON.stringify({ at: at(1000 + i), event: 'worked', task_id: String(i) }));
750
+ old.push(JSON.stringify({ at: at(900000), event: 'worked', task_id: 'recent-1' }));
751
+ old.push(JSON.stringify({ at: at(900100), event: 'worked', task_id: 'recent-2' }));
752
+ writeFileSync(p, old.join('\n'));
753
+ const t0 = Date.now();
754
+ assert.equal(runner.countWorkedSince(800000), 2, 'only the rows at or after the mark count');
755
+ assert.ok(Date.now() - t0 < 500, 'and it must not walk the whole log to say so');
756
+ delete process.env.AUTOBONGOS_LOG_FILE;
757
+ });
758
+
759
+ test('countWorkedSince stops at the first row older than the mark', () => {
760
+ const p = derivedLog();
761
+ process.env.AUTOBONGOS_LOG_FILE = p;
762
+ const at = (s) => new Date(s * 1000).toISOString();
763
+ writeFileSync(p, [
764
+ JSON.stringify({ at: at(100), event: 'worked', task_id: 'ancient' }),
765
+ JSON.stringify({ at: at(5000), event: 'worked', task_id: 'before-mark' }),
766
+ JSON.stringify({ at: at(9000), event: 'worked', task_id: 'after-mark' }),
767
+ ].join('\n'));
768
+ assert.equal(runner.countWorkedSince(8000), 1);
769
+ delete process.env.AUTOBONGOS_LOG_FILE;
770
+ });
@@ -342,3 +342,59 @@ test('evaluate on a missing file is UNKNOWN — the CLI never defaults to go', (
342
342
  assert.equal(d.unknown, true);
343
343
  });
344
344
  });
345
+
346
+ // --- a reading whose window has RESET is retired, not stale (task 1004178) ---
347
+ //
348
+ // An unattended host cannot refresh the gauge (see modules/autonomy/CLAUDE.md
349
+ // for the evidence). These pin the behaviour that makes it survivable.
350
+
351
+ test('an aged-out reading whose window has RESET is derived, not unknown', () => {
352
+ const now = 100000;
353
+ const g = {
354
+ written_at: now - 10 * HOUR,
355
+ five_hour: { used_percentage: 92, resets_at: now - 2 * HOUR, observed_at: now - 10 * HOUR },
356
+ };
357
+ const d = decideCapacity({ gauge: g, now });
358
+ assert.equal(d.go, true, 'the window it described has ended; 92% is no longer a fact about now');
359
+ assert.equal(d.unknown, false);
360
+ assert.equal(d.derived, true, 'and it must SAY so — the caller bounds work on a derived reading');
361
+ assert.equal(d.derivedSince, now - 2 * HOUR, 'the reset it stands on, so the cap has a start');
362
+ });
363
+
364
+ test('an aged-out reading whose window is STILL RUNNING stays unknown', () => {
365
+ const now = 100000;
366
+ const g = {
367
+ written_at: now - 10 * HOUR,
368
+ five_hour: { used_percentage: 92, resets_at: now + HOUR, observed_at: now - 10 * HOUR },
369
+ };
370
+ const d = decideCapacity({ gauge: g, now });
371
+ assert.equal(d.go, false, 'usage only GROWS inside a window, so an old 92% is a floor, never headroom');
372
+ assert.equal(d.unknown, true);
373
+ });
374
+
375
+ test('a fresh reading is unchanged and is never marked derived', () => {
376
+ const now = 100000;
377
+ const g = {
378
+ written_at: now - 60,
379
+ five_hour: { used_percentage: 10, resets_at: now + HOUR, observed_at: now - 60 },
380
+ seven_day: { used_percentage: 5, resets_at: now + 6 * 24 * HOUR, observed_at: now - 60 },
381
+ };
382
+ const d = decideCapacity({ gauge: g, now });
383
+ assert.equal(d.go, true);
384
+ assert.equal(d.derived, false);
385
+ assert.equal(d.derivedSince, null);
386
+ });
387
+
388
+ test('a derived seven-day window is NOT paced against a boundary we invented', () => {
389
+ const now = 100000;
390
+ // 90% used and only just past its reset: paced, this would refuse. But the
391
+ // boundary is inferred, so pacing it measures progress through a week nobody
392
+ // observed. Skipped rather than faked.
393
+ const g = {
394
+ written_at: now - 10 * HOUR,
395
+ seven_day: { used_percentage: 90, resets_at: now - HOUR, observed_at: now - 10 * HOUR },
396
+ };
397
+ const d = decideCapacity({ gauge: g, now });
398
+ assert.equal(d.derived, true);
399
+ assert.equal(d.go, true);
400
+ });
@@ -886,15 +886,26 @@ await t('a paragraph-long summary is bounded — it reaches a DOM', () => {
886
886
  // A box that really does hold a core checkout. `repos` maps a directory to what git
887
887
  // there answers: `core` (a real core checkout), `notCore` (a git repo of something else),
888
888
  // `shallow`, `owned` (git refuses on ownership) or absent (not a git repo).
889
+ //
890
+ // The release notes the core PACKAGE carries (task 1004218) are asked first, and are
891
+ // stubbed here: none unless a test hands some in, so the checkout tests below exercise the
892
+ // fallback — and no test in this file can ever reach the real registry.
889
893
  const changeDeps = ({
890
894
  shas = { '1.19.900': 'a'.repeat(40), '1.19.949': 'b'.repeat(40) },
891
895
  log = LOG, logOk = true, repos = { '/srv/core': 'core' }, env = {}, privileged = false,
896
+ installedNotes = null, packagedNotes = null,
892
897
  } = {}) => {
893
898
  const ran = [];
899
+ const downloaded = [];
894
900
  return {
895
- ran, env, privileged,
901
+ ran, downloaded, env, privileged,
896
902
  selfInstanceRoot: () => '/srv/core',
897
903
  appUser: () => 'appacct',
904
+ readInstalledNotes: () => (installedNotes ? { notes: installedNotes } : { missing: 'this core carries no release notes' }),
905
+ readPackagedNotes: (v) => {
906
+ downloaded.push(v);
907
+ return packagedNotes ? { notes: packagedNotes } : { missing: `the core ${v} package carries no release notes` };
908
+ },
898
909
  exec: (cmd) => {
899
910
  ran.push(cmd);
900
911
  const dir = (/git -C (\S+) /.exec(cmd) || [])[1];
@@ -1051,6 +1062,83 @@ await t('a long range is capped, and says how many it did not list', () => {
1051
1062
  assert.equal(r.more, 5, 'the remainder is counted, not silently dropped');
1052
1063
  });
1053
1064
 
1065
+ // ---- THE NOTES THE PACKAGE CARRIES, asked first (task 1004218) --------------------
1066
+
1067
+ // What the packer writes into release-notes.json: one key per release, what each brought in.
1068
+ const NOTES = {
1069
+ '1.19.900': [{ text: 'already live before the move' }],
1070
+ '1.19.901': [{ id: '1004001', text: 'the first thing the move brings' }],
1071
+ '1.19.920': [],
1072
+ '1.19.949': [{ id: '1004003', text: 'the target itself' }, { text: 'a hand-landed ship in the target' }],
1073
+ '1.19.950': [{ text: 'after the target, so not part of this move' }],
1074
+ };
1075
+
1076
+ await t('THE PACKAGE’S NOTES ANSWER FIRST — on a box with no checkout, and with no git run at all', () => {
1077
+ // A tenant box, which is every box but the one that builds the core: no core history.
1078
+ const d = changeDeps({ packagedNotes: NOTES, repos: {} });
1079
+ const r = R.changesBetween('1.19.900', '1.19.949', d);
1080
+ assert.equal(r.error, undefined);
1081
+ assert.deepEqual(r.entries.map((e) => e.text),
1082
+ ['the target itself', 'a hand-landed ship in the target', 'the first thing the move brings'], 'newest release first');
1083
+ assert.equal(r.source, 'release-notes');
1084
+ assert.deepEqual(d.ran, [], 'not one git command');
1085
+ assert.deepEqual(d.downloaded, ['1.19.949'], "the TARGET's own package is the one read");
1086
+ });
1087
+
1088
+ await t('the range is (from, to]: what the box already runs is not listed, what the target carries is', () => {
1089
+ const texts = R.changesBetween('1.19.900', '1.19.949', changeDeps({ packagedNotes: NOTES })).entries.map((e) => e.text);
1090
+ assert.ok(!texts.includes('already live before the move'), '`from` is exclusive');
1091
+ assert.ok(texts.includes('the target itself'), '`to` is inclusive');
1092
+ assert.ok(!texts.includes('after the target, so not part of this move'));
1093
+ });
1094
+
1095
+ await t('the INSTALLED core’s notes answer alone when they already reach the target — nothing is downloaded', () => {
1096
+ const d = changeDeps({ installedNotes: NOTES });
1097
+ assert.equal(R.changesBetween('1.19.900', '1.19.949', d).entries.length, 3);
1098
+ assert.deepEqual(d.downloaded, [], 'a tenant catching up to where the platform already is costs no download');
1099
+ });
1100
+
1101
+ await t('installed notes that stop SHORT of the target send it to the target’s package', () => {
1102
+ const d = changeDeps({ installedNotes: { '1.19.900': [], '1.19.901': [{ text: 'x' }] }, packagedNotes: NOTES });
1103
+ assert.equal(R.changesBetween('1.19.900', '1.19.949', d).entries.length, 3);
1104
+ assert.deepEqual(d.downloaded, ['1.19.949']);
1105
+ });
1106
+
1107
+ await t('notes that BEGIN after the box’s version are not used — the list would look whole and be short', () => {
1108
+ // A bounded file drops its oldest releases. Answering a range that reaches back past
1109
+ // where it begins would silently omit what it no longer holds.
1110
+ const r = R.changesBetween('1.19.850', '1.19.949', changeDeps({ packagedNotes: NOTES, repos: {} }));
1111
+ assert.equal(r.entries, undefined, 'no list at all');
1112
+ assert.match(r.error, /the release notes in the core 1\.19\.949 package begin at core 1\.19\.900, after 1\.19\.850/);
1113
+ });
1114
+
1115
+ await t('NEITHER SOURCE CAN ANSWER: the error carries both reasons, and is never an empty list', () => {
1116
+ // Every target published before the notes were packaged, on every tenant box.
1117
+ const r = R.changesBetween('1.19.900', '1.19.949', changeDeps({ repos: {} }));
1118
+ assert.equal(r.entries, undefined);
1119
+ assert.match(r.error, /the core 1\.19\.949 package carries no release notes/, "the notes' reason");
1120
+ assert.match(r.error, /no core checkout is reachable/, "and the checkout's own precise sentence (task 1004212)");
1121
+ });
1122
+
1123
+ await t('no notes in the package, but a checkout on the box: the checkout still answers', () => {
1124
+ const r = R.changesBetween('1.19.900', '1.19.949', changeDeps());
1125
+ assert.equal(r.entries.length, 2);
1126
+ assert.equal(r.source, 'checkout');
1127
+ });
1128
+
1129
+ await t('a download that fails is named, and the checkout is still tried', () => {
1130
+ const d = changeDeps({ repos: {} });
1131
+ d.readPackagedNotes = () => ({ error: 'could not download the core 1.19.949 package to read its release notes (npm error code E404)' });
1132
+ assert.match(R.changesBetween('1.19.900', '1.19.949', d).error, /could not download the core 1\.19\.949 package.*; no core checkout is reachable/);
1133
+ });
1134
+
1135
+ await t('a notes answer is capped exactly like a checkout answer', () => {
1136
+ const many = { '1.19.900': [], '1.19.949': Array.from({ length: R.CHANGE_CAP + 3 }, (_, i) => ({ text: `summary ${i}` })) };
1137
+ const r = R.changesBetween('1.19.900', '1.19.949', changeDeps({ packagedNotes: many }));
1138
+ assert.equal(r.entries.length, R.CHANGE_CAP);
1139
+ assert.equal(r.more, 3);
1140
+ });
1141
+
1054
1142
  await t('the dry run records the list beside the verdict, keyed to where the box IS', async () => {
1055
1143
  // Both answers are about ONE move, so they are stored together — and `from` is stored
1056
1144
  // with them because the list is only true of that starting point.
@@ -18,6 +18,7 @@ import fs from 'node:fs';
18
18
  import os from 'node:os';
19
19
  import path from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
+ import { macShapedTools } from './helpers.mjs';
21
22
 
22
23
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
24
  const SCRIPT = path.join(ROOT, 'scripts', 'gds', 'db-backup-nightly.sh');
@@ -29,13 +30,15 @@ const ENV = 'gds_backup_'.toUpperCase();
29
30
  // A fake pg_dump: a plain-format dump of `rows` rows, closed the way pg_dump closes one
30
31
  // (including the \unrestrict line patched servers append) unless it is `cut` short, and
31
32
  // exiting `exitCode`.
32
- // `seed` pre-places dumps in the shared dir as [filename, ageInDays].
33
- function runBackup({ rows = 40, cut = false, exitCode = 0, env = {}, seed = [] } = {}) {
33
+ // `seed` pre-places dumps in the shared dir as [filename, ageInDays]; `mac` puts
34
+ // BSD/macOS-shaped stat and wc first on PATH too.
35
+ function runBackup({ rows = 40, cut = false, exitCode = 0, env = {}, seed = [], mac = false } = {}) {
34
36
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'db-backup-'));
35
37
  const bin = path.join(tmp, 'bin');
36
38
  const dir = path.join(tmp, 'backups');
37
39
  fs.mkdirSync(bin);
38
40
  fs.mkdirSync(dir);
41
+ if (mac) macShapedTools(bin);
39
42
  for (const [name, ageDays] of seed) {
40
43
  const f = path.join(dir, name);
41
44
  fs.writeFileSync(f, '');
@@ -50,7 +53,7 @@ function runBackup({ rows = 40, cut = false, exitCode = 0, env = {}, seed = [] }
50
53
  encoding: 'utf8',
51
54
  env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, [`${ENV}DB`]: 'smallproj', [`${ENV}DIR`]: dir, ...env },
52
55
  });
53
- return { status: r.status, stdout: r.stdout, stderr: r.stderr, dumps: fs.readdirSync(dir).filter((f) => f.endsWith('.sql.gz')) };
56
+ return { status: r.status, stdout: r.stdout, stderr: r.stderr, dir, dumps: fs.readdirSync(dir).filter((f) => f.endsWith('.sql.gz')) };
54
57
  }
55
58
 
56
59
  test('a complete dump of a small project is kept, however small', { skip }, () => {
@@ -93,6 +96,21 @@ test('the prune and the count reach only this database\'s own dumps, never a lon
93
96
  assert.match(r.stdout, /ok · 1 dump\(s\) retained/, 'the count is this database\'s dumps only');
94
97
  });
95
98
 
99
+ test('it runs the same on macOS, where stat refuses -c and wc pads its numbers (task 1004170)', { skip }, () => {
100
+ // Every case above used to die on a Mac with "stat: illegal option -- c" while CI's
101
+ // Linux passed them, so the red never showed in Actions. This runs the Mac's tools here.
102
+ const attach = fs.mkdtempSync(path.join(os.tmpdir(), 'db-backup-attach-'));
103
+ for (const f of ['a.png', 'b.png']) fs.writeFileSync(path.join(attach, f), 'x');
104
+ const r = runBackup({ mac: true, seed: [['smallproj-2026-01-01T000000Z.sql.gz', 40]], env: { BUG_ATTACHMENTS_DIR: attach } });
105
+ assert.equal(r.status, 0, r.stderr);
106
+ assert.equal(r.dumps.length, 1, 'the expired dump is pruned and the new one kept');
107
+ const size = fs.statSync(path.join(r.dir, r.dumps[0])).size;
108
+ assert.match(r.stdout, new RegExp(`wrote .*\\.sql\\.gz \\(${size} bytes\\)`), 'the byte count is the real size, unpadded');
109
+ assert.match(r.stdout, /\(2 attachment\(s\)\)/);
110
+ assert.match(r.stdout, /pruned 1 dump\(s\)/);
111
+ assert.match(r.stdout, /ok · 1 dump\(s\) retained/);
112
+ });
113
+
96
114
  test('no fixed size floor is baked in for every instance', () => {
97
115
  const src = fs.readFileSync(SCRIPT, 'utf8');
98
116
  assert.doesNotMatch(src, new RegExp(`${ENV}MIN_BYTES:-[1-9]`), 'a default floor fails every database smaller than it');