@bongos/core 1.21.4 → 1.21.6

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 (126) hide show
  1. package/.bongos-core.json +233 -108
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +3 -1
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +1 -0
  8. package/docs/adr/0362-the-governor-docket-is-collected-from-contributed-sources.md +34 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +37 -2
  11. package/docs/api-reference.md +4 -3
  12. package/docs/copy-inventory.md +541 -658
  13. package/docs/copy-registry.json +1205 -2340
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/onboarding/diagrams/03-drachmae-karma.mmd +1 -1
  16. package/docs/onboarding/diagrams/assertions.json +1 -1
  17. package/docs/page-inventory.json +47 -28
  18. package/docs/page-readings.json +1128 -1339
  19. package/modules/autonomy/config-idle.js +192 -0
  20. package/modules/autonomy/governor-docket.js +101 -0
  21. package/modules/autonomy/routes/autonomy.js +27 -0
  22. package/modules/autonomy/runner-health.js +22 -1
  23. package/modules/discord/board-broadcast.js +3 -1
  24. package/modules/discord/craft-broadcast.js +107 -0
  25. package/modules/discord/routes/discord.js +10 -0
  26. package/modules/discord/ship-broadcast.js +30 -2
  27. package/modules/economy/reward.js +3 -0
  28. package/modules/government/board.js +20 -5
  29. package/modules/government/docket-vocab.js +82 -0
  30. package/modules/government/docket.js +458 -0
  31. package/modules/government/routes/government.js +34 -0
  32. package/modules/hall-ui/public/city-draw.js +438 -0
  33. package/modules/hall-ui/public/city.css +110 -0
  34. package/modules/hall-ui/public/city.html +71 -0
  35. package/modules/hall-ui/public/city.js +177 -0
  36. package/modules/hall-ui/public/city.states.json +30 -0
  37. package/modules/hall-ui/public/gate.js +11 -4
  38. package/modules/hall-ui/public/genesis-home.js +9 -10
  39. package/modules/hall-ui/public/profile.css +4 -0
  40. package/modules/hall-ui/public/profile.js +25 -1
  41. package/modules/hall-ui/public/settings-autobongos.js +19 -1
  42. package/modules/hall-ui/public/settings.css +26 -0
  43. package/modules/hall-ui/public/settings.html +8 -0
  44. package/modules/hall-ui/public/settings.js +43 -3
  45. package/modules/ideas/governor-docket.js +93 -0
  46. package/modules/ideas/routes/blockers.js +5 -0
  47. package/modules/lifecycle/governor-docket.js +192 -0
  48. package/modules/lifecycle/routes/gate-approvals.js +78 -33
  49. package/modules/lifecycle/routes/lifecycle.js +3 -0
  50. package/modules/onboarding/routes/access-requests.js +68 -27
  51. package/modules/platform-identity/craft-rollup.js +72 -0
  52. package/modules/platform-identity/migrations/platform_identity_029_activity_crafts.sql +23 -0
  53. package/modules/platform-identity/platform-identity.js +12 -6
  54. package/modules/platform-identity/routes/sso.js +4 -0
  55. package/modules/platform-identity/tests/platform-identity.mjs +1 -1
  56. package/modules/provisioning/core-upgrade.js +34 -7
  57. package/modules/provisioning/module.json +2 -1
  58. package/modules/provisioning/seams.js +2 -0
  59. package/modules/public-landing/public/assets/cosmos.css +6 -50
  60. package/modules/public-landing/public/projects.html +1170 -1659
  61. package/modules/public-landing/public/projects.probes.json +18 -18
  62. package/modules/public-landing/public/projects.states.json +24 -31
  63. package/modules/security/routes/reports.js +39 -0
  64. package/package-lock.json +2 -2
  65. package/package.json +1 -1
  66. package/release-notes.json +72 -0
  67. package/scripts/gds/autobongos-grade-cap.js +187 -0
  68. package/scripts/gds/autobongos-loop.js +4 -0
  69. package/scripts/gds/autobongos-run.js +154 -13
  70. package/scripts/gds/autobongos-verify.js +211 -14
  71. package/scripts/gds/provision-core-upgrade.js +14 -2
  72. package/scripts/gds/provision-repo.js +77 -13
  73. package/scripts/gds/ship-flow.js +5 -0
  74. package/scripts/gds/ship-preflight-steps.js +2 -1
  75. package/scripts/gds/ship.js +10 -0
  76. package/scripts/gds/update-sweep.js +24 -8
  77. package/scripts/gds/upgrade-outcome.js +1 -0
  78. package/src/bongos/auth-admission.js +95 -13
  79. package/src/bongos/db-kernel.js +1 -1
  80. package/src/bongos/module-rechecks.js +77 -0
  81. package/src/bongos/route-rank-check.js +3 -0
  82. package/src/bongos/routes/core-update.js +68 -2
  83. package/src/bongos/routes/modules.js +17 -0
  84. package/src/bongos/serve-internal.js +14 -0
  85. package/src/bongos/software-update.js +3 -2
  86. package/src/module-api.js +1 -1
  87. package/tests/activity_rollup_order_db.mjs +3 -1
  88. package/tests/activity_snapshots_db.mjs +2 -0
  89. package/tests/autobongos_grade_cap.mjs +125 -0
  90. package/tests/autobongos_loop.mjs +230 -1
  91. package/tests/autobongos_verify.mjs +239 -3
  92. package/tests/autonomy_config_idle.mjs +272 -0
  93. package/tests/core_update_banner.mjs +29 -0
  94. package/tests/core_upgrade_door.mjs +31 -1
  95. package/tests/core_upgrade_runner.mjs +20 -0
  96. package/tests/discord_craft_broadcast.mjs +162 -0
  97. package/tests/genesis_home.mjs +1 -1
  98. package/tests/government_routes.mjs +3 -1
  99. package/tests/governor_docket.mjs +552 -0
  100. package/tests/hall_audit.mjs +10 -1
  101. package/tests/hall_city.mjs +369 -0
  102. package/tests/hall_page_gate_map.mjs +5 -0
  103. package/tests/hub_craft_rollup.mjs +350 -0
  104. package/tests/onboard.mjs +10 -4
  105. package/tests/platform_boot.mjs +1 -1
  106. package/tests/profile_rollup_consent.mjs +2 -0
  107. package/tests/profile_route.mjs +16 -2
  108. package/tests/projects_hub.mjs +196 -89
  109. package/tests/projects_hub_app_status.mjs +3 -1
  110. package/tests/projects_hub_app_step.mjs +72 -48
  111. package/tests/projects_hub_dns_ready.mjs +10 -2
  112. package/tests/projects_hub_look.mjs +32 -22
  113. package/tests/projects_hub_module_picker.mjs +112 -62
  114. package/tests/projects_hub_pre_uat.mjs +48 -41
  115. package/tests/provision.mjs +78 -0
  116. package/tests/public_landing_projects.mjs +12 -6
  117. package/tests/settings_main_role.mjs +190 -0
  118. package/tests/software_update.mjs +56 -1
  119. package/tests/update_subscription_engine.mjs +55 -3
  120. package/tests/wizard_demo.mjs +37 -11
  121. package/tests/wizard_draft_resume.mjs +34 -22
  122. package/tests/wizard_front_door.mjs +173 -103
  123. package/tests/wizard_intent_resume.mjs +28 -25
  124. package/tests/wizard_physics.mjs +94 -76
  125. package/tests/wizard_physics_more.mjs +428 -277
  126. package/tests/wizard_six_screens.mjs +126 -0
