@bongos/core 1.21.150 → 1.21.152

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 (48) hide show
  1. package/.bongos-core.json +52 -52
  2. package/docs/copy-inventory.md +16 -16
  3. package/docs/copy-registry.json +16 -16
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/modules-contract.md +29 -0
  6. package/docs/page-readings.json +27 -26
  7. package/modules/agents/module.json +1 -0
  8. package/modules/autonomy/module.json +1 -0
  9. package/modules/builder-settings/module.json +1 -0
  10. package/modules/copy-desk/module.json +1 -0
  11. package/modules/design-styles/module.json +1 -0
  12. package/modules/discord/module.json +1 -0
  13. package/modules/economy/module.json +1 -0
  14. package/modules/government/module.json +1 -0
  15. package/modules/grading/module.json +1 -0
  16. package/modules/hall-ui/module.json +1 -0
  17. package/modules/hall-ui/public/modules.js +16 -4
  18. package/modules/ideas/module.json +1 -0
  19. package/modules/lifecycle/module.json +1 -0
  20. package/modules/memory/module.json +1 -0
  21. package/modules/npm-release/module.json +1 -0
  22. package/modules/onboarding/module.json +1 -0
  23. package/modules/page-notes/module.json +1 -0
  24. package/modules/pixel-art/module.json +1 -0
  25. package/modules/platform-identity/module.json +1 -0
  26. package/modules/provisioning/module.json +1 -0
  27. package/modules/public-landing/module.json +1 -0
  28. package/modules/render-deploy/module.json +1 -0
  29. package/modules/security/module.json +1 -0
  30. package/modules/sessions/module.json +1 -0
  31. package/modules/specialities/module.json +1 -0
  32. package/modules/status-ui/module.json +1 -0
  33. package/modules/ui-design/module.json +1 -0
  34. package/package-lock.json +2 -2
  35. package/package.json +1 -1
  36. package/release-notes.json +12 -0
  37. package/scripts/gds/credential-presence.js +10 -0
  38. package/scripts/gds/module-test-runner/README.md +3 -3
  39. package/scripts/gds/module-test-runner/module-tests.yml +18 -6
  40. package/scripts/gds/module-test-runner.js +92 -36
  41. package/scripts/gds/module.js +4 -2
  42. package/src/module-api.js +1 -1
  43. package/src/module-loader/catalog.js +15 -1
  44. package/src/module-loader/manifest-schema.js +40 -1
  45. package/tests/hall_catalog_world.mjs +4 -1
  46. package/tests/module_catalog.mjs +34 -0
  47. package/tests/module_manifest.mjs +36 -0
  48. package/tests/module_test_runner.mjs +70 -17
@@ -4,6 +4,7 @@
4
4
  "description": "The platform capability that lets any builder on any instance create and design good-looking, functional UIs in line with that instance's world (scoped with the owner 2026-08-28; task 1003324, ADR 0197). It ships METHOD and never a world: the neutral design contract of ADR 0081 with its tool adapters (Claude Design, Figma), their sync CLIs and the validator; the /design playbook for ui-discipline work; the look-before-you-ship kit (kit/) and the style library (styles/). The thirteen design-style skills that lived in skills/ are the opt-in design-styles module since task 1004470; /design reaches them when that module is on. The instance's world — its branding pack, config/design-tokens.*, config/design-sources.json and its DESIGN.md — stays in config/ and at the repo root as the host boundary this module reads and never owns (ADR 0062 §7). Cloud Bongos's own front door is this module's first customer, not its owner. default-on because every instance has a face. No routes, no migrations, no port: declaration-only like character-anim, so disabling it withdraws only the skills it contributes and touches no served surface.",
5
5
  "version": "1.0.0",
6
6
  "coreVersion": "^1.19.0",
7
+ "category": { "parent": "pages" },
7
8
  "default": true,
8
9
  "maintenance": { "status": "core-maintained" },
9
10
  "contributes": { "skills": ["design", "design-sync", "figma-design-sync"], "disciplines": ["ui"] },
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.150",
3
+ "version": "1.21.152",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.150",
9
+ "version": "1.21.152",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.150",
3
+ "version": "1.21.152",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -9914,5 +9914,17 @@
9914
9914
  "id": "1004760",
9915
9915
  "text": "When a project's GitHub App isn't installed, a refused ship now says so and links the one-click install, instead of a bare 'not proven'."
9916
9916
  }
9917
+ ],
9918
+ "1.21.151": [
9919
+ {
9920
+ "id": "1004739",
9921
+ "text": "The module test runner can now test against the private core: the server sends its own copy of the core into the private test repo, once per version."
9922
+ }
9923
+ ],
9924
+ "1.21.152": [
9925
+ {
9926
+ "id": "1003784",
9927
+ "text": "Every module now says which of the eight categories it belongs to, and the module list in the terminal and the hall's Modules tab both show it and let you search by it."
9928
+ }
9917
9929
  ]
9918
9930
  }
@@ -121,6 +121,16 @@ const CREDENTIALS = [
121
121
  names: ['METRICS_TOKEN'],
122
122
  purpose: 'bearer token for a remote Prometheus scrape of /metrics (src/bongos/metrics.js)',
123
123
  },
