@bongos/core 1.17.1 → 1.17.3

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 (233) hide show
  1. package/.bongos-core.json +521 -281
  2. package/.claude/skills/blocker-review/SKILL.md +1 -1
  3. package/.claude/skills/builder-claim/SKILL.md +2 -0
  4. package/.claude/skills/builder-exit/SKILL.md +2 -2
  5. package/.claude/skills/builder-redteam/SKILL.md +3 -3
  6. package/.claude/skills/builder-setup/SKILL.md +2 -2
  7. package/.claude/skills/builder-start/SKILL.md +1 -1
  8. package/.claude/skills/dev/SKILL.md +1 -1
  9. package/.claude/skills/idea-triage/SKILL.md +2 -2
  10. package/.claude/skills/ideate/SKILL.md +1 -1
  11. package/.claude/skills/merge-mode/SKILL.md +1 -1
  12. package/.claude/skills/new-project/SKILL.md +3 -0
  13. package/.claude/skills/paint/SKILL.md +1 -1
  14. package/GOVERNANCE.md +1 -1
  15. package/README.md +1 -1
  16. package/clients/bongos-client/README.md +1 -1
  17. package/clients/bongos-client/bongos-client.global.js +2 -0
  18. package/clients/bongos-client/index.cjs +2 -0
  19. package/clients/bongos-client/index.d.ts +4 -2
  20. package/clients/bongos-client/index.mjs +2 -0
  21. package/docs/adr/0001-stack-choice-single-vps.md +1 -1
  22. package/docs/adr/0008-google-chat-oauth-user-auth.md +1 -1
  23. package/docs/adr/0019-red-team-bounty-payout-table.md +3 -3
  24. package/docs/adr/0020-security-model-public-stub.md +1 -1
  25. package/docs/adr/0023-kind-multipliers-and-idea-bonus.md +1 -1
  26. package/docs/adr/0024-cloneable-repo-local-first-memory.md +1 -1
  27. package/docs/adr/0026-bfg-memory-hygiene-and-cross-builder-learning.md +4 -4
  28. package/docs/adr/0027-bfg-session-inefficiency-evaluator.md +3 -3
  29. package/docs/adr/0030-builders-hall-live-updates.md +1 -1
  30. package/docs/adr/0031-cloud-dev-environments-for-builders.md +4 -4
  31. package/docs/adr/0032-discord-mirror-of-gds.md +2 -2
  32. package/docs/adr/0034-thetes-graduated-newcomer-rank.md +1 -1
  33. package/docs/adr/0044-mediterranean-palette-replacement.md +1 -1
  34. package/docs/adr/0044-per-box-live-game-preview.md +1 -1
  35. package/docs/adr/0054-session-token-reward-cost-plus.md +10 -10
  36. package/docs/adr/0057-container-cost-ledger.md +3 -3
  37. package/docs/adr/0062-medusa-instance-model-decoupling.md +4 -4
  38. package/docs/adr/0065-agpl-nonprofit-ai-first-governance.md +2 -2
  39. package/docs/adr/0085-remove-default-per-builder-spend-cap.md +1 -1
  40. package/docs/adr/0086-goal-scoped-work-hierarchy.md +1 -1
  41. package/docs/adr/0096-require-reward-before-workable.md +9 -9
  42. package/docs/adr/0097-retroactive-reward-backfill.md +3 -3
  43. package/docs/adr/0100-two-repo-instance-model.md +1 -1
  44. package/docs/adr/0101-goal-hierarchy-phase-5-judge-membership-reward.md +3 -3
  45. package/docs/adr/0103-core-first-extraction-cloud-bongos-trunk.md +3 -3
  46. package/docs/adr/0107-module-upstreaming-contribution-to-core-catalog.md +1 -1
  47. package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +6 -6
  48. package/docs/adr/0136-update-channel-subscription-policy.md +51 -0
  49. package/docs/adr/0137-upstream-publish-policy-manual-owner-gated.md +40 -0
  50. package/docs/adr/0138-bundled-module-deps-declared-per-module.md +45 -0
  51. package/docs/adr/README.md +9 -6
  52. package/docs/api/openapi.json +65 -26
  53. package/docs/api-reference.md +3 -2
  54. package/docs/architecture.md +2 -2
  55. package/docs/branding-contract.md +5 -4
  56. package/docs/canonical-permissions.md +1 -1
  57. package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
  58. package/docs/file-map.md +18 -17
  59. package/docs/handoff-template.md +2 -2
  60. package/docs/modules-contract.md +6 -1
  61. package/docs/onboarding/diagrams/README.md +7 -7
  62. package/docs/onboarding/drachmae-and-karma.md +18 -18
  63. package/docs/onboarding/primer.md +9 -9
  64. package/docs/recipes/audit-replay.md +2 -2
  65. package/docs/recipes/builder-box-lifecycle.md +2 -2
  66. package/docs/recipes/claude-design-loop.md +1 -1
  67. package/docs/recipes/ops-gotchas.md +1 -1
  68. package/docs/recipes/private-npm-distribution.md +17 -0
  69. package/docs/recipes/staging-environment.md +1 -1
  70. package/docs/recipes/upgrading-the-core.md +55 -0
  71. package/docs/research/cursor-competitive-analysis.md +1 -1
  72. package/docs/research/llm-model-agnostic-and-cheaper-grader.md +1 -1
  73. package/modules/discord/module.json +2 -1
  74. package/modules/economy/achievements.js +9 -1
  75. package/modules/economy/credits.js +10 -1
  76. package/modules/hall-ui/public/builders.js +137 -620
  77. package/modules/hall-ui/public/goal-inbox.js +232 -0
  78. package/modules/hall-ui/public/goals-page.js +4 -1
  79. package/modules/hall-ui/public/goals.html +8 -0
  80. package/modules/hall-ui/public/hall-kit.js +474 -0
  81. package/modules/hall-ui/public/hall-widgets.js +14 -5
  82. package/modules/hall-ui/public/index.html +3 -0
  83. package/modules/hall-ui/public/instances-panel.js +24 -3
  84. package/modules/hall-ui/public/new-project-wizard.js +7 -5
  85. package/modules/hall-ui/public/style.css +168 -0
  86. package/modules/hall-ui/public/work.css +194 -0
  87. package/modules/hall-ui/public/work.html +46 -374
  88. package/modules/hall-ui/public/work.js +137 -102
  89. package/modules/lifecycle/criterion-suggest.js +80 -0
  90. package/modules/lifecycle/db.js +42 -5
  91. package/modules/lifecycle/routes/goals.js +26 -0
  92. package/modules/lifecycle/routes/tasks.js +21 -0
  93. package/modules/provisioning/CLAUDE.md +15 -0
  94. package/modules/provisioning/migrations/provisioning_001_tables.sql +160 -0
  95. package/modules/provisioning/migrations/provisioning_002_standalone_shape.sql +11 -0
  96. package/modules/provisioning/migrations/provisioning_003_github_app_manifest.sql +95 -0
  97. package/modules/provisioning/module.json +17 -0
  98. package/modules/provisioning/onboard-plan.js +37 -0
  99. package/modules/provisioning/provisioning.js +539 -0
  100. package/modules/provisioning/rate-limit.js +35 -0
  101. package/modules/provisioning/routes/provisioning.js +607 -0
  102. package/modules/provisioning/tests/provisioning.mjs +232 -0
  103. package/modules/provisioning/ui/.gitkeep +0 -0
  104. package/modules/security/secret-scrub.js +10 -0
  105. package/package-lock.json +2 -2
  106. package/package.json +7 -2
  107. package/scripts/discord/README.md +1 -1
  108. package/scripts/gds/claim.js +98 -48
  109. package/scripts/gds/claude-materialize.js +30 -9
  110. package/scripts/gds/cutover-codemod.js +241 -0
  111. package/scripts/gds/fitness.js +54 -0
  112. package/scripts/gds/init.js +59 -3
  113. package/scripts/gds/lint-infra-exec.js +70 -0
  114. package/scripts/gds/lint-workflow-exec.js +63 -0
  115. package/scripts/gds/mirror-lineage-check.js +14 -5
  116. package/scripts/gds/mirror-redact.js +8 -0
  117. package/scripts/gds/onboard.js +4 -0
  118. package/scripts/gds/package-core.js +18 -3
  119. package/scripts/gds/publish-manifest.js +60 -2
  120. package/scripts/gds/run-routine.js +51 -13
  121. package/scripts/gds/run-unit-tests.js +4 -0
  122. package/scripts/gds/update-channel.js +186 -0
  123. package/scripts/gds/upgrade.js +116 -9
  124. package/scripts/hall-preview/README.md +103 -0
  125. package/scripts/hall-preview/fixtures/access-requests.json +1 -0
  126. package/scripts/hall-preview/fixtures/achievements.json +76 -0
  127. package/scripts/hall-preview/fixtures/analytics__builder.json +251 -0
  128. package/scripts/hall-preview/fixtures/audit-log.json +1 -0
  129. package/scripts/hall-preview/fixtures/boxes__cost-ledger.json +1 -0
  130. package/scripts/hall-preview/fixtures/builders__active-claims.json +1 -0
  131. package/scripts/hall-preview/fixtures/builders__directory.json +81 -0
  132. package/scripts/hall-preview/fixtures/builders__roster.json +1 -0
  133. package/scripts/hall-preview/fixtures/goals.json +502 -0
  134. package/scripts/hall-preview/fixtures/goals__25.json +96 -0
  135. package/scripts/hall-preview/fixtures/goals__26.json +67 -0
  136. package/scripts/hall-preview/fixtures/goals__3.json +103 -0
  137. package/scripts/hall-preview/fixtures/goals__35.json +49 -0
  138. package/scripts/hall-preview/fixtures/goals__conflicts.json +154 -0
  139. package/scripts/hall-preview/fixtures/goals__detail.json +96 -0
  140. package/scripts/hall-preview/fixtures/goals__inbox.json +5 -0
  141. package/scripts/hall-preview/fixtures/goals__requests.json +3 -0
  142. package/scripts/hall-preview/fixtures/grades__by-builder.json +1 -0
  143. package/scripts/hall-preview/fixtures/inbox.json +330 -0
  144. package/scripts/hall-preview/fixtures/instance.json +124 -0
  145. package/scripts/hall-preview/fixtures/leaderboard.json +184 -0
  146. package/scripts/hall-preview/fixtures/me.json +476 -0
  147. package/scripts/hall-preview/fixtures/me__recent-ships.json +304 -0
  148. package/scripts/hall-preview/fixtures/override-requests.json +1 -0
  149. package/scripts/hall-preview/fixtures/provisioning__fleet.json +1 -0
  150. package/scripts/hall-preview/fixtures/provisioning__fleet__cost-ledger.json +1 -0
  151. package/scripts/hall-preview/fixtures/provisioning__instances.json +1 -0
  152. package/scripts/hall-preview/fixtures/public__analytics__ecosystem.json +516 -0
  153. package/scripts/hall-preview/fixtures/public__bounty-table.json +37 -0
  154. package/scripts/hall-preview/fixtures/public__cost-summary.json +742 -0
  155. package/scripts/hall-preview/fixtures/public__estimation-drift.json +41 -0
  156. package/scripts/hall-preview/fixtures/public__grades.json +50 -0
  157. package/scripts/hall-preview/fixtures/public__progress.json +124 -0
  158. package/scripts/hall-preview/fixtures/public__recent-shipped.json +426 -0
  159. package/scripts/hall-preview/fixtures/public__repo-health.json +160 -0
  160. package/scripts/hall-preview/fixtures/public__repo-info.json +5 -0
  161. package/scripts/hall-preview/fixtures/security__docs.json +1 -0
  162. package/scripts/hall-preview/fixtures/security__reports.json +1 -0
  163. package/scripts/hall-preview/fixtures/sessions__search.json +1 -0
  164. package/scripts/hall-preview/fixtures/tasks.json +2420 -0
  165. package/scripts/hall-preview/fixtures/tasks__claimable.json +2555 -0
  166. package/scripts/hall-preview/fixtures/tasks__detail.json +111 -0
  167. package/scripts/hall-preview/fixtures/tasks__peer-votes__feed.json +330 -0
  168. package/scripts/hall-preview/fixtures/versions.json +89 -0
  169. package/scripts/hall-preview/refresh-fixtures.js +77 -0
  170. package/scripts/hall-preview/server.js +178 -0
  171. package/scripts/migrate.sh +29 -4
  172. package/src/bongos/auth.js +14 -1
  173. package/src/bongos/optimizer.js +37 -2
  174. package/src/bongos/serve-internal.js +10 -1
  175. package/src/branding.js +1 -0
  176. package/src/module-api.js +12 -1
  177. package/src/module-loader/loader.js +36 -4
  178. package/src/module-loader/manifest-schema.js +16 -1
  179. package/tests/branding.mjs +11 -0
  180. package/tests/claim_error_surface.mjs +107 -0
  181. package/tests/claude_materialize.mjs +20 -0
  182. package/tests/consumer_layout_boot.mjs +302 -0
  183. package/tests/criterion_suggest.mjs +106 -0
  184. package/tests/cutover_codemod.mjs +184 -0
  185. package/tests/device_flow_start.mjs +25 -2
  186. package/tests/hook_core_resolve.mjs +125 -0
  187. package/tests/infra_exec_paths.mjs +104 -0
  188. package/tests/init.mjs +47 -0
  189. package/tests/migrate_root_resolution.mjs +97 -0
  190. package/tests/mirror_lineage_proof.mjs +14 -0
  191. package/tests/module_loader.mjs +42 -0
  192. package/tests/module_manifest.mjs +19 -0
  193. package/tests/onboard.mjs +13 -0
  194. package/tests/package_core.mjs +8 -0
  195. package/tests/provisioning_github_app_routes.mjs +15 -0
  196. package/tests/publish_manifest.mjs +52 -1
  197. package/tests/run_routine.mjs +26 -1
  198. package/tests/run_routine_two_root.mjs +77 -0
  199. package/tests/scrubber_corpus.mjs +17 -0
  200. package/tests/update_channel.mjs +186 -0
  201. package/tests/upgrade.mjs +131 -0
  202. package/tests/workflow_exec_paths.mjs +98 -0
  203. package/docs/design/container-exterior-tiles.md +0 -49
  204. package/docs/design/container-exterior.md +0 -81
  205. package/docs/design/container-interior.md +0 -77
  206. package/docs/design/world-map-scroll.md +0 -41
  207. package/docs/recipes/claude-gemini-bongos-doors.md +0 -155
  208. package/docs/recipes/cloudbongos-staging-gate.md +0 -139
  209. package/docs/recipes/cloudbongos-standup.md +0 -111
  210. package/docs/recipes/parallel-load-results-2026-05-30.md +0 -63
  211. package/docs/recipes/parallel-load-test.md +0 -32
  212. package/docs/recipes/pixel-art-pipeline.md +0 -411
  213. package/docs/recipes/staging-data.md +0 -50
  214. package/migrations/instance/018_pms_v3_planning_seed.sql +0 -131
  215. package/migrations/instance/080_gds_v4_planning_seed.sql +0 -75
  216. package/migrations/instance/110_medusa_planning_seed.sql +0 -70
  217. package/migrations/instance/120_bongos_v1_planning_seed.sql +0 -69
  218. package/migrations/instance/144_bongos_v1_module_system_criterion.sql +0 -26
  219. package/migrations/instance/159_bongos_v1_goal_hierarchy_criterion.sql +0 -29
  220. package/migrations/instance/172_v2_world_planning_seed.sql +0 -163
  221. package/migrations/instance/173_bongos_v1_c7_core_modularized.sql +0 -33
  222. package/migrations/instance/182_gdsv4_bongos_consolidation.sql +0 -132
  223. package/migrations/instance/183_gdsv4_carryover_themed_goals.sql +0 -116
  224. package/migrations/instance/186_bongos_v1_governance_planning_seed.sql +0 -69
  225. package/scripts/gds/art-stage.js +0 -152
  226. package/server.js +0 -212
  227. package/tests/art_key.mjs +0 -121
  228. package/tests/day_cycle.mjs +0 -197
  229. package/tests/snapshot_picker.mjs +0 -233
  230. package/tests/sprite_facing.mjs +0 -35
  231. package/tests/terrain_picker.mjs +0 -298
  232. package/tests/turn_in_place.mjs +0 -280
  233. package/tests/walkability.mjs +0 -188
