@bongos/core 1.21.5 → 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 (76) hide show
  1. package/.bongos-core.json +138 -68
  2. package/clients/bongos-client/index.d.ts +1 -1
  3. package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +1 -0
  4. package/docs/api/openapi.json +2 -1
  5. package/docs/api-reference.md +1 -1
  6. package/docs/copy-inventory.md +156 -144
  7. package/docs/copy-registry.json +286 -158
  8. package/docs/module-api-changelog.md +2 -0
  9. package/docs/onboarding/diagrams/03-drachmae-karma.mmd +1 -1
  10. package/docs/onboarding/diagrams/assertions.json +1 -1
  11. package/docs/page-inventory.json +31 -4
  12. package/docs/page-readings.json +121 -94
  13. package/modules/autonomy/config-idle.js +192 -0
  14. package/modules/autonomy/routes/autonomy.js +22 -0
  15. package/modules/autonomy/runner-health.js +22 -1
  16. package/modules/hall-ui/public/city-draw.js +438 -0
  17. package/modules/hall-ui/public/city.css +110 -0
  18. package/modules/hall-ui/public/city.html +71 -0
  19. package/modules/hall-ui/public/city.js +177 -0
  20. package/modules/hall-ui/public/city.states.json +30 -0
  21. package/modules/hall-ui/public/gate.js +11 -4
  22. package/modules/hall-ui/public/profile.css +4 -0
  23. package/modules/hall-ui/public/profile.js +25 -1
  24. package/modules/hall-ui/public/settings-autobongos.js +19 -1
  25. package/modules/hall-ui/public/settings.css +26 -0
  26. package/modules/hall-ui/public/settings.html +8 -0
  27. package/modules/hall-ui/public/settings.js +43 -3
  28. package/modules/platform-identity/craft-rollup.js +72 -0
  29. package/modules/platform-identity/migrations/platform_identity_029_activity_crafts.sql +23 -0
  30. package/modules/platform-identity/platform-identity.js +12 -6
  31. package/modules/platform-identity/routes/sso.js +4 -0
  32. package/modules/platform-identity/tests/platform-identity.mjs +1 -1
  33. package/modules/provisioning/core-upgrade.js +34 -7
  34. package/modules/provisioning/module.json +2 -1
  35. package/modules/provisioning/seams.js +2 -0
  36. package/modules/public-landing/public/projects.states.json +1 -0
  37. package/package-lock.json +2 -2
  38. package/package.json +1 -1
  39. package/release-notes.json +58 -0
  40. package/scripts/gds/autobongos-grade-cap.js +187 -0
  41. package/scripts/gds/autobongos-loop.js +4 -0
  42. package/scripts/gds/autobongos-run.js +154 -13
  43. package/scripts/gds/autobongos-verify.js +211 -14
  44. package/scripts/gds/provision-core-upgrade.js +14 -2
  45. package/scripts/gds/provision-repo.js +77 -13
  46. package/scripts/gds/ship-flow.js +5 -0
  47. package/scripts/gds/ship-preflight-steps.js +2 -1
  48. package/scripts/gds/ship.js +10 -0
  49. package/scripts/gds/update-sweep.js +24 -8
  50. package/scripts/gds/upgrade-outcome.js +1 -0
  51. package/src/bongos/auth-admission.js +95 -13
  52. package/src/bongos/db-kernel.js +1 -1
  53. package/src/bongos/routes/core-update.js +27 -2
  54. package/src/bongos/serve-internal.js +14 -0
  55. package/src/bongos/software-update.js +3 -2
  56. package/src/module-api.js +1 -1
  57. package/tests/activity_rollup_order_db.mjs +3 -1
  58. package/tests/activity_snapshots_db.mjs +2 -0
  59. package/tests/autobongos_grade_cap.mjs +125 -0
  60. package/tests/autobongos_loop.mjs +230 -1
  61. package/tests/autobongos_verify.mjs +239 -3
  62. package/tests/autonomy_config_idle.mjs +272 -0
  63. package/tests/core_update_banner.mjs +29 -0
  64. package/tests/core_upgrade_door.mjs +31 -1
  65. package/tests/core_upgrade_runner.mjs +20 -0
  66. package/tests/hall_audit.mjs +10 -1
  67. package/tests/hall_city.mjs +369 -0
  68. package/tests/hall_page_gate_map.mjs +5 -0
  69. package/tests/hub_craft_rollup.mjs +350 -0
  70. package/tests/profile_rollup_consent.mjs +2 -0
  71. package/tests/profile_route.mjs +16 -2
  72. package/tests/provision.mjs +78 -0
  73. package/tests/settings_main_role.mjs +190 -0
  74. package/tests/software_update.mjs +56 -1
  75. package/tests/update_subscription_engine.mjs +55 -3
  76. package/tests/wizard_physics_more.mjs +19 -0