124
+ {
125
+ id: 'module-test-runner', file: 'web.env', need: 'optional',
126
+ names: ['MODULE_TEST_REPO', 'MODULE_TEST_TOKEN'],
127
+ purpose: "runs store modules' own tests in a separate private GitHub repo (scripts/gds/module-test-runner.js, blocker 1000195); unset leaves Tests pending",
128
+ },
129
+ {
130
+ id: 'module-test-ref', file: 'web.env', need: 'optional',
131
+ names: ['MODULE_TEST_REF'], notSecret: true,
132
+ purpose: "the test repo branch holding the module-tests workflow (default main)",
133
+ },
124
134
  {
125
135
  id: 'hub-signing-key', file: 'web.env', need: 'optional',
126
136
  names: ['HUB_SIGNING_KEY'], prefixed: true,
@@ -22,9 +22,9 @@ stays `pending`.
22
22
  MODULE_TEST_REPO=<owner>/<repo>
23
23
  MODULE_TEST_TOKEN=<the token>
24
24
  ```
25
- Optional: `MODULE_TEST_CORE_REPO=<owner>/<name>` if the server's git `origin` is not
26
- the public core repo, and `MODULE_TEST_REF=<branch>` if the test repo's workflow
27
- lives on a branch other than `main`.
25
+ Optional: `MODULE_TEST_REF=<branch>` if the test repo's workflow lives on a
26
+ branch other than `main`. Nothing names the core repo: the runner gets the
27
+ core from the test repo itself (see the header of `module-test-runner.js`).
28
28
 
29
29
  On the runner, a module's tests run as a separate user with no admin rights and
30
30
  no network access (only the machine's own loopback), after the core is installed.
@@ -6,7 +6,8 @@
6
6
  # nothing here ever holds a DB credential or a control-plane secret.
7
7
  #
8
8
  # Two jobs on purpose: `fetch` can read the repo (where the tarball waits on a
9
- # throwaway branch) but runs no module code; `test` runs the module's code with a
9
+ # throwaway branch, and the core snapshot on core/<sha>) but runs no module code;
10
+ # `test` runs the module's code with a
10
11
  # GITHUB_TOKEN that has NO permissions, so a module that scrapes its token still
11
12
  # cannot read another run's tarball. Inside `test`, the module's code runs as an
12
13
  # unprivileged user with its network cut (see "Cut the network for module code").
@@ -19,8 +20,7 @@ on:
19
20
  version_id: { description: store_module_versions.id, required: true, type: string }
20
21
  module_key: { description: the module's key, required: true, type: string }
21
22
  nonce: { description: this run's nonce (32 hex), required: true, type: string }
22
- core_repo: { description: owner/name of the public core, required: true, type: string }
23
- core_ref: { description: the core commit to test against, required: true, type: string }
23
+ core_ref: { description: the core commit to test against (40 hex; branch core/<sha> holds it), required: true, type: string }
24
24
 
25
25
  permissions: {}
26
26
 
@@ -31,14 +31,21 @@ jobs:
31
31
  timeout-minutes: 5
32
32
  steps:
33
33
  - name: Check the inputs
34
- env: { NONCE: "${{ inputs.nonce }}", VERSION_ID: "${{ inputs.version_id }}" }
34
+ env: { NONCE: "${{ inputs.nonce }}", VERSION_ID: "${{ inputs.version_id }}", CORE_REF: "${{ inputs.core_ref }}" }
35
35
  run: |
36
36
  [[ "$NONCE" =~ ^[0-9a-f]{32}$ ]] || { echo "bad nonce"; exit 1; }
37
37
  [[ "$VERSION_ID" =~ ^[0-9]+$ ]] || { echo "bad version id"; exit 1; }
38
+ [[ "$CORE_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "bad core ref"; exit 1; }
38
39
  - uses: actions/checkout@v4
39
40
  with: { ref: "module-test/${{ inputs.nonce }}", path: tarball, persist-credentials: false }
41
+ # The core repo is private, so the control plane pushes a snapshot of the
42
+ # core it runs to core/<sha> in THIS repo (once per commit; task 1004739).
43
+ - uses: actions/checkout@v4
44
+ with: { ref: "core/${{ inputs.core_ref }}", path: core-snapshot, persist-credentials: false }
40
45
  - uses: actions/upload-artifact@v4
41
46
  with: { name: "tarball-${{ inputs.nonce }}", path: tarball/module.tgz, retention-days: 1 }
47
+ - uses: actions/upload-artifact@v4
48
+ with: { name: "core-${{ inputs.nonce }}", path: core-snapshot/core.tgz, retention-days: 1, compression-level: 0 }
42
49
 
43
50
  test:
44
51
  needs: fetch
@@ -46,12 +53,17 @@ jobs:
46
53
  permissions: {}
47
54
  timeout-minutes: 30
48
55
  steps:
49
- - uses: actions/checkout@v4
50
- with: { repository: "${{ inputs.core_repo }}", ref: "${{ inputs.core_ref }}", path: core, persist-credentials: false }
51
56
  - uses: actions/setup-node@v4
52
57
  with: { node-version: 22 }
53
58
  - uses: actions/download-artifact@v4
54
59
  with: { name: "tarball-${{ inputs.nonce }}", path: in }
60
+ - uses: actions/download-artifact@v4
61
+ with: { name: "core-${{ inputs.nonce }}", path: core-in }
62
+ - name: Unpack the core
63
+ run: |
64
+ mkdir core
65
+ tar -xzf core-in/core.tgz -C core
66
+ rm -rf core-in
55
67
  - name: Install the core
56
68
  working-directory: core
57
69
  run: npm ci --no-audit --no-fund
@@ -13,18 +13,31 @@
13
13
  // THE ROUND TRIP. Nothing on the runner ever holds a DB credential:
14
14
  // 1. dispatch — the control plane pushes the version's tarball to a throwaway
15
15
  // orphan branch `module-test/<nonce>` in the test repo and dispatches the
16
- // workflow with { version_id, module_key, nonce, core_repo, core_ref }. The
17
- // nonce goes into the pending Tests row's detail.runner.
16
+ // workflow with { version_id, module_key, nonce, core_ref }. The nonce goes
17
+ // into the pending Tests row's detail.runner.
18
18
  // 2. the run — a `fetch` job (contents: read, no module code) copies the tarball
19
- // into the run as an artifact; a separate `test` job (permissions: {}) checks
20
- // out the core at core_ref, runs module-assess-tests.js --tarball with
21
- // MODULE_TEST_SANDBOX=1, and uploads the signal JSON as artifact
19
+ // and the core snapshot into the run as artifacts; a separate `test` job
20
+ // (permissions: {}) unpacks the core, runs module-assess-tests.js --tarball
21
+ // with MODULE_TEST_SANDBOX=1, and uploads the signal JSON as artifact
22
22
  // module-tests-<version_id>-<nonce>.
23
23
  // 3. collect — the control plane PULLS: it finds the run whose run-name carries
24
24
  // the pending row's nonce, takes only that run's artifact of that exact name,
25
25
  // checks the payload names the same version and nonce, inserts the signal row
26
26
  // itself, recomposes the score, and deletes the branch.
27
27
  //
28
+ // WHY THE CORE TRAVELS AS A SNAPSHOT (task 1004739). The core repo is private, so
29
+ // a runner whose token has no permissions cannot check it out by name. Before the
30
+ // first run against a core commit, dispatch pushes `git archive` of that commit
31
+ // (tracked files only, so never node_modules; minus the design mocks in
32
+ // .impeccable/, which nothing runs) to an orphan branch `core/<sha>` in the test
33
+ // repo, with the same token. Once per commit: a later dispatch finds the ref and
34
+ // reuses it. The `fetch` job reads it with its contents: read token like the
35
+ // tarball, so the `test` job still needs no permission and no repo name at all.
36
+ // GitHub refuses a blob over 100 MB and the archive goes up base64-encoded (a
37
+ // third bigger), so a snapshot over MAX_CORE_BYTES fails the dispatch loudly: a
38
+ // dispatch that cannot be sent is logged as an error, not a warning, because the
39
+ // version's Tests part then stays pending until someone fixes the cause.
40
+ //
28
41
  // WHY A PER-RUN BRANCH, NOT A DOWNLOAD TICKET. A ticket would mean a new
29
42
  // unauthenticated route on the control plane (and a change to the protected
30
43
  // route-rank-check), i.e. a fresh inbound path to the server holding the secrets.
@@ -37,53 +50,50 @@
37
50
  // tests pass, so the score is never trust-critical; what this protects is the
38
51
  // secrets and other authors' code.
39
52
  //
40
- // Configured by three control-plane env vars (all unset = off; Tests then stays
41
- // `pending`, exactly as before this task):
53
+ // Configured by control-plane env vars (repo + token unset = off; Tests then
54
+ // stays `pending`, exactly as before this task):
42
55
  // MODULE_TEST_REPO owner/name of the private test repo
43
56
  // MODULE_TEST_TOKEN a fine-grained token scoped to ONLY that repo, with
44
57
  // Actions: read & write and Contents: read & write
45
- // MODULE_TEST_CORE_REPO owner/name of the public core the runner checks out
46
- // (default: this checkout's `origin`)
47
58
  // MODULE_TEST_REF the test repo branch holding the workflow (default main)
48
59
  //
49
60
  // node scripts/gds/module-test-runner.js dispatch <store_module_versions.id>
50
61
  // node scripts/gds/module-test-runner.js collect
51
62
 
52
63
  const fs = require('node:fs');
64
+ const path = require('node:path');
53
65
  const crypto = require('node:crypto');
54
66
  const zlib = require('node:zlib');
55
- const { execFileSync } = require('node:child_process');
67
+ const { execFile } = require('node:child_process');
68
+ const { promisify } = require('node:util');
69
+
70
+ const execFileAsync = promisify(execFile);
56
71
 
57
72
  const WORKFLOW_FILE = 'module-tests.yml';
58
73
  const BRANCH_PREFIX = 'module-test/';
74
+ const CORE_BRANCH_PREFIX = 'core/';
59
75
  const API = 'https://api.github.com';
60
76
  // A run that has not finished this long after dispatch is recorded not_scored.
61
77
  const MAX_RUN_AGE_MS = 6 * 60 * 60_000;
62
78
  const MAX_SIGNAL_BYTES = 256 * 1024;
79
+ // GitHub's blob limit is 100 MB and the snapshot is sent base64 (4/3 the size).
80
+ const MAX_CORE_BYTES = 72 * 1024 * 1024;
81
+ const CORE_ROOT = path.join(__dirname, '..', '..');
63
82
  const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
64
83
  const NONCE_RE = /^[0-9a-f]{32}$/;
84
+ const SHA_RE = /^[0-9a-f]{40}$/;
65
85
 
66
86
  const runName = (versionId, nonce) => `module-tests ${versionId} ${nonce}`;
67
87
  const artifactName = (versionId, nonce) => `module-tests-${versionId}-${nonce}`;
68
88
 
69
- function originRepo() {
70
- try {
71
- const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: __dirname, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
72
- const m = url.match(/github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?$/);
73
- return m ? m[1] : null;
74
- } catch { return null; }
75
- }
76
-
77
89
  // The runner config, or null when it is not set up (the default: Tests stays pending).
78
- function runnerConfig(env = process.env, { origin = originRepo } = {}) {
90
+ function runnerConfig(env = process.env) {
79
91
  const repo = (env.MODULE_TEST_REPO || '').trim();
80
92
  const token = (env.MODULE_TEST_TOKEN || '').trim();
81
93
  if (!repo || !token) return null;
82
94
  if (!REPO_RE.test(repo)) throw new Error(`MODULE_TEST_REPO must be owner/name, got "${repo}"`);
83
- const coreRepo = (env.MODULE_TEST_CORE_REPO || '').trim() || origin();
84
- if (!coreRepo || !REPO_RE.test(coreRepo)) throw new Error('MODULE_TEST_CORE_REPO is unset and the core repo could not be read from git origin');
85
95
  const ref = (env.MODULE_TEST_REF || '').trim() || 'main';
86
- return { repo, token, coreRepo, ref };
96
+ return { repo, token, ref };
87
97
  }
88
98
 
89
99
  // One GitHub REST call. Resolves the parsed body (or null for 204); throws with
@@ -104,7 +114,54 @@ async function gh(cfg, method, urlPath, body, { fetchImpl = fetch } = {}) {
104
114
  // The commit the control plane is running, so the runner tests against the same core.
105
115
  function currentCoreRef() {
106
116
  const { buildInfo } = require('../../src/build-info');
107
- return buildInfo().commit || 'main';
117
+ return buildInfo().commit;
118
+ }
119
+
120
+ // The core at one commit, as the tar.gz the runner unpacks. Async, so the web
121
+ // process keeps serving while git compresses. It needs the commit in a git
122
+ // checkout here: a vendored core (node_modules/@bongos/core) has none, and gets
123
+ // an error that says so rather than git's own.
124
+ async function archiveCore(sha) {
125
+ try {
126
+ const { stdout } = await execFileAsync('git', ['archive', '--format=tar.gz', sha, '--', '.', ':(exclude).impeccable', ':(exclude)node_modules'],
127
+ { cwd: CORE_ROOT, encoding: 'buffer', maxBuffer: 4 * MAX_CORE_BYTES });
128
+ return stdout;
129
+ } catch (e) {
130
+ const why = String((e.stderr && e.stderr.toString()) || e.message).trim().split('\n')[0];
131
+ throw new Error(`could not snapshot core commit ${sha.slice(0, 8)}: the core must run from a git checkout that holds it (a vendored install has none) — git said: ${why}`);
132
+ }
133
+ }
134
+
135
+ // Push a one-file orphan commit to a new branch of the test repo.
136
+ async function pushOrphanBranch(cfg, branch, file, bytes, message, { fetchImpl } = {}) {
137
+ const r = `/repos/${cfg.repo}`;
138
+ const blob = await gh(cfg, 'POST', `${r}/git/blobs`, { content: bytes.toString('base64'), encoding: 'base64' }, { fetchImpl });
139
+ const tree = await gh(cfg, 'POST', `${r}/git/trees`, { tree: [{ path: file, mode: '100644', type: 'blob', sha: blob.sha }] }, { fetchImpl });
140
+ const commit = await gh(cfg, 'POST', `${r}/git/commits`, { message, tree: tree.sha, parents: [] }, { fetchImpl });
141
+ await gh(cfg, 'POST', `${r}/git/refs`, { ref: `refs/heads/${branch}`, sha: commit.sha }, { fetchImpl });
142
+ }
143
+
144
+ // Make sure branch core/<sha> holds the core snapshot, pushing it only the first
145
+ // time. Resolves { branch, pushed }. Two dispatches racing on a new commit both
146
+ // push; the loser's ref create is refused (422) and it uses the winner's.
147
+ async function ensureCoreSnapshot(cfg, sha, { fetchImpl, archive = archiveCore } = {}) {
148
+ if (!SHA_RE.test(sha || '')) throw new Error(`the running core commit is unknown ("${sha || ''}"), so there is no core to test against`);
149
+ const branch = `${CORE_BRANCH_PREFIX}${sha}`;
150
+ try {
151
+ await gh(cfg, 'GET', `/repos/${cfg.repo}/git/ref/heads/${branch}`, null, { fetchImpl });
152
+ return { branch, pushed: false };
153
+ } catch (e) { if (e.status !== 404) throw e; }
154
+ const tgz = await archive(sha);
155
+ if (tgz.length > MAX_CORE_BYTES) {
156
+ throw new Error(`the core snapshot for ${sha.slice(0, 8)} is ${(tgz.length / 1048576).toFixed(1)} MB, over the ${MAX_CORE_BYTES / 1048576} MB the test repo can take; trim what archiveCore sends in scripts/gds/module-test-runner.js`);
157
+ }
158
+ try {
159
+ await pushOrphanBranch(cfg, branch, 'core.tgz', tgz, `core snapshot ${sha}`, { fetchImpl });
160
+ } catch (e) {
161
+ if (e.status === 422) return { branch, pushed: false };
162
+ throw e;
163
+ }
164
+ return { branch, pushed: true };
108
165
  }
109
166
 
110
167
  function readVersionTarball(ver) {
@@ -112,27 +169,26 @@ function readVersionTarball(ver) {
112
169
  return fs.promises.readFile(versionArtifactFile(ver));
113
170
  }
114
171
 
115
- // Push the tarball to a throwaway orphan branch and dispatch the workflow.
116
- // Resolves the runner record kept in the pending row: { nonce, branch, dispatched_at }.
117
- async function dispatchRun(ver, { cfg, fetchImpl, readTarball = readVersionTarball, coreRef = currentCoreRef, now = () => new Date() } = {}) {
172
+ // Make sure the core snapshot is there, push the tarball to a throwaway orphan
173
+ // branch, and dispatch the workflow. Resolves the runner record kept in the
174
+ // pending row: { nonce, branch, core_ref, dispatched_at }.
175
+ async function dispatchRun(ver, { cfg, fetchImpl, readTarball = readVersionTarball, coreRef = currentCoreRef, archive, now = () => new Date() } = {}) {
176
+ const sha = coreRef();
177
+ await ensureCoreSnapshot(cfg, sha, { fetchImpl, archive });
118
178
  const tgz = await readTarball(ver);
119
179
  const nonce = crypto.randomBytes(16).toString('hex');
120
180
  const branch = `${BRANCH_PREFIX}${nonce}`;
121
- const r = `/repos/${cfg.repo}`;
122
- const blob = await gh(cfg, 'POST', `${r}/git/blobs`, { content: tgz.toString('base64'), encoding: 'base64' }, { fetchImpl });
123
- const tree = await gh(cfg, 'POST', `${r}/git/trees`, { tree: [{ path: 'module.tgz', mode: '100644', type: 'blob', sha: blob.sha }] }, { fetchImpl });
124
- const commit = await gh(cfg, 'POST', `${r}/git/commits`, { message: `module tests: version ${ver.id}`, tree: tree.sha, parents: [] }, { fetchImpl });
125
- await gh(cfg, 'POST', `${r}/git/refs`, { ref: `refs/heads/${branch}`, sha: commit.sha }, { fetchImpl });
181
+ await pushOrphanBranch(cfg, branch, 'module.tgz', tgz, `module tests: version ${ver.id}`, { fetchImpl });
126
182
  try {
127
- await gh(cfg, 'POST', `${r}/actions/workflows/${WORKFLOW_FILE}/dispatches`, {
183
+ await gh(cfg, 'POST', `/repos/${cfg.repo}/actions/workflows/${WORKFLOW_FILE}/dispatches`, {
128
184
  ref: cfg.ref || 'main',
129
- inputs: { version_id: String(ver.id), module_key: ver.module_key, nonce, core_repo: cfg.coreRepo, core_ref: coreRef() },
185
+ inputs: { version_id: String(ver.id), module_key: ver.module_key, nonce, core_ref: sha },
130
186
  }, { fetchImpl });
131
187
  } catch (e) {
132
188
  await deleteBranch(cfg, branch, { fetchImpl });
133
189
  throw e;
134
190
  }
135
- return { nonce, branch, dispatched_at: now().toISOString() };
191
+ return { nonce, branch, core_ref: sha, dispatched_at: now().toISOString() };
136
192
  }
137
193
 
138
194
  async function deleteBranch(cfg, branch, { fetchImpl } = {}) {
@@ -162,7 +218,7 @@ async function maybeDispatch(ver, { db, env = process.env, log = console, ...opt
162
218
  if (newest && !(newest.outcome === 'pending' && !(newest.detail && newest.detail.runner))) return null;
163
219
  let runner;
164
220
  try { runner = await dispatchRun(ver, { cfg, ...opts }); }
165
- catch (e) { log.warn(`module test run for version ${ver.id} not dispatched: ${e.message}`); return null; }
221
+ catch (e) { log.error(`module test run for version ${ver.id} not dispatched: ${e.message}`); return null; }
166
222
  await db.query(
167
223
  `INSERT INTO module_assessment_signals (version_id, part, outcome, score, sample_size, reason, detail)
168
224
  VALUES ($1, 'tests', 'pending', NULL, NULL, $2, $3)`,
@@ -355,4 +411,4 @@ if (require.main === module) {
355
411
  main(process.argv.slice(2)).then((code) => { process.exitCode = code; }, (e) => { console.error(e.stack || e.message); process.exitCode = 1; });
356
412
  }
357
413
 
358
- module.exports = { runnerConfig, maybeDispatch, collectResults, sweepTick, readZipEntry, validateSignal, runName, artifactName };
414
+ module.exports = { runnerConfig, archiveCore, maybeDispatch, collectResults, sweepTick, readZipEntry, validateSignal, runName, artifactName };
@@ -452,14 +452,16 @@ function formatCatalog(rows, { catalogOnly = false, query = '', core = null, del
452
452
  return out.join('\n');
453
453
  }
454
454
 
455
- out.push(` ${pad('', 5)}${pad('KEY', 20)}${pad('VERSION', 9)}${pad('SOURCE', 10)}${pad('AUTHOR', 20)}${pad('ORIGIN', 26)}${pad('LICENSE', 12)}UPKEEP`);
455
+ out.push(` ${pad('', 5)}${pad('KEY', 20)}${pad('VERSION', 9)}${pad('SOURCE', 10)}${pad('CATEGORY', 28)}${pad('AUTHOR', 20)}${pad('ORIGIN', 26)}${pad('LICENSE', 12)}UPKEEP`);
456
456
  for (const r of shown) {
457
457
  const mark = r.enabled ? '[x]' : '[ ]';
458
458
  // An inferred credit is marked so it never reads as something the author
459
459
  // typed — the catalog's whole job is honest attribution (ADR 0107 §2).
460
460
  const suffix = r.credit.inferred ? '*' : '';
461
461
  const upkeep = ((r.maintenance && r.maintenance.status) || 'undeclared') + (delisted.has(r.key) ? ' · DELISTED' : '');
462
- out.push(` ${pad(mark, 5)}${pad(clip(r.key, 19), 20)}${pad(clip(r.version || '—', 8), 9)}${pad(r.source, 10)}${pad(clip((r.credit.author || '—') + suffix, 19), 20)}${pad(clip((r.credit.origin || '—') + suffix, 25), 26)}${pad(clip(r.license || '—', 11), 12)}${upkeep}`);
462
+ // The parent key, plus the sub-category once one is approved (ADR 0342).
463
+ const cat = r.category ? r.category.parent + (r.category.sub ? `/${r.category.sub}` : '') : '—';
464
+ out.push(` ${pad(mark, 5)}${pad(clip(r.key, 19), 20)}${pad(clip(r.version || '—', 8), 9)}${pad(r.source, 10)}${pad(clip(cat, 27), 28)}${pad(clip((r.credit.author || '—') + suffix, 19), 20)}${pad(clip((r.credit.origin || '—') + suffix, 25), 26)}${pad(clip(r.license || '—', 11), 12)}${upkeep}`);
463
465
  }
464
466
  out.push('');
465
467
  out.push(' [x] enabled here · [ ] available, not enabled');
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.150'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.152'; // 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');
@@ -30,6 +30,7 @@
30
30
  const fs = require('node:fs');
31
31
  const path = require('node:path');
32
32
  const { deriveProvenance } = require('./provenance');
33
+ const { CATEGORY_PARENTS } = require('./manifest-schema');
33
34
 
34
35
  // The core's own published + source package names. A checkout whose package.json
35
36
  // says one of these IS the core, so its modules/ IS the catalog (see
@@ -99,6 +100,17 @@ function creditFor(manifest = {}, { source = 'instance', coreProjectName = CORE_
99
100
  };
100
101
  }
101
102
 
103
+ // Where a module is browsed (ADR 0342): its parent key, that parent's label, and its
104
+ // sub-category key — or null when the manifest declares none. The label is resolved
105
+ // HERE so the hall and the CLI never keep their own copy of the parent list. A parent
106
+ // outside the closed list cannot reach this (the validator refuses it), so an unknown
107
+ // one is null rather than shown under a label nobody chose.
108
+ function categoryFor(manifest = {}) {
109
+ const c = manifest.category;
110
+ if (!c || typeof c !== 'object' || !Object.hasOwn(CATEGORY_PARENTS, c.parent)) return null;
111
+ return { parent: c.parent, parentTitle: CATEGORY_PARENTS[c.parent], sub: c.sub || null };
112
+ }
113
+
102
114
  // One catalog row per discovered module. `loaded` is the loader's
103
115
  // discoverModules().loaded ([{ key, dir, manifest }]); `enabled` is the resolved
104
116
  // enablement map (src/modules.js modules()).
@@ -138,6 +150,7 @@ function buildCatalog({
138
150
  license: manifest.license || null,
139
151
  maintainer: manifest.maintainer || null,
140
152
  maintenance: manifest.maintenance || null,
153
+ category: categoryFor(manifest),
141
154
  };
142
155
  })
143
156
  .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
@@ -160,7 +173,8 @@ function instanceRows(rows = []) {
160
173
  function searchCatalog(rows = [], query = '') {
161
174
  const q = String(query || '').trim().toLowerCase();
162
175
  if (!q) return rows;
163
- return rows.filter((r) => [r.key, r.title, r.description, r.credit.author, r.credit.origin, r.license]
176
+ return rows.filter((r) => [r.key, r.title, r.description, r.credit.author, r.credit.origin, r.license,
177
+ r.category && r.category.parentTitle, r.category && r.category.sub]
164
178
  .some((f) => f && String(f).toLowerCase().includes(q)));
165
179
  }
166
180
 
@@ -53,6 +53,10 @@
53
53
  // // actually declares in `contributes`, and every entry must be
54
54
  // // one it actually lists there — a typo'd exemption is an error,
55
55
  // // never a silent pass.
56
+ // "category": { // OPTIONAL. Where the module is browsed and ranked (ADR 0342).
57
+ // "parent": "money", // REQUIRED in-block: one of the eight CATEGORY_PARENTS keys.
58
+ // "sub": "stripe-payments" // OPTIONAL. An approved sub-category key — the comparable set
59
+ // }, // the store's cap of ten counts within; absent while pending.
56
60
  // "howto": { // OPTIONAL. Extras beside the module's HOWTO.md (ADR 0347 D3).
57
61
  // "artifactUrl": "https://claude.ai/…" // A Claude page with the same how-to, shown as an extra,
58
62
  // }, // never graded and never required — the file is the how-to.
@@ -106,6 +110,24 @@ const SPEND_KEYS = ['requiresPayer'];
106
110
  // The `howto` block (ADR 0347 D3) — closed for the same reason; not exported either.
107
111
  const HOWTO_KEYS = ['artifactUrl'];
108
112
 
113
+ // The eight fixed parent categories (ADR 0342 D1), key → the label a catalog shows.
114
+ // A parent is for browsing only: the cap of ten counts within a SUB-category, never a
115
+ // parent (D2). The list is closed so a typo'd "monye" fails loud rather than filing
116
+ // the module under a ninth parent nobody browses; changing it needs a new ADR.
117
+ // `sub` is shape-checked only — which sub-categories are approved is ledger state
118
+ // (task 1004355), not something a pure validator can know.
119
+ const CATEGORY_PARENTS = Object.freeze({
120
+ work: 'Work & planning',
121
+ people: 'Team & people',
122
+ quality: 'Quality & review',
123
+ money: 'Money',
124
+ communication: 'Communication',
125
+ pages: 'Pages & design',
126
+ hosting: 'Hosting & deploy',
127
+ knowledge: 'Knowledge',
128
+ });
129
+ const CATEGORY_KEYS = ['parent', 'sub'];
130
+
109
131
  // An https link on claude.ai (or a subdomain). Shape only: the store never fetches it.
110
132
  function isClaudeArtifactUrl(v) {
111
133
  if (!isStr(v)) return false;
@@ -320,6 +342,23 @@ function validateManifest(obj) {
320
342
  }
321
343
  }
322
344
 
345
+ // --- optional category (ADR 0342) ---
346
+ if ('category' in obj) {
347
+ const c = obj.category;
348
+ const parents = Object.keys(CATEGORY_PARENTS);
349
+ if (!isObj(c)) E('category: must be an object (e.g. { "parent": "money", "sub": "stripe-payments" })');
350
+ else {
351
+ for (const k of Object.keys(c)) {
352
+ if (!CATEGORY_KEYS.includes(k)) E(`category.${k}: unknown key (allowed: ${CATEGORY_KEYS.join(', ')}) — a module has exactly one sub-category (ADR 0342 D4)`);
353
+ }
354
+ if (!isStr(c.parent)) E(`category.parent: required — one of ${parents.join(', ')}`);
355
+ else if (!parents.includes(c.parent)) E(`category.parent: "${c.parent}" is not one of ${parents.join(', ')} (ADR 0342 D1)`);
356
+ if ('sub' in c && (!isStr(c.sub) || !KEY_RE.test(c.sub))) {
357
+ E('category.sub: must be a sub-category key (lowercase kebab-case) when present — leave it out while a new one is pending approval');
358
+ }
359
+ }
360
+ }
361
+
323
362
  // --- optional how-to extras (ADR 0347 D3) ---
324
363
  if ('howto' in obj) {
325
364
  const h = obj.howto;
@@ -370,5 +409,5 @@ function assertValidManifest(obj, label = 'module.json') {
370
409
 
371
410
  module.exports = {
372
411
  validateManifest, assertValidManifest, declaredEntries, entryLabel, KEY_RE, ALLOWED_CONTRIBUTES, ARRAY_OF_STRING_KEYS, ATTRIBUTION_KEYS,
373
- MAINTENANCE_STATUSES, MAINTENANCE_KEYS, SUNSET_STATUSES,
412
+ MAINTENANCE_STATUSES, MAINTENANCE_KEYS, SUNSET_STATUSES, CATEGORY_PARENTS,
374
413
  };
@@ -58,7 +58,7 @@ test('modules: the row is the oversight row — title with the mono key, clamped
58
58
  assert.match(js, /<span class="ov-row__title">\$\{escapeHtml\(m\.title \|\| m\.key\)\}<span class="ov-mono">\$\{escapeHtml\(m\.key\)\}<\/span>/, 'the key rides inside the title in mono');
59
59
  assert.match(js, /class="ov-row__sub mod-row__desc"/, 'the description is the sub line');
60
60
  assert.match(js, /<details class="mod-more"/, 'a details disclosure opens the rest');
61
- assert.match(js, /<div class="ov-row__facts">\$\{provenancePill\(m\)\}\$\{attributionFacts\(m\)\}\$\{upkeepFact\(m\)\}\$\{howtoFact\(m\)\}\$\{delistedFact\(m\)\}\$\{restartFact\(m\)\}<\/div>/, 'provenance, credit, upkeep, (for a store version, task 1004366) the how-to link and (task 1003797) the delisted pill and (task 1004463) the waits-for-a-restart pill are the facts line');
61
+ assert.match(js, /<div class="ov-row__facts">\$\{provenancePill\(m\)\}\$\{categoryFact\(m\)\}\$\{attributionFacts\(m\)\}\$\{upkeepFact\(m\)\}\$\{howtoFact\(m\)\}\$\{delistedFact\(m\)\}\$\{restartFact\(m\)\}<\/div>/, 'provenance, (task 1003784) the category, credit, upkeep, (for a store version, task 1004366) the how-to link and (task 1003797) the delisted pill and (task 1004463) the waits-for-a-restart pill are the facts line');
62
62
  assert.match(js, /<div class="ov-row__right mod-row__right">\$\{rightHtml\(m\)\}<\/div>/, 'the right column is one function');
63
63
  const css = read('modules.css');
64
64
  assert.match(css, /\.mod-row__desc \{[^}]*-webkit-line-clamp: 2/, 'the description clamps to two lines');
@@ -84,6 +84,9 @@ test('modules: upkeep colours the exception — undeclared is the warn pill, mai
84
84
  assert.match(js, /<span class="ov-fact">upkeep <b>\$\{escapeHtml\(status\)\}<\/b><\/span>/, 'a maintained module reads as a plain fact');
85
85
  assert.match(js, /fact-pill fact-pill--info">core-loaded</, 'core-loaded is the info pill');
86
86
  assert.match(js, /<span class="fact-chip">\$\{escapeHtml\(m\.license\)\}<\/span>/, 'the licence is a chip');
87
+ assert.match(js, /<span class="ov-fact">in <b>\$\{escapeHtml\(c\.parentTitle\)\}<\/b>\$\{sub\}<\/span>/,
88
+ 'the category is a plain fact, labelled from the server row (task 1003784)');
89
+ assert.match(js, /cat\.parentTitle, cat\.sub\]/, 'the hall search covers the category, as searchCatalog does');
87
90
  });
88
91
 
89
92
  test('modules: the kit filter bar carries the search and a state lens, and the empty state is the kit\'s', () => {
@@ -330,3 +330,37 @@ test('detectRoots: an explicit root bypasses the memo in both directions', () =>
330
330
  // …nor poison the memo for every later caller that asks about this process.
331
331
  assert.equal(catalog.detectRoots(), cached);
332
332
  });
333
+
334
+ // ---------------------------------------------------------------------------
335
+ // category — the ADR 0342 field, carried by the ONE model to both surfaces (task 1003784)
336
+ // ---------------------------------------------------------------------------
337
+
338
+ const categorized = () => catalog.buildCatalog({
339
+ loaded: [
340
+ loadedIn(CORE, 'economy', { category: { parent: 'money' } }),
341
+ loadedIn(CORE, 'discord', { category: { parent: 'communication', sub: 'team-chat' } }),
342
+ loadedIn(INST, 'game'),
343
+ ],
344
+ coreRoot: CORE, instanceRoot: INST,
345
+ });
346
+
347
+ test('buildCatalog: a row carries its category with the parent label; none declared is null', () => {
348
+ const byKey = Object.fromEntries(categorized().map((r) => [r.key, r]));
349
+ assert.deepEqual(byKey.economy.category, { parent: 'money', parentTitle: 'Money', sub: null });
350
+ assert.deepEqual(byKey.discord.category, { parent: 'communication', parentTitle: 'Communication', sub: 'team-chat' });
351
+ assert.equal(byKey.game.category, null);
352
+ });
353
+
354
+ test('searchCatalog: finds a module by its category label or sub-category', () => {
355
+ const rows = categorized();
356
+ assert.deepEqual(catalog.searchCatalog(rows, 'money').map((r) => r.key), ['economy']);
357
+ assert.deepEqual(catalog.searchCatalog(rows, 'team-chat').map((r) => r.key), ['discord']);
358
+ });
359
+
360
+ test('formatCatalog: the CATEGORY column shows parent/sub, and a dash when none is declared', () => {
361
+ const out = mod.formatCatalog(categorized());
362
+ assert.match(out, /CATEGORY/);
363
+ assert.match(out, /discord\s+\S+\s+core\s+communication\/team-chat/);
364
+ assert.match(out, /economy\s+\S+\s+core\s+money\s/);
365
+ assert.match(out, /game\s+\S+\s+instance\s+—\s/);
366
+ });
@@ -392,3 +392,39 @@ test('howto.artifactUrl: anything but an https claude.ai link is refused, as is
392
392
  .some((e) => e.startsWith('howto.artifactURL: unknown key')));
393
393
  assert.equal(validateManifest({ ...baseManifest(), howto: 'https://claude.ai/x' }).valid, false);
394
394
  });
395
+
396
+ // --- category (ADR 0342, task 1003784) ---
397
+
398
+ test('category: optional; a known parent, with or without a sub-category, is accepted', () => {
399
+ assert.equal(validateManifest(baseManifest()).valid, true, 'a manifest with no category still validates');
400
+ for (const category of [{ parent: 'money' }, { parent: 'communication', sub: 'team-chat' }]) {
401
+ const r = validateManifest({ ...baseManifest(), category });
402
+ assert.equal(r.valid, true, `${JSON.stringify(category)}: ${r.errors.join('; ')}`);
403
+ }
404
+ });
405
+
406
+ test('category: a parent outside the eight, a missing parent, a bad sub or an unknown key fails loud', () => {
407
+ const errs = (category) => validateManifest({ ...baseManifest(), category }).errors;
408
+ assert.ok(errs({ parent: 'monye' }).some((e) => e.startsWith('category.parent: "monye" is not one of')), 'a typo names the list');
409
+ assert.ok(errs({ sub: 'stripe-payments' }).some((e) => e.startsWith('category.parent: required')));
410
+ assert.ok(errs({ parent: 'money', sub: 'Stripe Payments' }).some((e) => e.startsWith('category.sub:')));
411
+ assert.ok(errs({ parent: 'money', sub: '' }).some((e) => e.startsWith('category.sub:')));
412
+ assert.ok(errs({ parent: 'money', subs: ['a', 'b'] }).some((e) => e.startsWith('category.subs: unknown key')),
413
+ 'one sub-category per module (D4) — a second field is a typo, not a list');
414
+ assert.equal(validateManifest({ ...baseManifest(), category: 'money' }).valid, false);
415
+ });
416
+
417
+ test('category: the eight parents are exactly ADR 0342 D1', () => {
418
+ const { CATEGORY_PARENTS } = require(path.join(ROOT, 'src', 'module-loader', 'manifest-schema.js'));
419
+ assert.deepEqual(Object.keys(CATEGORY_PARENTS),
420
+ ['work', 'people', 'quality', 'money', 'communication', 'pages', 'hosting', 'knowledge']);
421
+ });
422
+
423
+ test('category: every bundled module declares one (core modules get categories too, ADR 0342)', async () => {
424
+ const fs = await import('node:fs');
425
+ const dir = path.join(ROOT, 'modules');
426
+ const missing = fs.readdirSync(dir)
427
+ .filter((k) => fs.existsSync(path.join(dir, k, 'module.json')))
428
+ .filter((k) => !JSON.parse(fs.readFileSync(path.join(dir, k, 'module.json'), 'utf8')).category);
429
+ assert.deepEqual(missing, [], `declare "category": { "parent": … } in: ${missing.join(', ')}`);
430
+ });