@@ -4,8 +4,9 @@
4
4
 
5
5
  import { strict as assert } from 'node:assert';
6
6
  import { createRequire } from 'node:module';
7
+ import path from 'node:path';
7
8
  const require = createRequire(import.meta.url);
8
- const { parseRoutineFrontmatter, tokenize } = require('../scripts/gds/run-routine.js');
9
+ const { parseRoutineFrontmatter, tokenize, resolveStepArgv } = require('../scripts/gds/run-routine.js');
9
10
 
10
11
  let passed = 0, failed = 0;
11
12
  function test(name, fn) {
@@ -65,5 +66,29 @@ test('tokenize splits argv and honors quotes', () => {
65
66
  assert.deepEqual(tokenize(''), []);
66
67
  });
67
68
 
69
+ // resolveStepArgv — two-root step resolution (ADR 0108; task 2138). Explicit roots (pure).
70
+ const ROOTS = { coreRoot: '/core', instanceRoot: '/inst' };
71
+
72
+ test('resolveStepArgv: `bongos exec <rel>` resolves under the CORE root', () => {
73
+ assert.deepEqual(
74
+ resolveStepArgv(['bongos', 'exec', 'scripts/gds/box.js', 'sweep-idle', '--apply'], ROOTS),
75
+ [process.execPath, path.join('/core', 'scripts/gds/box.js'), 'sweep-idle', '--apply']);
76
+ });
77
+
78
+ test('resolveStepArgv: `node scripts/...` → CORE root; `node .claude/...` companion → INSTANCE root', () => {
79
+ assert.deepEqual(
80
+ resolveStepArgv(['node', 'scripts/gds/x.js', '--flag'], ROOTS),
81
+ [process.execPath, path.join('/core', 'scripts/gds/x.js'), '--flag']);
82
+ assert.deepEqual(
83
+ resolveStepArgv(['node', '.claude/scheduled-tasks/demo/run.js'], ROOTS),
84
+ [process.execPath, path.join('/inst', '.claude/scheduled-tasks/demo/run.js')]);
85
+ });
86
+
87
+ test('resolveStepArgv: absolute paths + non-node commands + empty pass through untouched', () => {
88
+ assert.deepEqual(resolveStepArgv(['node', '/abs/x.js'], ROOTS), ['node', '/abs/x.js']);
89
+ assert.deepEqual(resolveStepArgv(['bash', 'x.sh'], ROOTS), ['bash', 'x.sh']);
90
+ assert.deepEqual(resolveStepArgv([], ROOTS), []);
91
+ });
92
+
68
93
  console.log(`\nrun_routine: ${passed} passed, ${failed} failed`);
69
94
  process.exit(failed ? 1 : 0);
@@ -0,0 +1,77 @@
1
+ // tests/run_routine_two_root.mjs — task 2138 (ADR 0108 configurable-root).
2
+ //
3
+ // Proves the deterministic cron dispatcher works in a real CONSUMER layout: it FINDS a routine
4
+ // under resolveInstanceRoot() (the host repo — NOT the core root) and RUNS its companion step,
5
+ // whose core require() resolves into node_modules/@bongos/core via the materialized
6
+ // _core-resolve.js sibling. This is task 2138's "a consumer-layout dispatch finds + runs the
7
+ // routines" done-when, exercised in a synthetic two-root tree.
8
+ //
9
+ // Counterfactual built in: the routine exists ONLY under the synthetic instance root, so against
10
+ // the pre-2138 dispatcher (TASKS_DIR hardcoded to __dirname/../..) run-routine would look in THIS
11
+ // repo, not find `demo`, and exit 2 — reddening this test. And the companion asserts its core lib
12
+ // resolved from node_modules/@bongos/core (not the instance root), so a single-root resolve fails it.
13
+ //
14
+ // DB-free, no model. Run: node tests/run_routine_two_root.mjs
15
+
16
+ import { strict as assert } from 'node:assert';
17
+ import { execFileSync } from 'node:child_process';
18
+ import fs from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
+ const REPO = path.resolve(__dirname, '..'); // the core root (this repo), fixed by resolveCoreRoot()
25
+ const RUN_ROUTINE = path.join(REPO, 'scripts', 'gds', 'run-routine.js');
26
+ const CORE_RESOLVE = path.join(REPO, '.claude', 'hooks', '_core-resolve.js');
27
+
28
+ let passed = 0, failed = 0;
29
+ function test(name, fn) {
30
+ try { fn(); console.log(` ok ${name}`); passed++; }
31
+ catch (err) { console.error(` FAIL ${name}`); console.error(' ', err.message); failed++; }
32
+ }
33
+
34
+ // Build a synthetic CONSUMER tree: instance content (.claude/) at its OWN root; the core installed
35
+ // under node_modules/@bongos/core carrying just the one lib the companion needs.
36
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'rr-two-root-'));
37
+ const inst = path.join(tmp, 'instance');
38
+ try {
39
+ const coreLibDir = path.join(inst, 'node_modules', '@bongos', 'core', 'scripts', 'gds');
40
+ fs.mkdirSync(coreLibDir, { recursive: true });
41
+ fs.writeFileSync(path.join(coreLibDir, 'fake-core-lib.js'), 'module.exports = { ping: () => "CORE_OK" };\n');
42
+
43
+ // the materialized _core-resolve.js sibling (the real one — its resolution is what we exercise)
44
+ const hooksDir = path.join(inst, '.claude', 'hooks');
45
+ fs.mkdirSync(hooksDir, { recursive: true });
46
+ fs.copyFileSync(CORE_RESOLVE, path.join(hooksDir, '_core-resolve.js'));
47
+
48
+ // a materialized deterministic routine + its instance-path companion
49
+ const rtDir = path.join(inst, '.claude', 'scheduled-tasks', 'demo');
50
+ fs.mkdirSync(rtDir, { recursive: true });
51
+ fs.writeFileSync(path.join(rtDir, 'SKILL.md'),
52
+ ['---', 'name: demo', 'mode: deterministic', 'steps:',
53
+ ' - command: node .claude/scheduled-tasks/demo/run.js', ' sink: none', '---', 'body', ''].join('\n'));
54
+ fs.writeFileSync(path.join(rtDir, 'run.js'),
55
+ "const { requireCore, coreModulePath } = require('../../hooks/_core-resolve.js');\n" +
56
+ "const { ping } = requireCore('scripts/gds/fake-core-lib.js');\n" +
57
+ "console.log('RESOLVED=' + coreModulePath('scripts/gds/fake-core-lib.js'));\n" +
58
+ "console.log(ping());\n");
59
+
60
+ test('dispatch finds the routine under resolveInstanceRoot() and runs it; companion resolves core from node_modules', () => {
61
+ const out = execFileSync(process.execPath, [RUN_ROUTINE, 'demo'], {
62
+ encoding: 'utf8',
63
+ // Diverge the roots: OTB_INSTANCE_ROOT points resolveInstanceRoot() at the synthetic tree
64
+ // (OTB_ is an accepted legacy prefix; ADR 0108 §1); resolveCoreRoot() stays this repo.
65
+ // CLAUDE_PROJECT_DIR pins _core-resolve's own instance root to the synthetic tree too.
66
+ env: { ...process.env, OTB_INSTANCE_ROOT: inst, CLAUDE_PROJECT_DIR: inst },
67
+ });
68
+ assert.match(out, /CORE_OK/, 'companion step should have run and printed its core lib output');
69
+ assert.match(out, /RESOLVED=.*node_modules[/\\]@bongos[/\\]core[/\\]scripts[/\\]gds[/\\]fake-core-lib\.js/,
70
+ 'the core lib must resolve from node_modules/@bongos/core, proving two-root (not single-root) resolution');
71
+ });
72
+ } finally {
73
+ fs.rmSync(tmp, { recursive: true, force: true });
74
+ }
75
+
76
+ console.log(`\nrun_routine_two_root: ${passed} passed, ${failed} failed`);
77
+ process.exit(failed ? 1 : 0);
@@ -242,6 +242,23 @@ test('collectEnvSecrets: gathers secret-shaped env VALUES, skips benign & short'
242
242
  assert.ok(!out.includes(env.MAC_INSTALLER_CERT_P12) && !out.includes(env.APPLE_API_ISSUER_ID), 'env literals scrubbed');
243
243
  });