@@ -72,6 +72,33 @@ const REPO_ROOT = path.resolve(__dirname, '..', '..');
72
72
  // fail loudly here rather than silently reclassify a held claim as untouched.
73
73
  const HELD_STATUSES = new Set(['active', 'claimed', 'in_progress']);
74
74
 
75
+ // LAND_GRACE_MS — how long a SHIPPED task may have nothing on main before that
76
+ // is an owner's problem (task 1004538).
77
+ //
78
+ // The one place this file deliberately waits. Everywhere else the session that
79
+ // was meant to finish the job has exited, so waiting cannot change the verdict.
80
+ // A shipped task is different: the ship is done, and what is outstanding is the
81
+ // LAND, which runs on the server's own clock and can legitimately take hours on a
82
+ // red main. Task 1004513 was flagged at 22:24Z and its merge landed ~11 hours
83
+ // later; the owner got a false alarm (blocker 1000185). A day covers that with
84
+ // room to spare, and a genuinely codeless ship is already credited, so a day's
85
+ // delay in saying so costs nothing. AUTOBONGOS_LAND_GRACE_HOURS overrides it.
86
+ const LAND_GRACE_MS = (() => {
87
+ const h = Number(process.env.AUTOBONGOS_LAND_GRACE_HOURS);
88
+ return Number.isFinite(h) && h > 0 ? h * 3600_000 : 24 * 3600_000;
89
+ })();
90
+
91
+ // When the ship happened, from the LEDGER — never from this process, so a runner
92
+ // restart cannot reset the clock. `shipped_at` first: `updated_at` moves on any
93
+ // touch. Null when neither parses, which classifyLedger reads as "no grace".
94
+ function shippedAtMs(task) {
95
+ for (const k of ['shipped_at', 'updated_at']) {
96
+ const t = Date.parse(task && task[k]);
97
+ if (Number.isFinite(t)) return t;
98
+ }
99
+ return null;
100
+ }
101
+
75
102
  // ---------------------------------------------------------------------------
76
103
  // The pure half
77
104
  // ---------------------------------------------------------------------------
@@ -92,7 +119,7 @@ const HELD_STATUSES = new Set(['active', 'claimed', 'in_progress']);
92
119
  // modules/ideas/blockers.js is exactly ['ready']), which is why
93
120
  // the caller releases BEFORE it links.
94
121
  // halt — the run stops here rather than picking another task.
