@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
@@ -31,7 +31,9 @@ DB="${PGDATABASE:-cloudbongos}"
31
31
  CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
32
32
  case "$CORE_DIR" in */node_modules/@bongos/core) INSTANCE_DIR="${INSTANCE_DIR:-${CORE_DIR%/node_modules/@bongos/core}}" ;; esac
33
33
  [ -n "${INSTANCE_DIR:-}" ] || { echo "set INSTANCE_DIR to the control plane's instance checkout — this sweep is not running from one"; exit 1; }
34
- APP_USER="${PROVISION_APP_USER:-$(stat -c %U "$INSTANCE_DIR" 2>/dev/null)}"
34
+ # Owner by name: GNU stat spells it `-c %U`, BSD/macOS stat `-f %Su`, and each refuses the
35
+ # other's flag, so GNU is asked first and BSD answers when it refuses (task 1004170).
36
+ APP_USER="${PROVISION_APP_USER:-$(stat -c %U "$INSTANCE_DIR" 2>/dev/null || stat -f %Su "$INSTANCE_DIR" 2>/dev/null)}"
35
37
  if [ "$APP_USER" = root ] || ! [[ "$APP_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]]; then
36
38
  echo "cannot use '$APP_USER' as the app account — set PROVISION_APP_USER to the unprivileged account that owns $INSTANCE_DIR"; exit 1
37
39
  fi
@@ -94,6 +94,56 @@ function logPath() {
94
94
  try { return require('../../src/instance-config.js').configPath('autobongos-runs.jsonl'); }
95
95
  catch (_) { return path.join(REPO_ROOT, 'autobongos-runs.jsonl'); }
96
96
  }
97
+ // countWorkedSince — how many tasks this runner has worked since an epoch-second
98
+ // mark. Reads the run log, because that is the only record that survives a
99
+ // restart, and the cap it feeds has to hold across one (a runner that forgets
100
+ // its count on every crash has no cap at all).
101
+ //
102
+ // BOUNDED ON BOTH AXES, because this runs every iteration of a loop that never
103
+ // exits and the log only grows. It reads the TAIL rather than the file, and it
104
+ // scans BACKWARDS and stops at the first row older than the mark — the rows that
105
+ // can possibly count are the newest ones, so the common case touches a handful
106
+ // of lines whatever the log's size.
107
+ //
108
+ // A torn or missing log counts as zero rather than refusing: the fence and the
109
+ // gauge are the gates, this is a bound on top of them, and a bound that bricks
110
+ // the runner when its own log is unreadable is worse than one that occasionally
111
+ // allows an extra task.
112
+ const WORKED_SCAN_TAIL_BYTES = 1024 * 1024;
113
+ const NEWLINE = String.fromCharCode(10);
114
+
115
+ function countWorkedSince(sinceEpochS) {
116
+ if (!Number.isFinite(sinceEpochS) || sinceEpochS <= 0) return 0;
117
+ let text;
118
+ try {
119
+ const p = logPath();
120
+ const { size } = fs.statSync(p);
121
+ const from = Math.max(0, size - WORKED_SCAN_TAIL_BYTES);
122
+ const fd = fs.openSync(p, 'r');
123
+ try {
124
+ const buf = Buffer.alloc(Math.min(size, WORKED_SCAN_TAIL_BYTES));
125
+ fs.readSync(fd, buf, 0, buf.length, from);
126
+ text = buf.toString('utf8');
127
+ } finally { fs.closeSync(fd); }
128
+ // A tail read can land mid-line; drop the first partial one.
129
+ if (from > 0) text = text.slice(text.indexOf(NEWLINE) + 1);
130
+ } catch (_) { return 0; }
131
+
132
+ const lines = text.split(NEWLINE);
133
+ let n = 0;
134
+ for (let i = lines.length - 1; i >= 0; i--) {
135
+ const line = lines[i];
136
+ if (!line.trim()) continue;
137
+ let row;
138
+ try { row = JSON.parse(line); } catch (_) { continue; } // a torn line costs one row, not the count
139
+ const at = Date.parse(row.at);
140
+ if (!Number.isFinite(at)) continue;
141
+ if (at / 1000 < sinceEpochS) break; // the log is append-ordered: nothing older can count
142
+ if (row.event === 'worked') n += 1;
143
+ }
144
+ return n;
145
+ }
146
+
97
147
  function record(entry) {
98
148
  const row = { at: new Date().toISOString(), ...entry };
99
149
  try {
@@ -342,6 +392,36 @@ async function iteration(opts, deps = {}) {
342
392
  return log({ event: 'hold', go: false, unknown: !!g.decision.unknown, reason: g.decision.reason, sleep_until: g.decision.sleepUntil ?? null });
343
393
  }
344
394
 
395
+ // 1b. A DERIVED gauge buys a BOUNDED amount of work (task 1004178).
396
+ //
397
+ // `derived` means the last real reading's window had already reset, so the
398
+ // baseline is a retired observation rather than a measurement. That is sound
399
+ // for the FIVE-hour window, which turns over during a night. It does nothing
400
+ // for the SEVEN-day one, which does not — so the weekly pace check, the thing
401
+ // the module's notes call what actually binds a heavy week, is simply absent
402
+ // on a derived reading. A stale weekly figure cannot substitute: usage only
403
+ // grows, so an under-reported value makes the check more permissive, and that
404
+ // is the wrong direction to be wrong in.
405
+ //
406
+ // So the bound is a task count, not a percentage: on one derived window, do
407
+ // AUTOBONGOS_DERIVED_TASK_CAP tasks and then hold until a real reading exists.
408
+ // A terminal session writes one; the owner opening a terminal in the morning
409
+ // is what lifts it. Default 2 — each worker is wall-clock bounded at 90
410
+ // minutes, so two of them is well inside a five-hour window even at worst, and
411
+ // the cap only has to be small enough that being wrong is survivable.
412
+ if (g.decision.derived) {
413
+ const cap = Number(process.env.AUTOBONGOS_DERIVED_TASK_CAP ?? 2);
414
+ const since = Number(g.decision.derivedSince) || 0;
415
+ const done = countWorkedSince(since);
416
+ if (Number.isFinite(cap) && cap >= 0 && done >= cap) {
417
+ return log({
418
+ event: 'hold', go: false, unknown: false, derived: true, worked_since_derived: done,
419
+ reason: `derived gauge: ${done} task(s) already done since the window reset and the cap is ${cap} — holding for a real reading (a terminal session writes one)`,
420
+ sleep_until: null,
421
+ });
422
+ }
423
+ }
424
+
345
425
  // 2. Pick — from the ALLOWLISTED goals, never from what the command line asked
346
426
  // for. gate.goals is already the intersection.
347
427
  const picked = await pick(gate.goals, deps.pickDeps || {});
@@ -798,7 +878,7 @@ if (require.main === module) {
798
878
 
799
879
  module.exports = {
800
880
  readArgv, pickTask, iteration, record, logPath, spawnWorker, workerEnv, WORKER_ENV_ALLOW, resolveWorkerBin,
801
- worktreeName, readFence, reconcileLeftoverClaims, pollSignals, waitOrJump, forever, CLAIM_PREFIX,
881
+ worktreeName, readFence, reconcileLeftoverClaims, pollSignals, waitOrJump, forever, CLAIM_PREFIX, countWorkedSince,
802
882
  heartbeatPath, writeHeartbeat, readHeartbeat, pidAlive, anotherRunnerIsAlive, HEARTBEAT_STALE_MS,
803
883
  isRunnerTree, RUNNER_TREE_RE, postHeartbeat, RUNNER_STARTED_AT,
804
884
  };
@@ -40,7 +40,7 @@
40
40
  // anybody's diff. Hard-failing a PR because ambient debt turned eight weeks old
41
41
  // punishes whoever pushed next and gets the gate disabled. The audit asked for
42
42
  // "the repeatable, SCHEDULED version" of its own manual review, and the patrol
43
- // is that: history-wide, time-based, filed for adjudication (ADR 0162).
43
+ // is that: history-wide, time-based, filed for adjudication (ADR 0158 §2).
44
44
  //
45
45
  // AGE COMES FROM GIT, NOT FROM A STORED TIMESTAMP. A `since:` field in the JSON
46
46
  // would be faster and hermetic, and it would also be the thing that silently
@@ -131,7 +131,10 @@ pg_dump \
131
131
  # A SIGKILL mid-stream leaves a .partial that the next run will overwrite.
132
132
  mv "$OUT.partial" "$OUT"
133
133
 
134
- BYTES=$(stat -c '%s' "$OUT")
134
+ # Size in bytes via POSIX `wc -c`, not `stat -c '%s'`: that is GNU-only, and BSD/macOS
135
+ # stat refuses it outright, so every run on a Mac died here (task 1004170). tr drops the
136
+ # padding BSD wc prints in front of the number.
137
+ BYTES=$(wc -c < "$OUT" | tr -d '[:space:]')
135
138
  echo "[$(date -u +%FT%TZ)] gds-db-backup: wrote ${OUT} (${BYTES} bytes)"
136
139
 
137
140
  # Completeness guard (incident 2026-07-02, task 1711; task 1003925). pipefail already
@@ -190,7 +193,9 @@ ATTACH_OUT="${BACKUP_DIR}/${DB_NAME}-attachments-${TS}.tar.gz"
190
193
  ATTACH_GLOB="${DB_NAME}-attachments-${D}${D}${D}${D}-${D}${D}-${D}${D}T${D}${D}${D}${D}${D}${D}Z.tar.gz"
191
194
 
192
195
  if [ -d "$ATTACH_DIR" ]; then
193
- ATTACH_COUNT=$(find "$ATTACH_DIR" -maxdepth 1 -type f | wc -l)
196
+ # Every count here goes through tr for the same reason as BYTES: BSD wc pads it, which
197
+ # put "pruned 1 dump(s)" in a Mac's log where Linux writes "pruned 1 dump(s)".
198
+ ATTACH_COUNT=$(find "$ATTACH_DIR" -maxdepth 1 -type f | wc -l | tr -d '[:space:]')
194
199
  if [ "$ATTACH_COUNT" -gt 0 ]; then
195
200
  if tar -czf "$ATTACH_OUT.partial" -C "$ATTACH_DIR" . 2>/dev/null; then
196
201
  mv "$ATTACH_OUT.partial" "$ATTACH_OUT"
@@ -215,7 +220,7 @@ fi
215
220
  PRUNED=$(find "$BACKUP_DIR" -maxdepth 1 -type f \
216
221
  \( -name "$DUMP_GLOB" -o -name "$ATTACH_GLOB" \) \
217
222
  -mtime +"${RETENTION_DAYS}" \
218
- -print -delete | wc -l)
223
+ -print -delete | wc -l | tr -d '[:space:]')
219
224
 
220
225
  if [ "$PRUNED" -gt 0 ]; then
221
226
  echo "[$(date -u +%FT%TZ)] gds-db-backup: pruned ${PRUNED} dump(s) older than ${RETENTION_DAYS}d"
@@ -223,5 +228,5 @@ fi
223
228
 
224
229
  # ---- emit a one-line summary for journald + monitoring ---------------------
225
230
 
226
- REMAINING=$(find "$BACKUP_DIR" -maxdepth 1 -type f -name "$DUMP_GLOB" | wc -l)
231
+ REMAINING=$(find "$BACKUP_DIR" -maxdepth 1 -type f -name "$DUMP_GLOB" | wc -l | tr -d '[:space:]')
227
232
  echo "[$(date -u +%FT%TZ)] gds-db-backup: ok · ${REMAINING} dump(s) retained · ${BYTES} bytes latest"
@@ -169,7 +169,9 @@ if [ -z "$LATEST" ]; then
169
169
  fi
170
170
 
171
171
  BASENAME=$(basename "$LATEST")
172
- BYTES=$(stat -c '%s' "$LATEST")
172
+ # Sizes via POSIX `wc -c`, never GNU-only `stat -c '%s'`, which BSD/macOS stat refuses
173
+ # (task 1004170). tr drops the padding BSD wc prints in front of the number.
174
+ BYTES=$(wc -c < "$LATEST" | tr -d '[:space:]')
173
175
 
174
176
  # ---- encrypt before it leaves the box ---------------------------------------
175
177
  #
@@ -189,7 +191,7 @@ fi
189
191
 
190
192
  # A zero-byte or missing ciphertext would upload happily and restore to nothing.
191
193
  # Checked here rather than trusted, because the failure is silent for a month.
192
- ENC_BYTES=$(stat -c '%s' "$ENC")
194
+ ENC_BYTES=$(wc -c < "$ENC" | tr -d '[:space:]')
193
195
  if [ "$ENC_BYTES" -lt 1 ]; then
194
196
  echo "ERROR: encryption produced an empty file — refusing to upload it as a backup." >&2
195
197
  exit 2
@@ -34,6 +34,10 @@
34
34
  // 3. Per file, IN MEMORY (processCoreFile): DOCS (.md) are genericized + secret-scrubbed
35
35
  // like the mirror; FUNCTIONAL files are kept VERBATIM and scanned only for an exact
36
36
  // live-credential leak (brand/IP hits are non-fatal ADVISORIES, surfaced for a human).
37
+ // 3b. RELEASE NOTES (task 1004218): release-notes.json — what each release brought in,
38
+ // generated from the ref's HISTORY (scripts/gds/release-notes.js), its prose redacted
39
+ // exactly like a doc, and pinned like every other file. Skipped, with a line saying
40
+ // so, where there is no history to read (--from-worktree, a shallow clone).
37
41
  // 4. NO-LEAK GATE (fail-closed): if any DOC leaks post-redaction, or any FUNCTIONAL file
38
42
  // carries a live credential verbatim, PRODUCE NOTHING — dist/ is never touched. The
39
43
  // in-memory pass makes the abort atomic (same discipline as the mirror export).
@@ -105,6 +109,12 @@ const {
105
109
  const { collectEnvSecrets } = require(path.join(REPO_ROOT, 'modules/security/secret-scrub.js'));
106
110
  const { redactBlob, parseLsTree } = require(path.join(REPO_ROOT, 'scripts/public-mirror-export.js'));
107
111
  const { partitionHits, unusedEntries } = require(path.join(REPO_ROOT, 'scripts/gds/leak-scan-allowlist.js'));
112
+ // The release notes shipped beside package.json (task 1004218). Required by a literal path:
113
+ // the module is pure and requires nothing, so it adds no computed require to this file's
114
+ // executed closure (tests/executed_closure.mjs counts those).
115
+ const {
116
+ RELEASE_NOTES_FILE, RELEASE_NOTES_MAX_BYTES, generateReleaseNotes, boundReleaseNotes, serializeReleaseNotes,
117
+ } = require('./release-notes.js');
108
118
 
109
119
  const MANIFEST_SCHEMA = 1;
110
120
  const ARTIFACT_NAME = 'bongos-core';
@@ -734,6 +744,42 @@ function scanArtifactForLeaks(files, { literals = PRIOR_INSTANCE_LITERALS } = {}
734
744
  return { literalHits, secretWarnings };
735
745
  }
736
746
 
747
+ // release-notes.json — the one file the core carries that is derived from HISTORY rather
748
+ // than from a committed file (task 1004218). It only exists where the history does, which
749
+ // is the CI pack (publish.yml checks out at fetch-depth 0). main() stages it into the SAME
750
+ // staged/filesMeta arrays as package.json, so the pin manifest covers it and --self-verify
751
+ // reproduces it; a file written into the stage dir behind the manifest's back would fail
752
+ // self-verify at publish time.
753
+ //
754
+ // ITS PROSE IS TREATED AS A DOC. These are merge summaries — builder-authored sentences — so
755
+ // the neutral floor applies exactly as it does to a shipped .md: redacted with the same rules
756
+ // (redactBlob), and any residue after redaction is a fatal no-leak failure. Not optional:
757
+ // ten of the 1,399 summaries in the history (measured 2026-09-25) name a prior instance or
758
+ // its owner, and history cannot be edited — shipped verbatim, they would fail the fail-closed
759
+ // post-build scan on EVERY pack from now on, and the lane would never publish again. That
760
+ // scan still reads the finished file, as it reads every staged one.
761
+ //
762
+ // Pure: notes + the rules main() already built in, `{ buf, notes, dropped, summaries,
763
+ // redacted, leaks }` out.
764
+ function releaseNotesArtifact(notes, { rules, sensitiveLiterals, envSecrets, knownDocNames, maxBytes = RELEASE_NOTES_MAX_BYTES }) {
765
+ const leaks = [];
766
+ let redacted = 0;
767
+ const clean = {};
768
+ for (const [version, entries] of Object.entries(notes || {})) {
769
+ clean[version] = entries.map((e) => {
770
+ const r = redactBlob({ buf: Buffer.from(e.text, 'utf8'), rules, sensitiveLiterals, envSecrets, knownDocNames });
771
+ for (const l of r.leaks) leaks.push({ label: `doc:${l.label}`, sample: l.sample });
772
+ if (r.secret) leaks.push({ label: 'doc:secret-shape' });
773
+ const text = r.buf.toString('utf8');
774
+ if (text !== e.text) redacted++;
775
+ return e.id ? { id: e.id, text } : { text };
776
+ });
777
+ }
778
+ const bounded = boundReleaseNotes(clean, maxBytes);
779
+ const summaries = Object.values(bounded.notes).reduce((n, list) => n + list.length, 0);
780
+ return { buf: serializeReleaseNotes(bounded.notes), notes: bounded.notes, dropped: bounded.dropped, summaries, redacted, leaks };
781
+ }
782
+
737
783
  // Self-verify the built artifact (task 1002218 / audit H3, findings F1+F5). The packager
738
784
  // claims a consumer "verifies an install by recomputing [the pin] from the unpacked tree" —
739
785
  // this proves the packager's OWN output actually does, at build time, on the produce side:
@@ -964,6 +1010,29 @@ function main() {
964
1010
  staged.push({ name: TAR_ROOT + relPath, mode: 0o644, buf });
965
1011
  }
966
1012
 
1013
+ // 4b. The release notes (task 1004218): what each release brought in, generated from this
1014
+ // ref's history, redacted as a doc, and staged into the same arrays so the pin covers
1015
+ // them. A worktree build or a shallow clone has no history to read, so it ships without
1016
+ // — and an upgrade onto it says its notes are unavailable rather than listing nothing.
1017
+ const notesGen = fromWorktree
1018
+ ? { skipped: 'built from the working tree, which carries no history' }
1019
+ : generateReleaseNotes({ git, ref: sha, version });
1020
+ let notesStaged = false;
1021
+ if (notesGen.skipped) {
1022
+ console.error(`[package-core] ! ${RELEASE_NOTES_FILE} not generated — ${notesGen.skipped}.`);
1023
+ } else {
1024
+ const art = releaseNotesArtifact(notesGen.notes, { rules, sensitiveLiterals, envSecrets, knownDocNames });
1025
+ for (const l of art.leaks) leaks.push({ path: RELEASE_NOTES_FILE, label: l.label, sample: l.sample });
1026
+ filesMeta.push({ path: RELEASE_NOTES_FILE, mode: '100644', sha256: sha256hex(art.buf) });
1027
+ staged.push({ name: TAR_ROOT + RELEASE_NOTES_FILE, mode: 0o644, buf: art.buf });
1028
+ notesStaged = true;
1029
+ const kept = Object.keys(art.notes);
1030
+ const trimmed = art.dropped.length
1031
+ ? ` — the ${art.dropped.length} oldest release(s) DROPPED to stay under ${RELEASE_NOTES_MAX_BYTES / 1024} KiB`
1032
+ : '';
1033
+ console.error(`[package-core] ${RELEASE_NOTES_FILE}: ${kept.length} release(s) ${kept[0]} → ${kept[kept.length - 1]}, ${art.summaries} summaries (${art.redacted} redacted), ${(art.buf.length / 1024).toFixed(1)} KiB${trimmed}.`);
1034
+ }
1035
+
967
1036
  if (leaks.length > 0) {
968
1037
  console.error(`\n[package-core] NO-LEAK GATE FAILED — ${leaks.length} real leak(s) (doc redaction residue or a live credential in a functional file). PRODUCING NOTHING (fail-closed):`);
969
1038
  for (const l of leaks.slice(0, 50)) {
@@ -977,7 +1046,7 @@ function main() {
977
1046
  console.error(' for a live-credential leak, remove the secret at SOURCE (it should never be committed). Then re-run. dist/ was NOT touched.');
978
1047
  process.exit(1);
979
1048
  }
980
- console.error(`[package-core] no-leak gate PASSED — ${staged.length} files clean (${docCount} docs redacted, ${stubCount} agent-docs stubbed, ${funcCount} functional verbatim).`);
1049
+ console.error(`[package-core] no-leak gate PASSED — ${staged.length} files clean (${docCount} docs redacted, ${stubCount} agent-docs stubbed, ${funcCount} functional verbatim${notesStaged ? `, ${RELEASE_NOTES_FILE} generated` : ''}).`);
981
1050
 
982
1051
  // Advisory: brand-identity + public-IP literals that ship VERBATIM inside functional
983
1052
  // files. Not a leak (these are public, not secrets; a downstream rebrands via config) —
@@ -1139,6 +1208,8 @@ module.exports = {
1139
1208
  PRIOR_INSTANCE_FLOOR,
1140
1209
  // task 1003995 — --from-worktree's enumerator, for a core checkout with no git history
1141
1210
  walkWorktreeEntries,
1211
+ // task 1004218 — release-notes.json: the history-derived file, redacted as a doc and bounded
1212
+ releaseNotesArtifact,
1142
1213
  };
1143
1214
 
1144
1215
  if (require.main === module) main();
@@ -28,10 +28,14 @@
28
28
  // Its own file because scripts/gds/provision.js sits at the 1500-line oversized-file
29
29
  // ratchet, the same reason provision-net.js and provision-units.js were carved out.
30
30
 
31
+ const fs = require('fs');
32
+ const os = require('os');
31
33
  const path = require('path');
34
+ const { spawnSync } = require('child_process');
32
35
  const { CONFIG, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, REPO_ROOT } = require('./provision-config.js');
33
36
  const { appUser, dbName, instanceUser, selfInstanceRoot, standaloneRoot } = require('./provision-repo.js');
34
37
  const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
38
+ const { RELEASE_NOTES_FILE, RELEASE_NOTES_MAX_BYTES, parseChangeRecords, notesBetween } = require('./release-notes.js');
35
39
 
36
40
  // The shapes whose box this runner can actually reach. Mirrors UPGRADE_SHAPES in the
37
41
  // web-tier half, and deliberately EXCLUDES 'dedicated': a dedicated droplet is stood up
@@ -452,74 +456,101 @@ function preflightUpgrade(inst, to, deps) {
452
456
  // requires one and posts it to Discord. So this reads the merge summaries between the two
453
457
  // releases rather than inventing a changelog nobody would maintain.
454
458
  //
455
- // IT READS THE CONTROL PLANE'S OWN CORE CHECKOUT, ALWAYS, whichever instance is being
456
- // previewed. "What changed in the core between 1.19.881 and 1.19.972" is a question about
457
- // the CORE repository, and a tenant's instance directory is a checkout of the TENANT's
458
- // repo with the core vendored in from npm — no core history in it at all, and the wrong
459
- // history if you squint. The control plane is the machine that builds the core, so it is
460
- // the machine that can answer.
459
+ // IT READS THE NOTES THE CORE PACKAGE CARRIES, and a core checkout only as a fallback
460
+ // (task 1004218). "What changed in the core between 1.19.881 and 1.19.972" is a question
461
+ // about the CORE repository, and no tenant box will ever hold its history — every project
462
+ // is a checkout of its OWN repo with the core vendored in from npm. So the packer writes
463
+ // the summaries down once, from the full history CI packs with (release-notes.json, built
464
+ // by scripts/gds/release-notes.js), and any machine that can download a core version can
465
+ // read what it carries. The checkout path below is kept for a machine that has one.
461
466
  const CHANGE_CAP = 20;
462
- const CHANGE_TEXT_MAX = 400;
467
+ // One registry download of a core tarball. The preview's owner is waiting on the dry run
468
+ // beside it anyway, but a hung registry must not hold the runner forever.
469
+ const NOTES_FETCH_TIMEOUT_MS = 60000;
470
+
471
+ function runFault(r) {
472
+ if (!r) return 'no answer';
473
+ if (r.error) return String(r.error.message || r.error).slice(0, 160);
474
+ const tail = String(r.stderr || '').trim().split('\n').filter(Boolean).slice(-1)[0] || '';
475
+ return (tail || `exit ${r.status}`).slice(0, 160);
476
+ }
463
477
 
464
478
  /**
465
- * The merge summaries in a `git log --merges --format=%s%x1f%b%x1e` dump. PURE.
479
+ * The release notes the core THIS runner executes from carries, if any. Free to read, and
480
+ * enough on its own whenever the target is at or below this core's own version — a tenant
481
+ * catching up to where the platform already is.
482
+ */
483
+ function readInstalledNotes(deps = {}) {
484
+ const fsImpl = deps.fs || fs;
485
+ let raw;
486
+ try { raw = fsImpl.readFileSync(path.join(REPO_ROOT, RELEASE_NOTES_FILE), 'utf8'); }
487
+ catch { return { missing: 'this core carries no release notes' }; }
488
+ try { return { notes: JSON.parse(raw) }; }
489
+ catch { return { error: "this core's release notes are not valid JSON" }; }
490
+ }
491
+
492
+ /**
493
+ * The release notes inside ONE published core package — the TARGET's own (task 1004218).
466
494
  *
467
- * TWO MERGE SHAPES CARRY A SUMMARY AND THE SECOND IS THE COMMON ONE. A hand-landed ship
468
- * writes `Merge <branch>: <summary>`; the CI auto-merge writes `Merge pull request #N
469
- * from <owner>/<branch>` with `task <id>: <summary>` in the BODY. Measured over
470
- * 1.19.881→1.19.972: 114 usable summaries, of which 98 were the second shape. A parser
471
- * that read subjects only would have found eleven of them and quietly called that the
472
- * release.
495
+ * WHY THE TARGET'S. The installed core's notes stop at its own version, which is zero
496
+ * coverage for the move looked at most — the platform's own, onto a release newer than
497
+ * itself. The target's package is the one guaranteed to reach the target, because that is
498
+ * where the target was packed. `npm pack` downloads it (the core is public, so no
499
+ * credential — the same registry listAvailableVersions reads) and `tar -xzOf` reads the one
500
+ * file out of it rather than unpacking the rest.
473
501
  *
474
- * EVERYTHING ELSE IS DROPPED, and the drops are the majority: `Merge origin/main into
475
- * <branch> (pre-stage freshness…)`, `Merge remote-tracking branch…`, a regen chore. Those
476
- * are the mechanics of landing work, not the work, and listing them would bury the
477
- * eleven sentences the owner actually wants under a hundred they do not.
502
+ * Returns `{ notes }`, `{ missing }` when the package carries none (every release published
503
+ * before the notes were packaged), or `{ error }`. The download never outlives the call.
478
504
  */
479
- function parseChangeRecords(stdout) {
480
- const out = [];
481
- const seen = new Set();
482
- for (const rec of String(stdout || '').split('\x1e')) {
483
- const r = rec.trim();
484
- if (!r) continue;
485
- const i = r.indexOf('\x1f');
486
- const subject = (i < 0 ? r : r.slice(0, i)).trim();
487
- const body = i < 0 ? '' : r.slice(i + 1);
488
- let text = null;
489
- let id = null;
490
- // The negative lookahead is what keeps the two housekeeping shapes out: both begin
491
- // "Merge " and both would otherwise match a `<token>:` somewhere later in the line.
492
- let m = /^Merge\s+(?!pull request\b|remote-tracking\b)([^\s:]+):\s*(\S.*)$/.exec(subject);
493
- if (m) {
494
- text = m[2];
495
- } else {
496
- const first = body.split('\n').map((l) => l.trim()).find(Boolean) || '';
497
- m = /^task\s+(\d+):\s*(\S.*)$/.exec(first);
498
- if (m) { id = m[1]; text = m[2]; }
505
+ function readPackagedNotes(version, deps = {}) {
506
+ if (!CORE_VERSION_RE.test(String(version || ''))) return { error: 'could not work out which package to read' };
507
+ const run = deps.spawn || spawnSync;
508
+ const fsImpl = deps.fs || fs;
509
+ let dir = null;
510
+ try {
511
+ dir = fsImpl.mkdtempSync(path.join(deps.tmpDir || os.tmpdir(), 'bongos-release-notes-'));
512
+ const packed = run('npm', ['pack', `@bongos/core@${version}`, '--pack-destination', dir, '--silent'],
513
+ { cwd: dir, env: deps.env || process.env, encoding: 'utf8', timeout: NOTES_FETCH_TIMEOUT_MS });
514
+ if (!packed || packed.error || packed.status !== 0) {
515
+ return { error: `could not download the core ${version} package to read its release notes (${runFault(packed)})` };
516
+ }
517
+ // Whatever npm named it: a scoped package's tarball is <scope>-<name>-<version>.tgz today.
518
+ const tgz = fsImpl.readdirSync(dir).find((f) => f.endsWith('.tgz'));
519
+ if (!tgz) return { error: `npm reported the core ${version} package downloaded, but left no tarball` };
520
+ const x = run('tar', ['-xzOf', path.join(dir, tgz), `package/${RELEASE_NOTES_FILE}`],
521
+ { encoding: 'utf8', maxBuffer: 4 * RELEASE_NOTES_MAX_BYTES, timeout: NOTES_FETCH_TIMEOUT_MS });
522
+ if (!x || x.error || x.status !== 0) {
523
+ if (/not found in archive/i.test(String((x && x.stderr) || ''))) {
524
+ return { missing: `the core ${version} package carries no release notes` };
525
+ }
526
+ return { error: `could not read the release notes out of the core ${version} package (${runFault(x)})` };
499
527
  }
500
- if (!text) continue;
501
- // It reaches a DOM, and a ship summary is occasionally a paragraph.
502
- text = text.length > CHANGE_TEXT_MAX ? `${text.slice(0, CHANGE_TEXT_MAX - 1)}…` : text;
503
- if (seen.has(text)) continue; // a branch merged twice says its sentence once
504
- seen.add(text);
505
- out.push(id ? { id, text } : { text });
528
+ try { return { notes: JSON.parse(x.stdout) }; }
529
+ catch { return { error: `the release notes in the core ${version} package are not valid JSON` }; }
530
+ } catch (e) {
531
+ return { error: `could not fetch the core ${version} package (${String((e && e.message) || e).slice(0, 160)})` };
532
+ } finally {
533
+ if (dir) { try { fsImpl.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ } }
506
534
  }
507
- return out;
508
535
  }
509
536
 
510
537
  /**
511
- * What landed between two core releases, as the summaries their ships wrote.
512
- *
513
- * Returns `{ from, to, entries, more }`, or `{ from, to, error }` — NEVER an empty
514
- * `entries` for a range it could not resolve. An empty list reads as "nothing changed",
515
- * which on a page whose job is to say what a restart buys is the one wrong impression it
516
- * must not give, and it is exactly what a missing checkout or a shallow clone produces.
517
- *
518
- * Every version reaching a shell is re-checked against the strict three-number form
519
- * FIRST. They arrive from an intent row whose column has the same CHECK, but a value that
520
- * is interpolated into a command is checked where it is interpolated — the rule
521
- * upgradeInvocation states about the slug.
538
+ * The range, answered from release notes alone: `{ entries }`, or `{ why }` they cannot.
539
+ * The installed core first, when its notes already reach the target; the target's own
540
+ * package otherwise. No git, no SHAs, no checkout.
522
541
  */
542
+ function changesFromNotes(fromVersion, toVersion, deps) {
543
+ const installed = (deps.readInstalledNotes || readInstalledNotes)(deps);
544
+ if (installed.notes) {
545
+ const r = notesBetween(installed.notes, fromVersion, toVersion);
546
+ if (r.entries) return r;
547
+ }
548
+ const target = (deps.readPackagedNotes || readPackagedNotes)(toVersion, deps);
549
+ if (!target.notes) return { why: target.missing || target.error || `the core ${toVersion} package's release notes could not be read` };
550
+ const r = notesBetween(target.notes, fromVersion, toVersion);
551
+ return r.entries ? r : { why: `the release notes in the core ${toVersion} package ${r.uncovered}` };
552
+ }
553
+
523
554
  /**
524
555
  * Classify a git command that did not succeed. PURE.
525
556
  *
@@ -598,21 +629,11 @@ function findCoreCheckout(deps, git) {
598
629
  }
599
630
 
600
631
  /**
601
- * What landed between two core releases, as the summaries their ships wrote.
602
- *
603
- * Returns `{ from, to, entries, more }`, or `{ from, to, error }` — NEVER an empty
604
- * `entries` for a range it could not resolve. An empty list reads as "nothing changed",
605
- * which on a page whose job is to say what a restart buys is the one wrong impression it
606
- * must not give, and it is exactly what a missing checkout produces.
607
- *
608
- * Every version reaching a shell is re-checked against the strict three-number form
609
- * FIRST. They arrive from an intent row whose column has the same CHECK, but a value that
610
- * is interpolated into a command is checked where it is interpolated — the rule
611
- * upgradeInvocation states about the slug.
632
+ * The range, answered from a core CHECKOUT's git history: `{ entries }` or `{ error }`.
633
+ * The fallback for a machine that has one, since the package's notes only exist for
634
+ * releases published after they were introduced (task 1004218).
612
635
  */
613
- function changesBetween(fromVersion, toVersion, deps) {
614
- const bad = [fromVersion, toVersion].find((v) => !CORE_VERSION_RE.test(String(v || '')));
615
- if (bad !== undefined) return { from: fromVersion || null, to: toVersion || null, error: 'could not work out which releases this covers' };
636
+ function changesFromCheckout(fromVersion, toVersion, deps) {
616
637
  // The CONTROL PLANE's executor, never the previewed instance's: under
617
638
  // PROVISION_REMOTE_HOST `exec` is the remote box, while the core history — and the whole
618
639
  // question of what changed in the CORE — belongs to the machine that builds it.
@@ -635,7 +656,7 @@ function changesBetween(fromVersion, toVersion, deps) {
635
656
  };
636
657
  };
637
658
  const found = findCoreCheckout(deps, git);
638
- if (found.error) return { from: fromVersion, to: toVersion, error: found.error };
659
+ if (found.error) return { error: found.error };
639
660
  const dir = found.dir;
640
661
  const shaOf = (v) => {
641
662
  const r = git(dir, `log --format=%H --max-count=1 -F --grep='release: core ${v} '`);
@@ -645,18 +666,48 @@ function changesBetween(fromVersion, toVersion, deps) {
645
666
  };
646
667
  const from = shaOf(fromVersion);
647
668
  const to = shaOf(toVersion);
648
- if (from.fault || to.fault) return { from: fromVersion, to: toVersion, error: from.fault || to.fault };
669
+ if (from.fault || to.fault) return { error: from.fault || to.fault };
649
670
  // LOOKED, AND DID NOT FIND — a different sentence from "could not look", and it names
650
671
  // WHICH version is missing so the reader can tell a not-yet-published target from a
651
672
  // checkout that has not been fetched.
652
673
  const missing = [!from.sha && fromVersion, !to.sha && toVersion].filter(Boolean);
653
674
  if (missing.length) {
654
- return { from: fromVersion, to: toVersion, error: `core ${missing.join(' and ')} ${missing.length > 1 ? 'are' : 'is'} not in the core history on this machine yet` };
675
+ return { error: `core ${missing.join(' and ')} ${missing.length > 1 ? 'are' : 'is'} not in the core history on this machine yet` };
655
676
  }
656
677
  const r = git(dir, `log --merges --format=%s%x1f%b%x1e ${from.sha}..${to.sha}`);
657
- if (!r.ok) return { from: fromVersion, to: toVersion, error: gitFault(r, dir) };
658
- const all = parseChangeRecords(r.stdout);
659
- return { from: fromVersion, to: toVersion, entries: all.slice(0, CHANGE_CAP), more: Math.max(0, all.length - CHANGE_CAP) };
678
+ if (!r.ok) return { error: gitFault(r, dir) };
679
+ return { entries: parseChangeRecords(r.stdout) };
680
+ }
681
+
682
+ /**
683
+ * What landed between two core releases, as the summaries their ships wrote.
684
+ *
685
+ * Returns `{ from, to, entries, more, source }`, or `{ from, to, error }` — NEVER an empty
686
+ * `entries` for a range it could not resolve. An empty list reads as "nothing changed",
687
+ * which on a page whose job is to say what a restart buys is the one wrong impression it
688
+ * must not give, and it is exactly what a missing checkout produces.
689
+ *
690
+ * THE PACKAGE'S NOTES FIRST, THE CHECKOUT SECOND (task 1004218). The notes answer on every
691
+ * box; the checkout only where one exists, which is never a tenant. When neither can, the
692
+ * error carries BOTH reasons — the notes' and the checkout's own precise sentence (task
693
+ * 1004212) — so the reader is told everything that was tried and why each fell short.
694
+ *
695
+ * Every version reaching a shell is re-checked against the strict three-number form
696
+ * FIRST. They arrive from an intent row whose column has the same CHECK, but a value that
697
+ * is interpolated into a command is checked where it is interpolated — the rule
698
+ * upgradeInvocation states about the slug.
699
+ */
700
+ function changesBetween(fromVersion, toVersion, deps) {
701
+ const bad = [fromVersion, toVersion].find((v) => !CORE_VERSION_RE.test(String(v || '')));
702
+ if (bad !== undefined) return { from: fromVersion || null, to: toVersion || null, error: 'could not work out which releases this covers' };
703
+ const listed = (all, source) => ({
704
+ from: fromVersion, to: toVersion, entries: all.slice(0, CHANGE_CAP), more: Math.max(0, all.length - CHANGE_CAP), source,
705
+ });
706
+ const notes = changesFromNotes(fromVersion, toVersion, deps);
707
+ if (notes.entries) return listed(notes.entries, 'release-notes');
708
+ const checkout = changesFromCheckout(fromVersion, toVersion, deps);
709
+ if (checkout.entries) return listed(checkout.entries, 'checkout');
710
+ return { from: fromVersion, to: toVersion, error: `${notes.why}; ${checkout.error}` };
660
711
  }
661
712
 
662
713
  // Run a dry run for one instance and cache its verdict — the ON-DEMAND leg, drained from
@@ -780,4 +831,4 @@ async function snapshotAllCoreVersions(deps) {
780
831
  return { seen };
781
832
  }
782
833
 
783
- module.exports = { UPGRADE_SHAPES, CHANGE_CAP, parseChangeRecords, changesBetween, coreCheckoutCandidates, findCoreCheckout, gitFault, resolveRunAs, versionCmd, parseServedVersion, parseSchemaPending, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
834
+ module.exports = { UPGRADE_SHAPES, CHANGE_CAP, parseChangeRecords, readInstalledNotes, readPackagedNotes, changesBetween, coreCheckoutCandidates, findCoreCheckout, gitFault, resolveRunAs, versionCmd, parseServedVersion, parseSchemaPending, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
@@ -5,7 +5,7 @@
5
5
  // The patrol is the third enforcement layer (prevent → gate → PATROL): checks
6
6
  // too slow, too networked, or too history-wide for the merge gate, run weekly
7
7
  // by the .claude/scheduled-tasks/redteam-weekly routine. It is ADVISORY by
8
- // doctrine (ADR 0162 — verdicts never self-execute): findings are FILED as
8
+ // doctrine (ADR 0158 §2 — a verdict never self-executes): findings are FILED as
9
9
  // security reports for Archon adjudication, never turned into gates here.
10
10
  //
11
11
  // Checks (all deterministic — no model reads anything):
@@ -40,7 +40,7 @@
40
40
  // log. Instead --file preflights the session and, when there is none, follows the
41
41
  // main-audit.js --require-roster doctrine: report LOUDLY rather than green-and-
42
42
  // vacuous. The exit code says whether a finding was LOST, not whether findings
43
- // exist — findings are advisory (ADR 0162) and a patrol that goes red merely for
43
+ // exist — findings are advisory (ADR 0158 §2) and a patrol that goes red merely for
44
44
  // doing its job trains everyone to ignore it:
45
45
  //
46
46
  // ran + filed → 0
@@ -271,7 +271,7 @@ function buildClassReport(className, findings, notes = []) {
271
271
  target: `redteam-patrol/${className}`,
272
272
  severity: maxSeverity(findings),
273
273
  title: `[patrol] ${className}: ${findings.length} finding(s)`,
274
- description: `Weekly deterministic red-team patrol (task 1003128; advisory per ADR 0162 — adjudicate, then fix via ordinary tasks).`,
274
+ description: `Weekly deterministic red-team patrol (task 1003128; advisory per ADR 0158 §2 — adjudicate, then fix via ordinary tasks).`,
275
275
  repro_steps_md: body,
276
276
  };
277
277
  }
@@ -289,7 +289,7 @@ function stepSummary(md) {
289
289
  // that disposition deserves. Extracted so the honest-exit contract is a table a
290
290
  // test can enumerate rather than branching buried in main().
291
291
  //
292
- // The load-bearing row is the last one. Findings are ADVISORY (ADR 0162), so a
292
+ // The load-bearing row is the last one. Findings are ADVISORY (ADR 0158 §2), so a
293
293
  // run that finds three things and files them is a SUCCESS — going red merely for
294
294
  // doing its job is how a weekly lane becomes noise everyone filters. Red is
295
295
  // reserved for the single state where evidence EVAPORATES: findings exist,