@@ -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
  }
@@ -132,6 +132,69 @@ async function recentShipsForRollup(builderId) {
132
132
  }));
133
133
  }
134
134
 
135
+ // Each craft by its own work, for the hub's profile tiles (task 1004440 /
136
+ // WA6.RP10, goal 1000095). Until now the rollup said "Roles" from the declared
137
+ // preference alone and counted only shipped tasks, so an artist or an ideator who
138
+ // had done real work read on the hub as an engineer with nothing shipped.
139
+ //
140
+ // A craft is EARNED when this builder holds credits in it — the same rule the
141
+ // local profile uses to show a role (src/bongos/role-stats.js, task 1004433),
142
+ // read through the same ports. This is a kernel file, so it reads those ports
143
+ // by key rather than importing role-stats (ADR 0091 §1). For each earned craft it
144
+ // sends that craft's headline counts; an unearned craft sends nothing at all.
145
+ // Counts and sums only: no idea title, no page wording, no task list.
146
+ //
147
+ // `disciplines` becomes the earned crafts, in the builder's preferred order;
148
+ // with nothing earned (or no economy module to ask) it falls back to the declared
149
+ // preference, exactly as before. A port that cannot be asked leaves its numbers
150
+ // null ("could not ask"), never 0. Never throws: a failed read drops its craft's
151
+ // numbers, not the report.
152
+ const ROLLUP_CRAFTS = Object.freeze(['engineer', 'artist', 'ideator']);
153
+ const countOrNull = (v) => (Number.isInteger(v) && v >= 0 ? v : null);
154
+ async function readCraftRollup(builderId, { shipped = 0, preferred = [] } = {}) {
155
+ const fallback = { counts: null, disciplines: Array.isArray(preferred) ? preferred : [] };
156
+ try {
157
+ const reward = seams.resolveOptional('reward');
158
+ const byCraftMap = reward && typeof reward.creditsByCraft === 'function'
159
+ ? await reward.creditsByCraft([builderId]) : null;
160
+ const byCraft = byCraftMap ? byCraftMap.get(String(builderId)) : null;
161
+ if (!byCraft) return fallback;
162
+ const prefCrafts = fallback.disciplines
163
+ .map((d) => (typeof reward.craftForPreference === 'function' ? reward.craftForPreference(d) : d))
164
+ .filter((c) => ROLLUP_CRAFTS.includes(c));
165
+ const earned = [...prefCrafts, ...ROLLUP_CRAFTS]
166
+ .filter((c, i, a) => a.indexOf(c) === i && Number(byCraft[c]) > 0);
167
+ if (!earned.length) return fallback;
168
+ const ask = async (port) => {
169
+ const fn = seams.resolveOptional(port);
170
+ if (typeof fn !== 'function') return null;
171
+ try { return (await fn(builderId)) || null; } catch { return null; }
172
+ };
173
+ const [artist, ideator] = await Promise.all([
174
+ earned.includes('artist') ? ask('copy-desk.artistStats') : null,
175
+ earned.includes('ideator') ? ask('ideas.ideatorStats') : null,
176
+ ]);
177
+ const credits = (c) => countOrNull(Math.round(Number(byCraft[c])));
178
+ const counts = {};
179
+ if (earned.includes('engineer')) counts.engineer = { shipped: countOrNull(shipped), credits: credits('engineer') };
180
+ if (earned.includes('artist')) {
181
+ counts.artist = { pages_approved: countOrNull(artist && artist.pages_approved), credits: credits('artist') };
182
+ }
183
+ if (earned.includes('ideator')) {
184
+ counts.ideator = {
185
+ filed: countOrNull(ideator && ideator.filed),
186
+ full_ideas: countOrNull(ideator && ideator.full_ideas),
187
+ ratified: countOrNull(ideator && ideator.ratified),
188
+ credits: credits('ideator'),
189
+ };
190
+ }
191
+ return { counts, disciplines: earned };
192
+ } catch (err) {
193
+ log.info('[auth] craft rollup read failed (non-blocking):', err.message);
194
+ return fallback;
195
+ }
196
+ }
197
+
135
198
  // Best-effort cross-project activity rollup to the hub (ADR 0141 §4): report this