244
244
 
245
+ test('collectEnvSecrets: benign shell PWD/OLDPWD are skipped, real *_PWD password vars still collected (task 2130)', () => {
246
+ // Regression: PWD/OLDPWD contain "pwd" (the password alternative in ENV_SECRET_KEY_RE)
247
+ // and hold a directory PATH ≥ 8 chars, so pre-fix they were collected as "secrets" — then
248
+ // the package-core no-leak gate aborted a legit publish whenever that path appeared verbatim
249
+ // in a functional file (a /Users/<name> anti-pattern comment), passing or failing purely on
250
+ // the operator's last `cd`. Exact-match exclusion, so genuine password vars still match.
251
+ const env = {
252
+ PWD: '/Users/larskristensen/dev/Claude-OTB',
253
+ OLDPWD: '/Users/larskristensen',
254
+ MYSQL_PWD: randStr(20, 40, B64URL), // a real password var — MUST still be collected
255
+ };
256
+ const got = collectEnvSecrets(env);
257
+ assert.ok(!got.includes(env.PWD), 'PWD (a directory path) must not be collected');
258
+ assert.ok(!got.includes(env.OLDPWD), 'OLDPWD (a directory path) must not be collected');
259
+ assert.ok(got.includes(env.MYSQL_PWD), 'MYSQL_PWD (a real password var) must still be collected');
260
+ });
261
+
245
262
  test('self-reference `key = key` (SQL SET col = col) survives — zero recall loss', () => {
246
263
  // task 1688: the core packager redacts live SQL, so a `_token` COLUMN self-increment
247
264
  // must NOT be corrupted — while a real `key = <secret>` assignment is STILL redacted.
@@ -0,0 +1,186 @@
1
+ // tests/update_channel.mjs
2
+ //
3
+ // task 2150 / ADR 0136 — the update-subscription policy core (scripts/gds/update-channel.js).
4
+ // Pins the channel math + manifest loading the `core-update-subscription` routine relies on:
5
+ // 1. semver — parse (incl. prerelease), compare precedence.
6
+ // 2. channelAllows — the pinned/patch/minor blast-radius matrix; majors + prereleases never auto.
7
+ // 3. resolveChannelTarget — highest permitted target, or null (pinned / nothing newer / unknown).
8
+ // 4. parseNpmViewVersions — the array / bare-string / empty shapes npm view emits.
9
+ // 5. listAvailableVersions — ok + fail-soft with an injected runner.
10
+ // 6. loadSubscriptions — missing/malformed → empty; channel default + validate; env kept; dropless.
11
+ //
12
+ // DB-free + network-free (every external is injected). Run: node tests/update_channel.mjs
13
+
14
+ import { strict as assert } from 'node:assert';
15
+ import { createRequire } from 'node:module';
16
+ import { mkdtempSync, writeFileSync } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+
20
+ const require = createRequire(import.meta.url);
21
+ const uc = require('../scripts/gds/update-channel.js');
22
+
23
+ let passed = 0, failed = 0;
24
+ function t(name, fn) {
25
+ try { fn(); passed++; console.log(` PASS ${name}`); }
26
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
27
+ }
28
+
29
+ // ---- semver ----------------------------------------------------------------
30
+
31
+ t('parseSemver: plain, v-prefixed, prerelease, junk', () => {
32
+ assert.deepEqual(uc.parseSemver('1.17.2'), { major: 1, minor: 17, patch: 2, pre: '' });
33
+ assert.deepEqual(uc.parseSemver('v2.0.0'), { major: 2, minor: 0, patch: 0, pre: '' });
34
+ assert.deepEqual(uc.parseSemver('1.17.2-rc.1'), { major: 1, minor: 17, patch: 2, pre: 'rc.1' });
35
+ assert.equal(uc.parseSemver('1.17'), null);
36
+ assert.equal(uc.parseSemver('latest'), null);
37
+ assert.equal(uc.parseSemver(''), null);
38
+ });
39
+
40
+ t('compareSemver: numeric precedence + prerelease below stable', () => {
41
+ assert.equal(uc.compareSemver('1.17.2', '1.17.1'), 1);
42
+ assert.equal(uc.compareSemver('1.17.1', '1.18.0'), -1);
43
+ assert.equal(uc.compareSemver('1.17.1', '2.0.0'), -1);
44
+ assert.equal(uc.compareSemver('1.17.2', '1.17.2'), 0);
45
+ assert.equal(uc.compareSemver('1.17.2-rc.1', '1.17.2'), -1); // prerelease < stable
46
+ assert.equal(uc.compareSemver('bogus', '1.0.0'), -1); // unparseable sorts lowest
47
+ });
48
+
49
+ t('normalizeChannel: valid pass through, unknown/blank → patch default', () => {
50
+ assert.equal(uc.normalizeChannel('pinned'), 'pinned');
51
+ assert.equal(uc.normalizeChannel('MINOR'), 'minor');
52
+ assert.equal(uc.normalizeChannel('major'), 'patch'); // major is not a valid auto-channel
53
+ assert.equal(uc.normalizeChannel(''), 'patch');
54
+ assert.equal(uc.normalizeChannel(undefined), 'patch');
55
+ });
56
+
57
+ // ---- channelAllows ---------------------------------------------------------
58
+
59
+ t('channelAllows: patch stays within x.y', () => {
60
+ assert.equal(uc.channelAllows('1.17.1', '1.17.2', 'patch'), true);
61
+ assert.equal(uc.channelAllows('1.17.1', '1.17.10', 'patch'), true);
62
+ assert.equal(uc.channelAllows('1.17.1', '1.18.0', 'patch'), false); // minor bump not allowed on patch
63
+ assert.equal(uc.channelAllows('1.17.1', '2.0.0', 'patch'), false);
64
+ });
65
+
66
+ t('channelAllows: minor allows higher minor within the major, not a major', () => {
67
+ assert.equal(uc.channelAllows('1.17.1', '1.18.0', 'minor'), true);
68
+ assert.equal(uc.channelAllows('1.17.1', '1.17.5', 'minor'), true);
69
+ assert.equal(uc.channelAllows('1.17.1', '2.0.0', 'minor'), false); // major never automatic
70
+ });
71
+
72
+ t('channelAllows: pinned never; not-newer never; prerelease never; unknown installed never', () => {
73
+ assert.equal(uc.channelAllows('1.17.1', '1.99.99', 'pinned'), false);
74
+ assert.equal(uc.channelAllows('1.17.2', '1.17.1', 'patch'), false); // older
75
+ assert.equal(uc.channelAllows('1.17.1', '1.17.1', 'patch'), false); // same
76
+ assert.equal(uc.channelAllows('1.17.1', '1.17.2-rc.1', 'patch'), false); // prerelease target
77
+ assert.equal(uc.channelAllows(null, '1.17.2', 'patch'), false); // unknown installed
78
+ assert.equal(uc.channelAllows('1.17.1', 'garbage', 'patch'), false);
79
+ });
80
+
81
+ // ---- resolveChannelTarget --------------------------------------------------
82
+
83
+ const AVAIL = ['1.16.9', '1.17.0', '1.17.1', '1.17.2', '1.17.3', '1.18.0', '1.18.1', '2.0.0', '1.17.4-rc.1'];
84
+
85
+ t('resolveChannelTarget: patch → highest patch on the same minor', () => {
86
+ assert.equal(uc.resolveChannelTarget({ installed: '1.17.1', channel: 'patch', available: AVAIL }), '1.17.3');
87
+ });
88
+
89
+ t('resolveChannelTarget: minor → highest minor within the major (not 2.0.0)', () => {
90
+ assert.equal(uc.resolveChannelTarget({ installed: '1.17.1', channel: 'minor', available: AVAIL }), '1.18.1');
91
+ });
92
+
93
+ t('resolveChannelTarget: pinned → null', () => {
94
+ assert.equal(uc.resolveChannelTarget({ installed: '1.17.1', channel: 'pinned', available: AVAIL }), null);
95
+ });
96
+
97
+ t('resolveChannelTarget: already newest → null; prerelease never chosen', () => {
98
+ assert.equal(uc.resolveChannelTarget({ installed: '1.17.3', channel: 'patch', available: AVAIL }), null);
99
+ // A prerelease ahead (1.17.4-rc.1) is NOT a target even though it's numerically newer.
100
+ assert.equal(uc.resolveChannelTarget({ installed: '1.17.3', channel: 'minor', available: ['1.17.4-rc.1'] }), null);
101
+ });
102
+
103
+ t('resolveChannelTarget: unknown installed / empty list → null (never guesses)', () => {
104
+ assert.equal(uc.resolveChannelTarget({ installed: null, channel: 'patch', available: AVAIL }), null);
105
+ assert.equal(uc.resolveChannelTarget({ installed: '1.17.1', channel: 'patch', available: [] }), null);
106
+ assert.equal(uc.resolveChannelTarget({}), null);
107
+ });
108
+
109
+ // ---- parseNpmViewVersions --------------------------------------------------
110
+
111
+ t('parseNpmViewVersions: array, bare string, empty, malformed', () => {
112
+ assert.deepEqual(uc.parseNpmViewVersions('["1.0.0","1.1.0"]'), { ok: true, versions: ['1.0.0', '1.1.0'] });
113
+ assert.deepEqual(uc.parseNpmViewVersions('"1.0.0"'), { ok: true, versions: ['1.0.0'] });
114
+ assert.deepEqual(uc.parseNpmViewVersions(' '), { ok: true, versions: [] });
115
+ const bad = uc.parseNpmViewVersions('not json');
116
+ assert.equal(bad.ok, false);
117
+ });
118
+
119
+ // ---- listAvailableVersions (injected runner) -------------------------------
120
+
121
+ t('listAvailableVersions: ok path parses stdout; non-zero exit is fail-soft', () => {
122
+ const ok = uc.listAvailableVersions({ run: () => ({ status: 0, stdout: '["1.17.1","1.17.2"]' }) });
123
+ assert.deepEqual(ok, { ok: true, versions: ['1.17.1', '1.17.2'] });
124
+
125
+ const fail = uc.listAvailableVersions({ run: () => ({ status: 1, stderr: 'E404 Not Found' }) });
126
+ assert.equal(fail.ok, false);
127
+ assert.match(fail.error, /E404/);
128
+
129
+ const threw = uc.listAvailableVersions({ run: () => { throw new Error('spawn ENOENT'); } });
130
+ assert.equal(threw.ok, false);
131
+ assert.match(threw.error, /ENOENT/);
132
+ });
133
+
134
+ // ---- loadSubscriptions -----------------------------------------------------
135
+
136
+ function scratchWith(obj) {
137
+ const dir = mkdtempSync(join(tmpdir(), 'otb-subs-'));
138
+ const cfgDir = join(dir, 'config');
139
+ require('node:fs').mkdirSync(cfgDir, { recursive: true });
140
+ writeFileSync(join(cfgDir, 'update-subscriptions.json'), typeof obj === 'string' ? obj : JSON.stringify(obj));
141
+ return dir;
142
+ }
143
+
144
+ t('loadSubscriptions: missing file → empty roster, present:false, no throw', () => {
145
+ const dir = mkdtempSync(join(tmpdir(), 'otb-subs-none-'));
146
+ const r = uc.loadSubscriptions({ instanceDir: dir });
147
+ assert.deepEqual(r.instances, []);
148
+ assert.equal(r.present, false);
149
+ assert.equal(r.defaultChannel, 'patch');
150
+ });
151
+
152
+ t('loadSubscriptions: malformed JSON → empty + error, no throw', () => {
153
+ const dir = scratchWith('{ not valid');
154
+ const r = uc.loadSubscriptions({ instanceDir: dir });
155
+ assert.deepEqual(r.instances, []);
156
+ assert.match(r.error, /malformed JSON/);
157
+ });
158
+
159
+ t('loadSubscriptions: normalizes channel, keeps env, defaults from defaultChannel', () => {
160
+ const dir = scratchWith({
161
+ defaultChannel: 'minor',
162
+ instances: [
163
+ { slug: 'demo', dir: '/srv/demo', service: 'demo.service', healthUrl: 'https://demo/healthz', env: { PGDATABASE: 'demo', BAD: 5 } },
164
+ { slug: 'pinnedone', dir: '/srv/p', channel: 'PINNED' },
165
+ { slug: 'nochan', dir: '/srv/n' },
166
+ ],
167
+ });
168
+ const r = uc.loadSubscriptions({ instanceDir: dir });
169
+ assert.equal(r.defaultChannel, 'minor');
170
+ assert.equal(r.instances.length, 3);
171
+ const demo = r.instances.find((i) => i.slug === 'demo');
172
+ assert.equal(demo.channel, 'minor'); // inherited from defaultChannel
173
+ assert.deepEqual(demo.env, { PGDATABASE: 'demo' }); // non-string env value dropped
174
+ assert.equal(r.instances.find((i) => i.slug === 'pinnedone').channel, 'pinned');
175
+ assert.equal(r.instances.find((i) => i.slug === 'nochan').channel, 'minor');
176
+ });
177
+
178
+ t('loadSubscriptions: an entry without a dir is dropped (cannot be upgraded)', () => {
179
+ const dir = scratchWith({ instances: [{ slug: 'ghost' }, { slug: 'real', dir: '/srv/real' }] });
180
+ const r = uc.loadSubscriptions({ instanceDir: dir });
181
+ assert.equal(r.instances.length, 1);
182
+ assert.equal(r.instances[0].slug, 'real');
183
+ });
184
+
185
+ console.log(`\nupdate_channel: ${passed} passed, ${failed} failed`);
186
+ process.exit(failed ? 1 : 0);
package/tests/upgrade.mjs CHANGED
@@ -416,6 +416,137 @@ t('runUpgrade --registry (full): pins the exact version + writes the env-fed .np
416
416
  assert.ok(!calls.some((c) => /vendor/.test(c)), 'did not vendor a tarball');
417
417
  });
418
418
 
419
+ // ---- 9. auto-rollback on failure (task 2149) --------------------------------
420
+
421
+ // A pin-aware spawn shim: `npm install` installs whatever the CURRENT pin resolves to, so the
422
+ // forward install lands the target and a post-rollback install restores the previous core.
423
+ // Knobs simulate a failing target install or a failing migrate.
424
+ function pinAwareRun(calls, { dir, failMigrate = false, failInstallForVersion = null } = {}) {
425
+ return (cmd, args) => {
426
+ calls.push(`${cmd} ${(args || []).join(' ')}`.trim());
427
+ if (cmd === 'git' && args[0] === 'status') return { status: 0, stdout: '' };
428
+ if (cmd === 'npm' && args[0] === 'install') {
429
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
430
+ const dep = pkg.dependencies['@bongos/core'];
431
+ const v = u.versionFromTgzPath(dep) || u.versionFromDep(dep);
432
+ if (failInstallForVersion && v === failInstallForVersion) return { status: 1 };
433
+ writeFileSync(join(dir, 'node_modules', '@bongos', 'core', 'package.json'),
434
+ JSON.stringify({ name: '@bongos/core', version: v }) + '\n');
435
+ return { status: 0 };
436
+ }
437
+ if (cmd === 'npm' && args[0] === 'run' && args[1] === 'migrate') return { status: failMigrate ? 1 : 0 };
438
+ return { status: 0 }; // systemctl / restart-cmd / anything else
439
+ };
440
+ }
441
+
442
+ // A fake pg that records every ledger INSERT's params (params[3] = note).
443
+ function recordingPg(queries) {
444
+ return { Pool: class { async query(sql, params) { queries.push({ sql, params }); } async end() {} } };
445
+ }
446
+
447
+ t('rollback on health failure: reverts pin, restarts again, re-checks health, writes ONE rolled_back ledger row', async () => {
448
+ const src = mkdtempSync(join(tmpdir(), 'src-'));
449
+ writeFileSync(join(src, 'bongos-core-1.15.0.tgz'), 'TGZ');
450
+ const dir = scratchConsumer({ pinned: '1.14.1', installed: '1.14.1' });
451
+ const calls = [], queries = [];
452
+ let fetchN = 0;
453
+ const res = await u.runUpgrade(
454
+ { to: '1.15.0', from: join(src, 'bongos-core-1.15.0.tgz'), instance: dir, service: 'demo.service', healthUrl: 'http://x/healthz' },
455
+ {
456
+ log: () => {}, err: () => {},
457
+ run: pinAwareRun(calls, { dir }),
458
+ materialize: () => ({ skills: 1 }), regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
459
+ databaseUrl: 'postgres://x/y', pg: recordingPg(queries),
460
+ // forward health fails (1st fetch); the post-rollback re-check recovers (2nd fetch)
461
+ fetch: async () => { fetchN++; return fetchN === 1 ? { ok: false, status: 503 } : { ok: true, status: 200 }; },
462
+ sleep: async () => {}, now: () => 0, healthBudgetMs: 0,
463
+ }
464
+ );
465
+ assert.equal(res.ok, false, 'a failed health check is not a success');
466
+ assert.equal(res.rolledBack, true, 'the upgrade rolled back');
467
+ assert.equal(u.readPinnedCoreVersion(dir), '1.14.1', 'pin reverted to the previous core');
468
+ assert.equal(u.readInstalledCoreVersion(dir), '1.14.1', 'previous core reinstalled');
469
+ assert.equal(res.rollback.healthOk, true, 'health recovered after the rollback restart');
470
+ assert.equal(calls.filter((c) => c === 'systemctl restart demo.service').length, 2, 'restarted twice: forward + rollback');
471
+ assert.equal(queries.length, 1, 'only the rolled_back row is written (no phantom upgraded row)');
472
+ assert.equal(queries[0].params[1], '1.15.0', 'to_version = the attempted target');
473
+ assert.match(queries[0].params[3], /^rolled_back:/, 'note marks the reversal');
474
+ });
475
+
476
+ t('rollback on migrate failure: reverts pin + reinstalls previous core + rolled_back ledger', async () => {
477
+ const src = mkdtempSync(join(tmpdir(), 'src-'));
478
+ writeFileSync(join(src, 'bongos-core-1.15.0.tgz'), 'TGZ');
479
+ const dir = scratchConsumer({ pinned: '1.14.1', installed: '1.14.1' });
480
+ const calls = [], queries = [];
481
+ const res = await u.runUpgrade(
482
+ { to: '1.15.0', from: join(src, 'bongos-core-1.15.0.tgz'), instance: dir, service: 'demo.service' },
483
+ {
484
+ log: () => {}, err: () => {},
485
+ run: pinAwareRun(calls, { dir, failMigrate: true }),
486
+ materialize: () => ({ skills: 1 }), regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
487
+ databaseUrl: 'postgres://x/y', pg: recordingPg(queries),
488
+ }
489
+ );
490
+ assert.equal(res.ok, false);
491
+ assert.equal(res.rolledBack, true);
492
+ assert.match(res.rollback.reason, /migrate failed/);
493
+ assert.equal(u.readPinnedCoreVersion(dir), '1.14.1', 'pin reverted');
494
+ assert.equal(u.readInstalledCoreVersion(dir), '1.14.1', 'previous core reinstalled');
495
+ assert.equal(queries.length, 1);
496
+ assert.match(queries[0].params[3], /^rolled_back: migrate failed/);
497
+ });
498
+
499
+ t('rollback on npm-install failure: reverts pin + reinstalls previous core', async () => {
500
+ const src = mkdtempSync(join(tmpdir(), 'src-'));
501
+ writeFileSync(join(src, 'bongos-core-1.15.0.tgz'), 'TGZ');
502
+ const dir = scratchConsumer({ pinned: '1.14.1', installed: '1.14.1' });
503
+ const calls = [], queries = [];
504
+ const res = await u.runUpgrade(
505
+ { to: '1.15.0', from: join(src, 'bongos-core-1.15.0.tgz'), instance: dir, service: 'demo.service' },
506
+ {
507
+ log: () => {}, err: () => {},
508
+ run: pinAwareRun(calls, { dir, failInstallForVersion: '1.15.0' }),
509
+ materialize: () => ({ skills: 1 }), regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
510
+ databaseUrl: 'postgres://x/y', pg: recordingPg(queries),
511
+ }
512
+ );
513
+ assert.equal(res.ok, false);
514
+ assert.equal(res.rolledBack, true);
515
+ assert.equal(u.readPinnedCoreVersion(dir), '1.14.1', 'pin reverted after the failed install');
516
+ assert.equal(u.readInstalledCoreVersion(dir), '1.14.1', 'previous core reinstalled');
517
+ assert.match(queries[0].params[3], /^rolled_back: npm install failed/);
518
+ });
519
+
520
+ t('--no-rollback-on-failure: a failed health check does NOT revert (records the attempt, leaves the new pin)', async () => {
521
+ const src = mkdtempSync(join(tmpdir(), 'src-'));
522
+ writeFileSync(join(src, 'bongos-core-1.15.0.tgz'), 'TGZ');
523
+ const dir = scratchConsumer({ pinned: '1.14.1', installed: '1.14.1' });
524
+ const queries = [];
525
+ const res = await u.runUpgrade(
526
+ { to: '1.15.0', from: join(src, 'bongos-core-1.15.0.tgz'), instance: dir, service: 'demo.service', healthUrl: 'http://x/healthz', rollbackOnFailure: false },
527
+ {
528
+ log: () => {}, err: () => {},
529
+ run: pinAwareRun([], { dir }),
530
+ materialize: () => ({ skills: 1 }), regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
531
+ databaseUrl: 'postgres://x/y', pg: recordingPg(queries),
532
+ fetch: async () => ({ ok: false, status: 503 }), sleep: async () => {}, now: () => 0, healthBudgetMs: 0,
533
+ }
534
+ );
535
+ assert.equal(res.ok, false);
536
+ assert.ok(!res.rolledBack, 'no rollback when opted out');
537
+ assert.equal(u.readPinnedCoreVersion(dir), '1.15.0', 'the new pin is left in place for inspection');
538
+ assert.equal(queries.length, 1, 'the attempt is still recorded');
539
+ assert.ok(!/^rolled_back/.test(queries[0].params[3] || ''), 'not marked as a rollback');
540
+ });
541
+
542
+ t('restorePin: writes a previous dep string back verbatim (file: or registry)', () => {
543
+ const dir = scratchConsumer({ pinned: '1.15.0' });
544
+ u.restorePin(dir, 'file:vendor/bongos-core-1.14.1.tgz');
545
+ assert.equal(u.readPinnedCoreVersion(dir), '1.14.1');
546
+ u.restorePin(dir, '^1.16.0');
547
+ assert.equal(JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).dependencies['@bongos/core'], '^1.16.0');
548
+ });
549
+
419
550
  await Promise.all(pending);