95
- function classifyLedger({ task, artifact = null, nowMs = Date.now() } = {}) {
122
+ function classifyLedger({ task, artifact = null, nowMs = Date.now(), landGraceMs = LAND_GRACE_MS } = {}) {
96
123
  const none = { releaseClaim: false, fileBlocker: false, linkBlocker: false, halt: false };
97
124
 
98
125
  if (!task) {
@@ -110,6 +137,19 @@ function classifyLedger({ task, artifact = null, nowMs = Date.now() } = {}) {
110
137
 
111
138
  if (status === 'shipped') {
112
139
  if (artifact && artifact.checked && !artifact.onMain) {
140
+ // Not yet is not never (task 1004538). Inside the grace window this is a
141
+ // land still in flight: nothing is filed, and the runner re-probes it on
142
+ // later iterations (recheckLands). With no readable clock the grace cannot
143
+ // be measured, so it falls through to the strict verdict below.
144
+ const shippedAt = shippedAtMs(task);
145
+ if (shippedAt !== null && nowMs - shippedAt < landGraceMs) {
146
+ const hours = Math.round(landGraceMs / 3600_000);
147
+ return {
148
+ ...none, verified: false, shape: 'pending_land', status, pendingLand: true,
149
+ reason: 'shipped, but main does not carry a commit naming it yet — the land can take hours on a red main',
150
+ remedy: `nothing yet: the runner re-checks main on later iterations and files a blocker only if it is still missing ${hours}h after the ship`,
151
+ };
152
+ }
113
153
  // The flip happened with nothing behind it — tasks 1002459 and 1002792,
114
154
  // both credited, neither carrying a line of code on any branch. The task is
115
155
  // NOT released and NOT linked: it is shipped, credits are paid, and parking
@@ -240,26 +280,50 @@ const runGit = (args, cwd) => new Promise((resolve) => {
240
280
  //
241
281
  // Deliberately weak and deliberately cheap. It answers "is there a trace", not
242
282
  // "which commit is it": a body mention counts, because the question being asked
243
- // is the 1002459 question — was anything at all produced. The fixed-string grep
244
- // is against `(task N)`, the convention every ship commit follows.
283
+ // is the 1002459 question — was anything at all produced.
284
+ //
285
+ // WHAT COUNTS AS NAMING THE TASK (task 1004538). `(task N)` alone was too
286
+ // narrow: the commits PR 1456 landed for task 1004513 never wrote it — the merge
287
+ // subject names the ship BRANCH (`…/autobongos-1004513-978953`, or
288
+ // `…/task-1004440` for a hand ship). So the old probe said "no artifact" even
289
+ // after the land. Now git narrows by the bare id (fixed string, cheap) and
290
+ // namesTask() makes the exact call in JS, never matching the prefix of a longer
291
+ // id. The SUBJECT may name it as `task N` / `task-N` / `autobongos-N-`; the BODY
292
+ // only as `(task N)`, because bodies routinely DISCUSS other tasks ("task 1004513
293
+ // was flagged…" is in this very file's commit), and a mention is not an artifact.
245
294
  //
246
295
  // A probe that could not run returns `checked: false`, which classifyLedger
247
296
  // treats as no evidence either way.
248
- async function probeArtifact(taskId, { git = runGit, cwd = REPO_ROOT } = {}) {
297
+ function namesTask(subject, body, id) {
298
+ const inSubject = new RegExp(`(?:\\btask[\\s-]+|\\bautobongos-)${id}(?!\\d)`, 'i');
299
+ const inBody = new RegExp(`\\(task ${id}\\)`, 'i');
300
+ return inSubject.test(String(subject || '')) || inBody.test(String(body || ''));
301
+ }
302
+
303
+ // `fetch: false` skips the fetch and reads the FETCH_HEAD a caller has just
304
+ // fetched itself — recheckLands fetches once per pass, not once per task.
305
+ async function probeArtifact(taskId, { git = runGit, cwd = REPO_ROOT, fetch = true } = {}) {
249
306
  const id = String(taskId);
250
307
  if (!/^\d{1,19}$/.test(id)) return { checked: false, reason: 'task id is not a plain number' };
251
308
 
252
- const fetched = await git(['fetch', '-q', 'origin', 'main'], cwd);
253
- if (!fetched.ok) {
254
- return { checked: false, reason: `git fetch failed: ${(fetched.stderr || '').trim().slice(-200)}` };
309
+ if (fetch) {
310
+ const fetched = await git(['fetch', '-q', 'origin', 'main'], cwd);
311
+ if (!fetched.ok) {
312
+ return { checked: false, reason: `git fetch failed: ${(fetched.stderr || '').trim().slice(-200)}` };
313
+ }
255
314
  }
256
- const log = await git(['log', 'FETCH_HEAD', '--max-count=5', '--format=%H %s', '-F', `--grep=(task ${id})`], cwd);
315
+ // %x1f between fields, %x1e after each commit: a body may hold any text.
316
+ const log = await git(['log', 'FETCH_HEAD', '--max-count=50', '--format=%H%x1f%s%x1f%b%x1e', '-F', `--grep=${id}`], cwd);
257
317
  if (!log.ok) return { checked: false, reason: `git log failed: ${(log.stderr || '').trim().slice(-200)}` };
258
318
 
259
- const first = log.stdout.split('\n').find((l) => l.trim());
260
- if (!first) return { checked: true, onMain: false, sha: null, subject: null };
261
- const sp = first.indexOf(' ');
262
- return { checked: true, onMain: true, sha: first.slice(0, sp < 0 ? first.length : sp), subject: sp < 0 ? null : first.slice(sp + 1) };
319
+ for (const rec of log.stdout.split('\x1e')) {
320
+ const [sha, subject, body] = rec.replace(/^\s+/, '').split('\x1f');
321
+ if (!sha || !sha.trim()) continue;
322
+ if (namesTask(subject, body, id)) {
323
+ return { checked: true, onMain: true, sha: sha.trim(), subject: subject || null };
324
+ }
325
+ }
326
+ return { checked: true, onMain: false, sha: null, subject: null };
263
327
  }
264
328
 
265
329
  // fetchTask returns the row or null, and says WHY on a null. The reason is the
@@ -330,7 +394,7 @@ async function verifyShip({ taskId, taskTitle = null, worker = null }, deps = {}
330
394
  artifact = await probe(taskId, { cwd: deps.cwd || REPO_ROOT });
331
395
  }
332
396
 
333
- const v = classifyLedger({ task, artifact, nowMs: deps.nowMs || Date.now() });
397
+ const v = classifyLedger({ task, artifact, nowMs: deps.nowMs || Date.now(), ...(deps.landGraceMs ? { landGraceMs: deps.landGraceMs } : {}) });
334
398
  const out = { ...v, task_id: String(taskId), artifact, released: false, blocker: null };
335
399
  if (readError) out.read_error = readError;
336
400
  if (!act) return out;
@@ -360,6 +424,136 @@ async function verifyShip({ taskId, taskTitle = null, worker = null }, deps = {}
360
424
  return out;
361
425
  }
362
426
 
427
+ // ---------------------------------------------------------------------------
428
+ // Pending lands, across iterations and restarts (task 1004538)
429
+ // ---------------------------------------------------------------------------
430
+
431
+ // pendingLandIds — which tasks are still waiting on a land, from the run log.
432
+ //
433
+ // The run log is append-only JSONL on disk, so this survives a restart; nothing
434
+ // lives only in memory. The LATEST verdict per task wins: a `worked` or
435
+ // `land_recheck` row whose ledger_shape is `pending_land` keeps it on the list,
436
+ // anything later (shipped, shipped_no_artifact, …) takes it off. The grace clock
437
+ // itself is not here — it is the ledger's `shipped_at`, re-read on every check.
438
+ function pendingLandIds(rows) {
439
+ const latest = new Map();
440
+ for (const r of rows || []) {
441
+ if (!r || typeof r !== 'object' || !r.task_id || !r.ledger_shape) continue;
442
+ if (r.event !== 'worked' && r.event !== 'land_recheck') continue;
443
+ latest.set(String(r.task_id), r.ledger_shape);
444
+ }
445
+ return Array.from(latest).filter(([, shape]) => shape === 'pending_land').map(([id]) => id);
446
+ }
447
+
448
+ const NO_ARTIFACT_REF = /^task-(\d{1,19})-shipped_no_artifact$/;
449
+
450
+ // openNoArtifactBlockers — every open blocker this file filed as
451
+ // `shipped_no_artifact`, read from the DATABASE (so a blocker filed by a
452
+ // previous process, or before this code existed, is still found).
453
+ //
454
+ // GET /blockers lists OPEN blockers only (listOpenBlockers) — the owner's
455
+ // to-do list, normally tens of rows — so one or two pages is the real cost; the
456
+ // route has no source filter, hence the client-side match. Paged by the route's
457
+ // own `page.total`. MAX_PAGES is a runaway guard, and hitting it is reported, not
458
+ // silently treated as "looked everywhere".
459
+ const BLOCKER_PAGE = 200;
460
+ const MAX_BLOCKER_PAGES = 25;
461
+ async function openNoArtifactBlockers(api) {
462
+ const out = [];
463
+ for (let page = 0; ; page++) {
464
+ if (page >= MAX_BLOCKER_PAGES) {
465
+ throw new Error(`more than ${MAX_BLOCKER_PAGES * BLOCKER_PAGE} open blockers — stopped paging; the ones past that were not checked`);
466
+ }
467
+ const offset = page * BLOCKER_PAGE;
468
+ const r = await api.blockers.getBlockers({ query: { limit: BLOCKER_PAGE, offset } });
469
+ if (!r || !r.ok) throw new Error(`GET /blockers → ${r ? r.status : 'no response'}`);
470
+ const rows = (r.data && r.data.blockers) || [];
471
+ for (const b of rows) {
472
+ const m = b && b.source === 'autobongos' && NO_ARTIFACT_REF.exec(String(b.source_ref || ''));
473
+ if (m && (!b.status || b.status === 'open')) out.push({ id: String(b.id), taskId: m[1] });
474
+ }
475
+ const total = r.data && r.data.page && Number(r.data.page.total);
476
+ if (!rows.length || (Number.isFinite(total) ? offset + rows.length >= total : rows.length < BLOCKER_PAGE)) break;
477
+ }
478
+ return out;
479
+ }
480
+
481
+ // recheckLands — one pass, run by the runner once per loop.
482
+ //
483
+ // 1. Every pending land is verified again. Inside the grace window it stays
484
+ // `pending_land` and nothing is filed; past it, verifyShip files the
485
+ // `shipped_no_artifact` blocker exactly as before. Shipped tasks are never
486
+ // released, so no release function is passed.
487
+ // 2. Every OPEN `shipped_no_artifact` blocker is re-probed, and resolved with
488
+ // a note once main carries the commit — the false alarm closes itself.
489
+ // Only a probe that actually ran and found the commit resolves anything.
490
+ //
491
+ // I/O per pass: at most ONE `git fetch` (only when there is something to probe),
492
+ // one `git log` per distinct task id, and the blocker list only when
493
+ // `sweepBlockers` is not false — the runner sweeps hourly, not every loop.
494
+ //
495
+ // Never throws: a pass that fails is reported in `errors` and retried next loop.
496
+ async function recheckLands({ pendingIds = [], sweepBlockers = true } = {}, deps = {}) {
497
+ const api = deps.api || await (require('./cli-lib').cliClient)();
498
+ const cwd = deps.cwd || REPO_ROOT;
499
+ const git = deps.git || runGit;
500
+ const out = { rechecked: [], resolved: [], errors: [] };
501
+
502
+ let open = [];
503
+ if (sweepBlockers) {
504
+ try { open = await openNoArtifactBlockers(api); }
505
+ catch (e) { out.errors.push(`blocker list: ${e && e.message ? e.message : String(e)}`); }
506
+ }
507
+
508
+ // One fetch for the whole pass; every probe then reads the same FETCH_HEAD.
509
+ // An injected probe owns its own I/O, so none is done for it.
510
+ let fetchFailed = null;
511
+ if (!deps.probeArtifact && (pendingIds.length || open.length)) {
512
+ const f = await git(['fetch', '-q', 'origin', 'main'], cwd);
513
+ if (!f.ok) fetchFailed = `git fetch failed: ${(f.stderr || '').trim().slice(-200)}`;
514
+ }
515
+ const rawProbe = deps.probeArtifact || ((id) => (fetchFailed
516
+ ? { checked: false, reason: fetchFailed }
517
+ : probeArtifact(id, { git, cwd, fetch: false })));
518
+ const memo = new Map();
519
+ const probe = (id) => {
520
+ if (!memo.has(String(id))) {
521
+ memo.set(String(id), (async () => rawProbe(id, { cwd }))()
522
+ .catch((e) => ({ checked: false, reason: e && e.message ? e.message : String(e) })));
523
+ }
524
+ return memo.get(String(id));
525
+ };
526
+
527
+ for (const id of pendingIds) {
528
+ try {
529
+ const v = await verifyShip({ taskId: id }, {
530
+ api, cwd, probeArtifact: (tid) => probe(tid),
531
+ ...(deps.fileBlocker ? { fileBlocker: deps.fileBlocker } : {}),
532
+ ...(deps.nowMs ? { nowMs: deps.nowMs } : {}),
533
+ ...(deps.landGraceMs ? { landGraceMs: deps.landGraceMs } : {}),
534
+ });
535
+ out.rechecked.push({
536
+ task_id: String(id), ledger_status: v.status, ledger_shape: v.shape, verified: !!v.verified,
537
+ ledger_reason: v.reason || null,
538
+ blocker_id: v.blocker && v.blocker.blockerId ? v.blocker.blockerId : null,
539
+ });
540
+ } catch (e) { out.errors.push(`task ${id}: ${e && e.message ? e.message : String(e)}`); }
541
+ }
542
+
543
+ for (const b of open) {
544
+ const a = await probe(b.taskId);
545
+ if (!a || !a.checked || !a.onMain) continue;
546
+ const sha = a.sha ? String(a.sha).slice(0, 9) : 'a commit';
547
+ const note = `Auto-resolved by autobongos-verify.js (task 1004538): main now carries ${sha}${a.subject ? ` ("${String(a.subject).slice(0, 120)}")` : ''}, which names task ${b.taskId}. The land was slow, not missing.`;
548
+ try {
549
+ const r = await api.blockers.postBlockersIdResolve({ id: Number(b.id), body: { resolution_note: note } });
550
+ if (r && r.ok) out.resolved.push({ blocker_id: b.id, task_id: b.taskId, sha });
551
+ else out.errors.push(`resolve blocker ${b.id} → ${r ? r.status : 'no response'}`);
552
+ } catch (e) { out.errors.push(`resolve blocker ${b.id}: ${e && e.message ? e.message : String(e)}`); }
553
+ }
554
+ return out;
555
+ }
556
+
363
557
  // ---------------------------------------------------------------------------
364
558
  // CLI
365
559
  // ---------------------------------------------------------------------------
@@ -399,4 +593,7 @@ if (require.main === module) {
399
593
  });
400
594
  }
401
595
 
402
- module.exports = { classifyLedger, blockerFor, probeArtifact, fileBlocker, verifyShip, failureReason };
596
+ module.exports = {
597
+ classifyLedger, blockerFor, probeArtifact, fileBlocker, verifyShip, failureReason,
598
+ LAND_GRACE_MS, namesTask, pendingLandIds, recheckLands,
599
+ };
@@ -117,7 +117,7 @@ function parseSchemaPending(stdout) {
117
117
  * @param {string} installed the version running now
118
118
  * @param {string} channel this instance's update channel
119
119
  */
120
- function targetFault(to, available, installed, channel, channelAllows) {
120
+ function targetFault(to, available, installed, channel, channelAllows, compare) {
121
121
  if (!Array.isArray(available) || available.length === 0) {
122
122
  return `could not read the published core versions from the registry — nothing was changed; the release may still be publishing (a tag is minted before the publish completes)`;
123
123
  }
@@ -136,6 +136,18 @@ function targetFault(to, available, installed, channel, channelAllows) {
136
136
  if (!installed) {
137
137
  return `could not read the core version installed at this instance — nothing was changed. An upgrade that cannot say where it is starting from cannot be bounded by the '${channel}' channel.`;
138
138
  }
139
+ // A TARGET THE BOX HAS ALREADY REACHED IS NOT A CHANNEL QUESTION (task 1004534).
140
+ // channelAllows answers "may an unattended jump go here" and says no to anything not
141
+ // strictly newer, so on 2026-10-01 a press for 1.20.73, landed a minute earlier by the
142
+ // sweep, was reported as "outside this project's 'minor' update channel … across a major
143
+ // version or onto a prerelease" — false on every count, and it put a "your call" card on
144
+ // /deploy for a project that was simply up to date. Already there: no fault, so the
145
+ // caller's already-serving no-op answers it. Already past it: say that, in words that
146
+ // classify as a downgrade, never as the update rule.
147
+ if (installed === to) return null;
148
+ if (compare && compare(to, installed) < 0) {
149
+ return `core ${to} is older than the core this project already runs (it already runs core ${installed}) — nothing was changed. A move never goes back to an older core on its own.`;
150
+ }
139
151
  if (channelAllows && !channelAllows(installed, to, channel)) {
140
152
  return `core ${to} is outside this project's '${channel}' update channel — nothing was changed. ${channelNextStep(channel)}`;
141
153
  }
@@ -276,7 +288,7 @@ async function coreUpgradeInstance(inst, deps, intent) {
276
288
  const channel = instanceChannel(inst, channelMod);
277
289
  const listed = offeredVersions(channelMod, inst, instanceDir);
278
290
  const available = (listed && listed.versions) || [];
279
- const fault = targetFault(to, available, installed, channel, channelMod.channelAllows);
291
+ const fault = targetFault(to, available, installed, channel, channelMod.channelAllows, channelMod.compareSemver);
280
292
  if (fault) return failed(outcome.runnerReason(fault), fault);
281
293
  if (installed && installed === to) {
282
294
  // `bongos upgrade` would no-op on this too, but saying so here keeps a re-drained
@@ -19,6 +19,7 @@ const { CONFIG, REPO_ROOT, coreVersionSafe, provisionerBotEmail, provisionerBotN
19
19
  // upgrade.js can reach these three without pulling provision*.js into the merge
20
20
  // resolver's executed closure; re-exported below so every existing caller is unchanged.
21
21
  const { instanceRepoRemote, parseTargetRef, resolveOwnerGithubToken } = require('./provision-repo-auth.js');
22
+ const { CORE_PKG, compareSemver, isStable } = require('./update-channel.js');
22
23
 
23
24
  function dbName(inst) { return inst.db_name || inst.slug; }
24
25
 
@@ -500,8 +501,49 @@ function coreCheckoutHasGit({ repoRoot = REPO_ROOT, fsImpl = fs } = {}) {
500
501
  return fsImpl.existsSync(path.join(repoRoot, '.git'));
501
502
  }
502
503
 
504
+ // The core version THIS control plane runs — what the ladder below would vend: the
505
+ // pinned tarball's version, else the registry-installed package's, else the checkout's
506
+ // CORE_VERSION. fs reads only → dry-run safe.
507
+ function runningCoreVersion({ instanceRoot, coreV }) {
508
+ const pinned = installedCoreTarball({ instanceRoot });
509
+ if (pinned) return require('./upgrade').versionFromTgzPath(path.basename(pinned)) || coreV;
510
+ const packSrc = installedCorePackDir({ instanceRoot });
511
+ return packSrc ? packSrc.version : coreV;
512
+ }
513
+
514
+ // Which core a hosted project is given (task 1004299, ADR 0361 D5). PURE. Since merges
515
+ // publish as candidates, the platform hall runs ahead of the registry's `latest` label by
516
+ // design — and the ladder below vends whatever the hall runs, so every new project would
517
+ // be born on a version nobody released. When the hall is AHEAD of the release, the project
518
+ // gets the release instead. Equal (or the hall behind it, which a release can't normally
519
+ // produce) → the running core, byte-exact, as before.
520
+ //
521
+ // No readable label → the running core, deliberately (fail-open). Until candidates are
522
+ // armed the running core IS the release; vendoring is the path that works with no
523
+ // registry at all (ADR 0292); and refusing project creation over a registry blip is worse
524
+ // for the person creating it than a newer-than-released core in the rare outage window.
525
+ function projectCoreDecision({ running, released }) {
526
+ if (!released || !isStable(released)) return { version: running, source: 'running', reason: 'no readable released (latest) label — using the core this hall runs' };
527
+ if (compareSemver(running, released) > 0) return { version: released, source: 'released', reason: `this hall runs ${running}, ahead of the release` };
528
+ return { version: running, source: 'running', reason: `this hall runs the released core ${released}` };
529
+ }
530
+
531
+ // The I/O half: read the `latest` label through the injected exec (so a dry run and the
532
+ // test harness's stub exec never touch the network), then decide. `npm view` is fine
533
+ // here — provisioning is a runner script, not a request handler (ADR 0293's objection).
534
+ function projectCoreTarget({ instanceRoot, coreV, exec, log, tag = '[core]' }) {
535
+ const running = runningCoreVersion({ instanceRoot, coreV });
536
+ const res = exec(`npm view ${CORE_PKG} dist-tags.latest`, { allowFail: true });
537
+ const label = String((res && res.ok !== false && res.stdout) || '').trim();
538
+ const decision = projectCoreDecision({ running, released: label || null });
539
+ log(` ${tag} ${decision.source === 'released' ? `giving the project the RELEASED core ${decision.version}` : `giving the project core ${decision.version}`} — ${decision.reason} (task 1004299)`);
540
+ return decision;
541
+ }
542
+
503
543
  // Resolve a vendorable @bongos/core tarball — the four-rung ladder, freshest-correct
504
- // first, shared by the fresh scaffold and the re-provision pin refresh (task 1003050):
544
+ // first, shared by the fresh scaffold and the re-provision pin refresh (task 1003050).
545
+ // A `target` from projectCoreTarget with source 'released' short-circuits it: the
546
+ // project gets the released version packed from the registry, not this hall's core.
505
547
  // 1. a `file:`-pinned control plane reuses its own pinned tarball (task 1002275 —
506
548
  // rebuilding via package-core.js aborts with an EMPTY core set when this runner
507
549
  // IS a consumer instance, leaving new repos empty);
@@ -516,7 +558,16 @@ function coreCheckoutHasGit({ repoRoot = REPO_ROOT, fsImpl = fs } = {}) {
516
558
  // rung 3's reproducible build is the better answer for the identical file set.
517
559
  // Returns { tgz, packDest } — packDest (set only on rung 2) is a temp dir the CALLER
518
560
  // must `rm -rf` once the tarball has been vendored.
519
- function resolveVendorableCoreTarball({ instanceRoot, repoRoot = REPO_ROOT, exec, log, coreV, tag = '[core]' }) {
561
+ function resolveVendorableCoreTarball({ instanceRoot, repoRoot = REPO_ROOT, exec, log, coreV, tag = '[core]', target = null }) {
562
+ if (target && target.source === 'released') {
563
+ // target.version passed isStable (digits and dots only), so it is shell-safe.
564
+ const v = target.version;
565
+ const packDest = path.join(os.tmpdir(), `bongos-core-released-${v}`);
566
+ log(` ${tag} packing the released core ${v} from the registry — not the candidate this hall runs (task 1004299)`);
567
+ exec(`mkdir -p ${packDest}`);
568
+ exec(`npm pack ${CORE_PKG}@${v} --pack-destination ${packDest}`);
569
+ return { tgz: path.join(packDest, `bongos-core-${v}.tgz`), packDest };
570
+ }
520
571
  const pinned = installedCoreTarball({ instanceRoot });
521
572
  if (pinned) {
522
573
  log(` ${tag} reusing the pinned core tarball this control plane runs (${path.basename(pinned)}) — no source rebuild (task 1002275)`);
@@ -629,9 +680,12 @@ async function scaffoldStandaloneRepo(inst, deps) {
629
680
  throw new Error('cannot resolve the core version (src/module-api CORE_VERSION) — run the scaffold from the control-plane checkout.');
630
681
  }
631
682
  const remote = instanceRepoRemote(repo);
683
+ // The released core, not this hall's candidate (task 1004299).
684
+ const target = projectCoreTarget({ instanceRoot: deps.instanceRoot, coreV, exec, log, tag: '[scaffold] core:' });
685
+ const pinV = target.version;
632
686
  log(mode === 'adopt'
633
- ? ` [scaffold] ${repo.owner}/${repo.name} → ${root} (ADOPT: clone → layer additively → pin core ${coreV} → commit → fast-forward push)`
634
- : ` [scaffold] ${repo.owner}/${repo.name} → ${root} (clone → materialize → pin core ${coreV} → commit → push)`);
687
+ ? ` [scaffold] ${repo.owner}/${repo.name} → ${root} (ADOPT: clone → layer additively → pin core ${pinV} → commit → fast-forward push)`
688
+ : ` [scaffold] ${repo.owner}/${repo.name} → ${root} (clone → materialize → pin core ${pinV} → commit → push)`);
635
689
 
636
690
  // Owner login = the founding firstAdmin. Best-effort; the spec falls back to the repo owner.
637
691
  const ownerLogin = await ownerLoginOf(inst, db);
@@ -743,7 +797,7 @@ async function scaffoldStandaloneRepo(inst, deps) {
743
797
  // Vendored tarball — resolveVendorableCoreTarball's four-rung ladder (shared
744
798
  // with the re-provision pin refresh, task 1003050).
745
799
  const { tgz, packDest } = resolveVendorableCoreTarball({
746
- instanceRoot: deps.instanceRoot, exec, log, coreV, tag: '[scaffold] core install:',
800
+ instanceRoot: deps.instanceRoot, exec, log, coreV, tag: '[scaffold] core install:', target,
747
801
  });
748
802
  exec(`node scripts/gds/init.js --from ${specPath} --dir ${root} --no-seed ${modeFlags} --vendor-core ${tgz}`);
749
803
  // init --vendor-core COPIED the tarball into the child's vendor/ — the temp pack dir is spent.
@@ -775,8 +829,8 @@ async function scaffoldStandaloneRepo(inst, deps) {
775
829
  exec(`git -C ${root} config user.email "${provisionerBotEmail()}"`);
776
830
  exec(`git -C ${root} add -A`);
777
831
  const commitMsg = mode === 'adopt'
778
- ? `chore: adopt Cloud Bongos onto ${inst.slug} on a pinned @bongos/core ${coreV} (provision.js, ADR 0121)`
779
- : `chore: scaffold ${inst.slug} on a pinned @bongos/core ${coreV} (provision.js, ADR 0108)`;
832
+ ? `chore: adopt Cloud Bongos onto ${inst.slug} on a pinned @bongos/core ${pinV} (provision.js, ADR 0121)`
833
+ : `chore: scaffold ${inst.slug} on a pinned @bongos/core ${pinV} (provision.js, ADR 0108)`;
780
834
  exec(`git -C ${root} commit -m "${commitMsg}"`, { allowFail: true });
781
835
 
782
836
  // Push. NO --force on either path, ever (see the function header): the ADR 0155 `repo`
@@ -1078,16 +1132,23 @@ async function refreshStandaloneCorePin(inst, deps) {
1078
1132
  let pkg = null;
1079
1133
  try { pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); } catch { /* unreadable → skip below */ }
1080
1134
  const pinnedDep = pkg && ((pkg.dependencies || {})['@bongos/core'] || (pkg.devDependencies || {})['@bongos/core']);
1081
- const plan = planCorePinRefresh({ pinnedDep, coreVersion: coreV });
1082
- if (plan.action === 'skip') { log(` [core-pin] ${plan.reason}`); return plan; }
1083
- if (plan.action === 'current') { log(` [core-pin] committed pin already matches the running core (${coreV})`); return plan; }
1084
- log(` [core-pin] committed pin ${safeVersionLabel(plan.pinned)} trails this control plane's core ${coreV} — refreshing so the next repo-driven deploy cannot roll the instance back (task 1003050)`);
1135
+ if (!/^file:/.test(String(pinnedDep || '').trim())) {
1136
+ const plan = planCorePinRefresh({ pinnedDep, coreVersion: coreV });
1137
+ log(` [core-pin] ${plan.reason}`); return plan;
1138
+ }
1139
+ // Heal toward the core a project is GIVEN — the release when this hall runs ahead of it —
1140
+ // or the pin a brand-new project was just born on is bumped straight back to the
1141
+ // candidate in the same provisioning run (task 1004299).
1142
+ const target = projectCoreTarget({ instanceRoot: deps.instanceRoot, coreV, exec, log, tag: '[core-pin]' });
1143
+ const plan = planCorePinRefresh({ pinnedDep, coreVersion: target.version });
1144
+ if (plan.action === 'current') { log(` [core-pin] committed pin already matches the core this project is given (${target.version})`); return plan; }
1145
+ log(` [core-pin] committed pin ${safeVersionLabel(plan.pinned)} differs from the core this project is given (${target.version}) — refreshing so the next repo-driven deploy cannot roll the instance back (task 1003050)`);
1085
1146
 
1086
1147
  const { tgz, packDest } = resolveVendorableCoreTarball({
1087
- instanceRoot: deps.instanceRoot, exec, log, coreV, tag: '[core-pin]',
1148
+ instanceRoot: deps.instanceRoot, exec, log, coreV, tag: '[core-pin]', target,
1088
1149
  });
1089
1150
  try {
1090
- const targetVersion = upgrade.versionFromTgzPath(path.basename(tgz)) || coreV;
1151
+ const targetVersion = upgrade.versionFromTgzPath(path.basename(tgz)) || target.version;
1091
1152
  const vendorRel = upgrade.vendorTarball({ instanceDir: root, fromTgz: tgz, targetVersion });
1092
1153
  // Prune superseded vendored tarballs (+ manifests) so the repo carries exactly ONE
1093
1154
  // core. fs.rmSync, NOT exec — a filename comes off the instance's own (lower-trust)
@@ -1176,6 +1237,9 @@ module.exports = {
1176
1237
  refreshStandaloneCorePin,
1177
1238
  resolveOwnerGithubToken,
1178
1239
  resolveVendorableCoreTarball,
1240
+ projectCoreDecision,
1241
+ projectCoreTarget,
1242
+ runningCoreVersion,
1179
1243
  safeVersionLabel,
1180
1244
  scaffoldStandaloneRepo,
1181
1245
  seedFirstVersionCmd,
@@ -31,6 +31,9 @@ const tweakHold = require('../../modules/lifecycle/page-tweak-hold.js');
31
31
  const { evaluateRegradeEligibility, evaluateResumeAction, resumeMerge, shouldConfirmWithoutGrade } = require('./ship-resume.js');
32
32
  const { fileNoArtifactEscalation, noArtifactRefusalLines } = require('./ship-escalation.js');
33
33
  const { noteServerReward } = require('./ship-state.js');
34
+ // task 1004536: each graded round lands in the autonomous run's ledger (inert
35
+ // without one), so ship.js can refuse a third grade at its entry point.
36
+ const { recordGradeOutcome } = require('./autobongos-grade-cap.js');
34
37
 
35
38
  // ---------- Main flow ----------
36
39
 
@@ -695,6 +698,7 @@ async function shipMain() {
695
698
  }
696
699
 
697
700
  const g = gradePost.data.grade;
701
+ recordGradeOutcome({ taskId, grade: g });
698
702
  gradeObj = g;
699
703
  gradePassed = g.passed;
700
704
  advanceToMerge = gradePost.data.advanceToMerge;
@@ -1101,6 +1105,7 @@ async function regradeMain() {
1101
1105
  }
1102
1106
 
1103
1107
  const g = gradePost.data.grade;
1108
+ recordGradeOutcome({ taskId, grade: g });
1104
1109
  const postGradeStatus = gradePost.data.taskStatus;
1105
1110
  printGradeOutcome(g, taskId);
1106
1111
  printCreditsAndAchievements({
@@ -82,7 +82,8 @@ const TOOLING_PATHSPECS = Object.freeze([
82
82
  'scripts/gds/ship*.js', // ship.js + every ship-*.js carved out of it
83
83
  'scripts/gds/smoke-gds.js',
84
84
  'scripts/gds/cli-lib.js',
85
- 'modules/grading/grader*', // grader.js, grader-*.js, grader-rubric.json, grader-workers/
85
+ 'scripts/gds/autobongos-grade-cap.js', // ship.js's entry refuses a capped run with it (task 1004536)
86
+ 'modules/grading/grader*', // grader.js, grader-*.js, grader-rubric.json, grader-workers/
86
87
  'modules/grading/grade-engine.js', // grader.js requires it (task 1002839)
87
88
  ]);
88
89
 
@@ -348,6 +348,16 @@ if (require.main === module) {
348
348
  console.log(require('./ship-deploy-target.js').resolveDeployMode());
349
349
  process.exit(0);
350
350
  }
351
+ // task 1004536: the autonomous-run grade cap — the first grade plus ONE
352
+ // re-grade. Checked HERE, before either flow and before any flag is read, so
353
+ // --regrade, a plain re-ship that resumes the grade, and --skip-grade are all
354
+ // refused alike. Inert unless the Autobongos supervisor set the run's ledger.
355
+ const { checkGradeCap, REFUSED_EXIT } = require('./autobongos-grade-cap.js');
356
+ const gradeCap = checkGradeCap({ taskId: Number(process.argv[2]) });
357
+ if (!gradeCap.ok) {
358
+ for (const line of gradeCap.lines) console.error(line);
359
+ process.exit(REFUSED_EXIT);
360
+ }
351
361
  // #678: --regrade re-grades a grade-failed `completed` task (no claim needed).
352
362
  // Dispatched here so it owns its own arg-parsing + session bootstrap.
353
363
  const main = process.argv.slice(2).includes('--regrade') ? regradeMain : shipMain;
@@ -103,6 +103,9 @@ function git(dir, args, run = spawnSync) {
103
103
  return { ok, stdout: (r && r.stdout) || '', stderr: (r && r.stderr) || '', status: r ? r.status : null };
104
104
  }
105
105
 
106
+ // The sweep's own materialize preview — the fallback for an instance core that predates it.
107
+ function ownWillRewrite() { return require('./claude-materialize.js').materializeWillRewrite; }
108
+
106
109
  // The instance-relative paths THIS upgrade writes into the instance's own tree — the provenance
107
110
  // answer pinState's third bucket is sorted by.
108
111
  //
@@ -118,11 +121,21 @@ function git(dir, args, run = spawnSync) {
118
121
  // It is the same function upgrade.js's own clean-tree pre-flight waives dirt by, so the lane and
119
122
  // the bump it spawns agree on what "this upgrade rewrote" means.
120
123
  //
121
- // FAIL-SOFT, and the direction is deliberate. An instance whose core predates either export, or whose
122
- // materialize throws, yields nothing here: every dirty path stays in `other` and the guard is exactly
123
- // as strict as it was before this function existed. The failure mode is "still wedged", never
124
- // "committed a stranger's work".
125
- function laneGeneratedPaths(instDir, { resolveFrom = coreModulePathFrom, load = require } = {}) {
124
+ // A CORE TOO OLD TO ANSWER (task 1004519). materializeWillRewrite shipped in 1.19.933, and the
125
+ // wedge it exists for is exactly the one that strands an instance on an OLDER core: hoodify-finance
126
+ // took one bump to 1.19.871, which materialized 33 skills and committed nothing, and its core then
127
+ // could not say those files were its own — so the set came back empty and every sweep halted on
128
+ // them, forever. When the instance's core ships claude-materialize.js but not the preview, the
129
+ // preview is asked of the sweep's OWN copy instead, run over the instance's INSTALLED core. That is
130
+ // still provenance, not a glob: the file set is walked off that core's .claude/, the very files its
131
+ // own upgrade materializes. And the sweep's copy is no longer the stale one the paragraph above
132
+ // warns about — it runs from the platform's installed core (task 1004307), always the newest here.
133
+ //
134
+ // FAIL-SOFT, and the direction is deliberate. An instance whose core ships no materialize at all,
135
+ // whose installed core has no .claude/skills to walk, or whose materialize throws, yields nothing
136
+ // here: every dirty path stays in `other` and the guard is exactly as strict as it was before this
137
+ // function existed. The failure mode is "still wedged", never "committed a stranger's work".
138
+ function laneGeneratedPaths(instDir, { resolveFrom = coreModulePathFrom, load = require, own = ownWillRewrite } = {}) {
126
139
  const out = new Set();
127
140
  try {
128
141
  const p = resolveFrom(instDir, 'scripts/gds/upgrade.js');
@@ -130,13 +143,16 @@ function laneGeneratedPaths(instDir, { resolveFrom = coreModulePathFrom, load =
130
143
  } catch (_) { /* an older core, or one that will not load here — stay strict */ }
131
144
  try {
132
145
  const p = resolveFrom(instDir, 'scripts/gds/claude-materialize.js');
133
- const willRewrite = p && load(p).materializeWillRewrite;
134
- if (willRewrite) {
146
+ if (p) {
135
147
  // coreRoot is derived from where the module RESOLVED (<core>/scripts/gds/x.js → <core>), not
136
148
  // from a package-name guess: an instance may carry the core under any of its published names,
137
149
  // and in the monolith layout the core root IS the instance root (where materialize no-ops).
138
150
  const coreRoot = path.resolve(path.dirname(p), '..', '..');
139
- for (const f of willRewrite({ instanceDir: instDir, coreRoot }, () => {}) || []) out.add(f);
151
+ const theirs = load(p).materializeWillRewrite;
152
+ // Only fall back where there is a real .claude/ to walk — an empty source still "writes" the
153
+ // provenance manifest, and that is a guess, not an answer.
154
+ const willRewrite = theirs || (fs.existsSync(path.join(coreRoot, '.claude', 'skills')) ? own() : null);
155
+ if (willRewrite) for (const f of willRewrite({ instanceDir: instDir, coreRoot }, () => {}) || []) out.add(f);
140
156
  }
141
157
  } catch (_) { /* same — a set we could not derive is an empty one, never a guessed one */ }
142
158
  return [...out];
@@ -136,6 +136,7 @@ function runnerReason(text) {
136
136
  if (/^could not read the published core versions/.test(s)) return 'registry_unreadable';
137
137
  if (/is not published yet/.test(s)) return 'not_published';
138
138
  if (/could not read the core version installed/.test(s)) return 'installed_unreadable';
139
+ if (/is older than the core this project already runs/.test(s)) return 'downgrade';
139
140
  if (/update channel/.test(s)) return 'outside_channel';
140
141
  return 'unknown';
141
142
  }