136
199
  // builder's descriptive talent snapshot (credits, ships, karma, rank, disciplines,
137
200
  // and since task 1002803 their last few shipped works) for the cross-project
@@ -162,26 +225,45 @@ async function reportActivityRollup({ builderId, githubId = null }) {
162
225
  const row = b.rows[0];
163
226
  const ghId = githubId ?? row.github_id;
164
227
  if (!/^\d+$/.test(String(ghId ?? ''))) return;
165
- const shipped = await pool.query('SELECT count(*)::int AS n FROM tasks WHERE shipped_by = $1', [builderId]);
228
+ // SHIPPED works only (task 1004440): a count with no status filter also
229
+ // counted work that was handed back after shipping. The same predicate as the
230
+ // instance's own profile (lifecycle countShippedTasksForBuilder).
231
+ const shipped = await pool.query(
232
+ `SELECT count(*)::int AS n FROM tasks WHERE shipped_by = $1 AND status = 'shipped'`,
233
+ [builderId],
234
+ );
235
+ const shippedCount = shipped.rows[0] ? shipped.rows[0].n : 0;
166
236
  const recentShips = await recentShipsForRollup(builderId);
167
- await fetch(`${idp.origin}/api/bongos/sso/activity/rollup`, {
237
+ const craft = await readCraftRollup(builderId, { shipped: shippedCount, preferred: row.preferred_disciplines });
238
+ const body = {
239
+ client_id: idp.clientId,
240
+ client_secret: idp.clientSecret,
241
+ github_id: ghId,
242
+ credits: Number(row.total_credits) || 0,
243
+ tasks_shipped: shippedCount,
244
+ karma: Number(row.karma) || 0,
245
+ rank: row.rank || null,
246
+ disciplines: craft.disciplines,
247
+ recent_ships: recentShips,
248
+ };
249
+ if (craft.counts) body.crafts = craft.counts;
250
+ const send = (payload) => fetch(`${idp.origin}/api/bongos/sso/activity/rollup`, {
168
251
  method: 'POST',
169
252
  headers: {
170
253
  'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent(),
171
254
  'Bongos-Report-As-Of': asOf,
172
255
  },
173
- body: JSON.stringify({
174
- client_id: idp.clientId,
175
- client_secret: idp.clientSecret,
176
- github_id: ghId,
177
- credits: Number(row.total_credits) || 0,
178
- tasks_shipped: shipped.rows[0] ? shipped.rows[0].n : 0,
179
- karma: Number(row.karma) || 0,
180
- rank: row.rank || null,
181
- disciplines: Array.isArray(row.preferred_disciplines) ? row.preferred_disciplines : [],
182
- recent_ships: recentShips,
183
- }),
256
+ body: JSON.stringify(payload),
184
257
  });
258
+ const res = await send(body);
259
+ // A hub older than `crafts` validates the body strictly and refuses the
260
+ // unknown field with a 400, which would lose the whole report. So a refused
261
+ // report that carried crafts goes again once without them: the totals still
262
+ // land, and the craft numbers arrive once that hub upgrades.
263
+ if (body.crafts && res && res.status === 400) {
264
+ const { crafts: _unsent, ...older } = body;
265
+ await send(older);
266
+ }
185
267
  } catch (err) {
186
268
  log.info('[auth] hub activity rollup failed (non-blocking):', err.message);
187
269
  }
@@ -55,7 +55,7 @@ async function getBuilderById(id) {
55
55
  // AFTER INSERT triggers on credit_log + karma_log respectively.
56
56
  const { rows } = await pool.query(
57
57
  `SELECT id, github_id, github_login, display_name, avatar_url, total_credits, karma, created_at, rank,
58
- status, deactivated_at, reactivated_at, deactivated_reason, preferred_disciplines, system_role,
58
+ status, deactivated_at, reactivated_at, deactivated_reason, preferred_disciplines, main_discipline, system_role,
59
59
  anthropic_email, anthropic_user_id, monthly_budget_usd
60
60
  FROM builders WHERE id = $1`,
61
61
  [id]
@@ -129,7 +129,27 @@ function contributeCoreUpdateToDocket(read = readCoreUpdate) {
129
129
  });
130
130
  }
131
131
 
132
+ // This hall's own update rule, from the provisioning module when it runs (task 1004535):
133
+ // the rule set on /deploy, which the runner and the sweep already follow (ADR 0357: the
134
+ // /deploy row beats the sweep's roster file, which lives in the box's own checkout and is
135
+ // not this server's to read). No module, no row, or a failed read → null, and the readers
136
+ // fall back to the platform default exactly as before.
137
+ function storedSelfChannel() {
138
+ const read = seams.resolveOptional('provisioning.selfUpdateChannel');
139
+ return typeof read === 'function' ? read() : null;
140
+ }
141
+
142
+ // { channel, source } — WHICH rule the answer was computed under, so the page can say so:
143
+ // 'deploy' (the project's stored rule) or 'default' (none could be read). Never throws.
144
+ async function resolveChannel(selfChannel) {
145
+ let channel = null;
146
+ try { channel = (await selfChannel()) || null; } catch { channel = null; }
147
+ return { channel, source: channel ? 'deploy' : 'default' };
148
+ }
149
+
132
150
  function buildCoreUpdateRouter({
151
+ coreUpdate = readCoreUpdate,
152
+ selfChannel = storedSelfChannel,
133
153
  upgrades = readCoreUpgrades,
134
154
  softwareUpdate = readSoftwareUpdate,
135
155
  canUpdate = holdsCorePin,
@@ -155,7 +175,8 @@ function buildCoreUpdateRouter({
155
175
  try { running = require('../../module-api').CORE_VERSION; } catch { /* version unknown */ }
156
176
  // No `deploy_url` any more (task 1004296): the banner on a hall without a door of its
157
177
  // own links to Settings → Software update, and the hand-off lives on GET /software-update.
158
- res.json({ ok: true, update: await readCoreUpdate({ running }) });
178
+ const { channel, source } = await resolveChannel(selfChannel);
179
+ res.json({ ok: true, update: { ...(await coreUpdate({ running, channel })), channel_source: source } });
159
180
  }, { errorCode: 'core_update_failed', message: false }),
160
181
  );
161
182
 
@@ -186,7 +207,11 @@ function buildCoreUpdateRouter({
186
207
  asyncHandler('GET /software-update', async (req, res) => {
187
208
  let running = null;
188
209
  try { running = require('../../module-api').CORE_VERSION; } catch { /* version unknown */ }
189
- const [update, can] = await Promise.all([softwareUpdate({ running }), canUpdate(req)]);
210
+ // canUpdate does not depend on the rule, so it runs beside the rule read, not after it.
211
+ const [update, can] = await Promise.all([
212
+ resolveChannel(selfChannel).then(async ({ channel, source }) => ({ ...(await softwareUpdate({ running, channel })), channel_source: source })),
213
+ canUpdate(req),
214
+ ]);
190
215
  res.json({ ok: true, update, door: updateDoor(doorInputs()), can_update: can === true });
191
216
  }, { errorCode: 'software_update_failed', message: false }),
192
217
  );
@@ -941,6 +941,16 @@ function mountInternalSurfaces(app) {
941
941
  // statically, and a comment in that gap drops the regex out of the map, which
942
942
  // makes the page test as UNGATED while looking gated here. Prose goes above.
943
943
  const BOARD_ROOM_PAGE_RE = /^\/builders\/board-room(?:\.html)?\/?$/;
944
+ // /city — the Governor City's home (task 1004524, goal 1000125). Gated on the
945
+ // `board.vote.cast` atom, like the Board Room, because that is the atom its one
946
+ // governed read (GET /government/docket) checks: the page's reach equals the
947
+ // right to read what it draws, so no holder is refused a city they could read
948
+ // and no one else is served its shell. Its two goal reads are requireBuilder,
949
+ // a wider floor.
950
+ //
951
+ // NB as at PROJECT_SETTINGS_PAGE_RE: nothing between the `=` and the literal
952
+ // (tests/hall_page_gate_map.mjs parses this file statically). Prose goes above.
953
+ const CITY_PAGE_RE = /^\/builders\/city(?:\.html)?\/?$/;
944
954
  // /deploy — the owner's deploy door (task 1003159, ADR 0293). Gated on the
945
955
  // `core.pin.move` atom, the same key its preview and its move both check, so the
946
956
  // shell's reach equals the right to press the button. Archon-floor and system:true,
@@ -1023,6 +1033,10 @@ function mountInternalSurfaces(app) {
1023
1033
  if (BOARD_ROOM_PAGE_RE.test(norm)) {
1024
1034
  return gdsAuth.requireBoardVotePage(req, res, next);
1025
1035
  }
1036
+ // task 1004524: the Governor City, on the docket's own atom (see CITY_PAGE_RE).
1037
+ if (CITY_PAGE_RE.test(norm)) {
1038
+ return gdsAuth.requireBoardVotePage(req, res, next);
1039
+ }
1026
1040
  // ADR 0293: the deploy door, on the same atom its two routes check.
1027
1041
  if (DEPLOY_PAGE_RE.test(norm)) {
1028
1042
  return gdsAuth.requireCorePinPage(req, res, next);
@@ -18,8 +18,9 @@
18
18
  //
19
19
  // THE CHANNEL RULE IS NOT RESTATED HERE, as in core-update.js: `resolveChannelTarget` is the
20
20
  // upgrade runner's own function, so this never offers a version the runner would refuse.
21
- // No channel is passed, exactly as the banner passes none: an instance does not store its
22
- // own channel, and the runner's default is what it would get.
21
+ // The channel is the project's own update rule when the route can read it (the rule set on
22
+ // /deploy, via the provisioning.selfUpdateChannel seam — task 1004535), else the runner's
23
+ // default. The banner (core-update.js) is handed the same rule, so the two cannot disagree.
23
24
  //
24
25
  // A READING, NEVER A GUESS. `reading` is 'ok' only when the registry answered; "up to date"
25
26
  // is `offered: null` on an ok reading and nothing else. Notes that could not be read say so,
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.5'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.6'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -74,7 +74,9 @@ const same = (actual, expected, msg) => { assert.deepEqual(actual, expected, msg
74
74
 
75
75
  try {
76
76
  for (const f of ['platform_identity_005_builder_activity.sql', 'platform_identity_012_recent_ships.sql',
77
- 'platform_identity_022_activity_snapshots.sql', 'platform_identity_026_activity_reported_as_of.sql']) {
77
+ 'platform_identity_022_activity_snapshots.sql', 'platform_identity_026_activity_reported_as_of.sql',
78
+ // upsertActivity also writes crafts (029, task 1004440): 42703 without it.
79
+ 'platform_identity_029_activity_crafts.sql']) {
78
80
  await q(migrationSql(f));
79
81
  }
80
82
  await q(migrationSql('platform_identity_026_activity_reported_as_of.sql')); // idempotent: replays cleanly
@@ -99,6 +99,8 @@ try {
99
99
  await q(migrationSql('platform_identity_022_activity_snapshots.sql'));
100
100
  // upsertActivity also writes reported_as_of (task 1004268) — same reason as 012 above.
101
101
  await q(migrationSql('platform_identity_026_activity_reported_as_of.sql'));
102
+ // ...and each craft's counts (029, task 1004440), same reason again.
103
+ await q(migrationSql('platform_identity_029_activity_crafts.sql'));
102
104
  await cleanup();
103
105
 
104
106
  // 1 — the writer snapshots the LIVE row, and only when it changed.