420
551
  console.log(`\nupgrade: ${passed} passed, ${failed} failed`);
421
552
  process.exit(failed ? 1 : 0);
@@ -0,0 +1,98 @@
1
+ // tests/workflow_exec_paths.mjs — task 2136 (ADR 0108 configurable-root).
2
+ //
3
+ // Proves every .github/workflows/*.yml invocation of a CORE script resolves in a consumer layout
4
+ // after the R89 split: each is routed through the both-layouts resolver (infra/core-node.sh for
5
+ // node targets, infra/core-path.sh for bash targets) AND names a PUBLISHABLE core file that ships
6
+ // in @bongos/core (→ present at node_modules/@bongos/core post-install) and exists at the core root
7
+ // today. No workflow still invokes a core script the bare repo-relative way. Also proves
8
+ // core-path.sh resolves both layouts (the bash-target sibling of core-node.sh).
9
+ //
10
+ // DB-free, no network. Run: node tests/workflow_exec_paths.mjs
11
+
12
+ import { strict as assert } from 'node:assert';
13
+ import { execFileSync } from 'node:child_process';
14
+ import { createRequire } from 'node:module';
15
+ import fs from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ const require = createRequire(import.meta.url);
21
+ const { lintWorkflowExec } = require('../scripts/gds/lint-workflow-exec.js');
22
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
+ const REPO = path.resolve(__dirname, '..');
24
+ const CORE_PATH_SH = path.join(REPO, 'infra', 'core-path.sh');
25
+
26
+ let passed = 0, failed = 0;
27
+ function test(name, fn) {
28
+ try { fn(); console.log(` ok ${name}`); passed++; }
29
+ catch (err) { console.error(` FAIL ${name}`); console.error(' ', err.message); failed++; }
30
+ }
31
+
32
+ // ---- A. the workflow lint is green on the real tree ---------------------------------------------
33
+ test('lint-workflow-exec: every core invocation is routed + publishable (no bare repo-relative form)', () => {
34
+ const r = lintWorkflowExec({ instanceRoot: REPO, coreRoot: REPO });
35
+ assert.deepEqual(r.violations, [], `expected no violations, got: ${JSON.stringify(r.violations, null, 2)}`);
36
+ assert.ok(r.ok, 'lint should pass');
37
+ // The security-critical merge gates + the functional gates must all be covered.
38
+ const rels = r.checked.map((c) => `${c.file}:${c.rel}`);
39
+ assert.ok(rels.some((s) => s.startsWith('gate-review.yml:') && s.endsWith('scripts/gds/gate-review.js')), 'gate-review routed');
40
+ assert.ok(rels.some((s) => s.startsWith('grade-gate.yml:') && s.endsWith('scripts/gds/ci-grade.js')), 'grade-gate routed');
41
+ assert.ok(rels.some((s) => s.endsWith('scripts/migrate.sh')), 'selfhost migrate routed via core-path.sh');
42
+ });
43
+
44
+ test('lint-workflow-exec: a bare core invocation in a workflow is flagged (counterfactual)', () => {
45
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wf-lint-'));
46
+ try {
47
+ fs.mkdirSync(path.join(tmp, '.github', 'workflows'), { recursive: true });
48
+ fs.mkdirSync(path.join(tmp, 'scripts', 'gds'), { recursive: true });
49
+ fs.writeFileSync(path.join(tmp, 'scripts', 'gds', 'gate-review.js'), '// probe\n');
50
+ fs.writeFileSync(path.join(tmp, '.github', 'workflows', 'legacy.yml'),
51
+ 'jobs:\n x:\n steps:\n - run: node scripts/gds/gate-review.js\n');
52
+ const r = lintWorkflowExec({ instanceRoot: tmp, coreRoot: tmp });
53
+ assert.ok(!r.ok, 'a bare core invocation must fail the lint');
54
+ assert.equal(r.violations.length, 1);
55
+ assert.match(r.violations[0].why, /bare core invocation/);
56
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
57
+ });
58
+
59
+ // ---- B. core-path.sh resolves BOTH layouts ------------------------------------------------------
60
+ function runCorePath(rel, instanceRoot) {
61
+ return execFileSync('bash', [CORE_PATH_SH, rel], {
62
+ encoding: 'utf8', env: { ...process.env, BONGOS_INSTANCE_ROOT: instanceRoot },
63
+ }).trim();
64
+ }
65
+
66
+ test('core-path.sh prints the monolith path (script at the instance root)', () => {
67
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cp-mono-'));
68
+ try {
69
+ fs.mkdirSync(path.join(tmp, 'scripts'), { recursive: true });
70
+ fs.writeFileSync(path.join(tmp, 'scripts', 'migrate.sh'), '# probe\n');
71
+ assert.equal(runCorePath('scripts/migrate.sh', tmp), path.join(tmp, 'scripts', 'migrate.sh'));
72
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
73
+ });
74
+
75
+ test('core-path.sh falls back to the consumer path (script only under node_modules/@bongos/core)', () => {
76
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cp-cons-'));
77
+ try {
78
+ const pkg = path.join(tmp, 'node_modules', '@bongos', 'core', 'scripts');
79
+ fs.mkdirSync(pkg, { recursive: true });
80
+ fs.writeFileSync(path.join(pkg, 'migrate.sh'), '# probe\n');
81
+ assert.equal(runCorePath('scripts/migrate.sh', tmp), path.join(pkg, 'migrate.sh'));
82
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
83
+ });
84
+
85
+ test('core-path.sh exits non-zero (no stdout) when the script is in NO layout', () => {
86
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cp-none-'));
87
+ let threw = false;
88
+ try {
89
+ execFileSync('bash', [CORE_PATH_SH, 'scripts/nope.sh'], {
90
+ encoding: 'utf8', stdio: 'pipe', env: { ...process.env, BONGOS_INSTANCE_ROOT: tmp },
91
+ });
92
+ } catch (_) { threw = true; }
93
+ finally { fs.rmSync(tmp, { recursive: true, force: true }); }
94
+ assert.ok(threw, 'expected a non-zero exit when the script resolves in no layout');
95
+ });
96
+
97
+ console.log(`\nworkflow_exec_paths: ${passed} passed, ${failed} failed`);
98
+ process.exit(failed ? 1 : 0);
@@ -1,49 +0,0 @@
1
- # Container building — exterior tile spec (pixel-precise, V2)
2
-
3
- - **Task:** [#643](https://example.com/builders#/task/643) (V2 · product). Promoted from idea_inbox [#162](https://example.com/builders#/idea/162) (builder 25 art/design brief, 2026-06-04).
4
- - **Date:** 2026-06-18
5
- - **Builds on:** [`container-exterior.md`](container-exterior.md) (the structure-level design, task [#641](https://example.com/builders#/task/641)) — this is the **tile-level** layer it calls out. Colour is governed by the locked decisions below.
6
- - **Status:** Spec ready. **Generation waits for [#640](https://example.com/builders#/task/640) / [ADR 0044](../adr/0044-mediterranean-palette-replacement.md)** (every tile here re-quantizes against the replacement palette — per `container-exterior.md` §Pipeline). Spec work proceeds now.
7
-
8
- ## ⚠️ Colour: the original brief is superseded — weathered charcoal, not clean red
9
-
10
- idea [#162](https://example.com/builders#/idea/162)'s brief (this task's source) asked for **"saturated industrial red (or black) — clean, no weathering, no rust."** That was written **before** Lars resolved the question. The authoritative decision (later wins):
11
-
12
- - **Blocker [#7](https://example.com/builders#/blocker/7) (Lars, 2026-06-10, now a locked `style_guide.md` rule):** the container body is **weathered BLACK/CHARCOAL corrugated metal with rust streaks** — *not* red, *not* teal. Faded **Hermes-teal is an ACCENT only** (the door panel + a faint caduceus etch). It must read as an *ancient, heavy, discovered relic*, not factory-new cargo.
13
- - **`style_guide.md` (Lars, 2026-06-14):** **no white / light edge rim** on container (or other large-object) tiles — strip near-white edge pixels in post ("the white on the container actually sucks").
14
- - **`style_guide.md`:** non-ground objects carry a **1-px darker outline** against their interior (FireRed rock/tree convention); ground tiles do not.
15
-
16
- So every tile below is **weathered charcoal corrugated metal with rust streaks**; the brief's "clean red, no weathering, single-pixel white highlight" wording does **not** apply. The brief's *geometry* (corrugation, glass panels, deck, roof, staircase) is still the source — only the colour/finish is corrected.
17
-
18
- ## Per-tile pixel specs (32×32 base)
19
-
20
- ### Container wall tile
21
- - **Base:** weathered charcoal/gunmetal (a matte near-black with a faint blue-grey undertone), drawn from the replacement palette's charcoal/grey value steps (ADR 0044 guarantees enough steps).
22
- - **Corrugation:** 4–5 vertical ridges per tile. Each ridge: 1px lighter charcoal on the left (highlight), 1px darker on the right (shadow); flat panels between are the base.
23
- - **Weathering:** sparse vertical **rust streaks** (a desaturated orange-brown from the palette) bleeding downward from ridge tops + seams — irregular, 1–2px wide, not on every ridge. Reads as age, not damage.
24
- - **Edges:** 1px darker-charcoal **outline** on the object silhouette (per the style rule). Top edge 2px darker (top face in oblique light); bottom edge 1px near-black ground shadow. **No white/light rim** anywhere on the silhouette.
25
- - **Stacked-unit seam:** a 2px dark recessed line with a 1px shadow below, where the ground-floor container meets the second storey.
26
-
27
- ### Glass storefront / window panel (32×32, or 64×32 wide)
28
- - **Fill:** semi-transparent blue-grey (≈40–50% over what's behind). Through dark glass, a faint **warm amber interior glow** (the "dark exterior, warm interior" mood from `container-exterior.md`).
29
- - **Frame:** 3px matte-charcoal border (the frames dissolve into the dark facade rather than reading as separate elements).
30
- - **Interior hint:** extremely faint 1–2-shade-darker silhouettes (a plant, shelving) — not detailed.
31
- - **Reflection highlight — FLAG for Lars:** the brief wanted a white upper-left reflection strip; the no-white-rim rule targets the *object silhouette*, not interior glass. Proposed: a restrained **pale-blue** (not white) 1px upper-left reflection inside the frame. If Lars wants zero light pixels here, drop it. *(Open pixel-detail decision.)*
32
-
33
- ### Door tile (the one Hermes-teal accent)
34
- - Charcoal frame; the door panel is **faded Hermes-teal** (the single body-color exception), with a **faint caduceus etch** (1–2px lighter teal lines) where space allows, and a warm interior glow at the threshold. This is the only teal on the building.
35
-
36
- ### Deck tile (wood apron, walkable)
37
- - Horizontal planks: light warm tan base, 4–5 planks/tile, 1px dark gap between; very subtle 1–2px grain lines; 2px darker far-edge shadow. Drawn as an alpha-margined object overlay over sand (the `marble_path` technique, [ADR 0013](../adr/<redacted>.md)) so it works on any ground.
38
-
39
- ### Roof tile (top-down)
40
- - Flat charcoal surface, 1 shade darker than the wall face (less direct light); small 3×3 dark **container corner castings** at each corner; a faint 2px panel seam every 8px; 1px railing line on edge tiles (per `container-exterior.md` rooftop band).
41
-
42
- ### Exterior staircase (object overlay)
43
- - Dark charcoal steel: two parallel rails with treads between (top-down ladder look); 1px railing line each outer edge, 1px lighter than the tread. Arrives at the rooftop stair-landing tile.
44
-
45
- ## Mood target
46
- A **designed object, not a found one** — presence from its *shape and glass*, not from bright paint. The charcoal recedes into the coastal scene; at dusk the warm interior light through dark glass is the most dramatic visual in the game. The contradiction (an *ancient* shipping container) lives in the weathering + the teal-and-caduceus door.
47
-
48
- ## Pipeline mapping (generate after [#640](https://example.com/builders#/task/640))
49
- Per [ADR 0008](../adr/<redacted>.md) (families, not per-tile): `container_building_facade` (wall · glass · door · light-fixture · staircase), `container_building_roof` (field · railing · landing), `deck_wood` (16-autotile alpha-tile line family). Furniture singletons (table, chairs) per-tile. **Nothing generates until the palette swap ([#640](https://example.com/builders#/task/640)/ADR 0044) lands** — else every tile re-quantizes against the old palette and is redone.